222 lines
7.6 KiB
HTML
222 lines
7.6 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="{{ lang }}">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>{{ site_name }} — {{ lang | upper }}</title>
|
|
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
|
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
|
|
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='android-chrome-192x192.png') }}">
|
|
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='android-chrome-512x512.png') }}">
|
|
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/reset.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
|
</head>
|
|
<body>
|
|
|
|
<div id="header">
|
|
<h1>{{ site_name }}</h1>
|
|
{% if channel_url %}
|
|
<span class="sep">/</span>
|
|
<a class="channel" href="{{ channel_url }}" target="_blank">{{ channel_label }}</a>
|
|
{% endif %}
|
|
{% if default_feed_path %}
|
|
<a class="rss-link" href="/{{ lang }}/{{ default_feed_path }}" title="RSS feed ({{ lang | upper }})">
|
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="6.18" cy="17.82" r="2.18"/><path d="M4 4.44v2.83c7.03 0 12.73 5.7 12.73 12.73h2.83c0-8.59-6.97-15.56-15.56-15.56z"/><path d="M4 10.1v2.83c3.9 0 7.07 3.17 7.07 7.07h2.83c0-5.47-4.43-9.9-9.9-9.9z"/></svg>
|
|
RSS
|
|
</a>
|
|
{% endif %}
|
|
<span id="last-updated"></span>
|
|
<div class="live-dot"></div>
|
|
</div>
|
|
|
|
<div id="ticker-wrap">
|
|
<div id="ticker-label">{{ ui.live | default('LIVE') }}</div>
|
|
<div id="ticker-track"></div>
|
|
</div>
|
|
|
|
<div id="status"></div>
|
|
<div id="feed"></div>
|
|
|
|
<div id="footer">
|
|
{% for code, name in languages %}
|
|
<a href="/{{ code }}/" {% if code == lang %}class="active"{% endif %} title="{{ name }}">{{ code }}</a>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<div id="lb"><img id="lb-img" src="" alt="" /></div>
|
|
|
|
<script>
|
|
const LANG = '{{ lang }}';
|
|
const FEED = '{{ default_feed_path }}' ? '/{{ lang }}/{{ default_feed_path }}' : null;
|
|
const UI = {{ ui | tojson }};
|
|
let knownGuids = new Set();
|
|
|
|
function formatTime(d) {
|
|
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
function formatDate(d) {
|
|
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
|
}
|
|
function timeAgo(d) {
|
|
const s = Math.floor((Date.now() - d) / 1000);
|
|
if (s < 60) return s + 's ago';
|
|
if (s < 3600) return Math.floor(s/60) + 'm ago';
|
|
if (s < 86400) return Math.floor(s/3600) + 'h ago';
|
|
return Math.floor(s/86400) + 'd ago';
|
|
}
|
|
|
|
function proxyUrl(src) {
|
|
if (src.includes('telesco.pe')) return '/media?url=' + encodeURIComponent(src);
|
|
return src;
|
|
}
|
|
|
|
function extractImages(html) {
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
return [...doc.querySelectorAll('img')]
|
|
.map(i => i.getAttribute('src'))
|
|
.filter(s => s && !s.endsWith('.mp4'))
|
|
.map(proxyUrl);
|
|
}
|
|
|
|
function cleanTitle(t) {
|
|
return t.replace(/^\u{1f5bc}\s*/u, '').trim();
|
|
}
|
|
|
|
function buildCard(item, isNew) {
|
|
const date = new Date(item.pubDate);
|
|
const imgs = extractImages(item.description);
|
|
const title = cleanTitle(item.title);
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'card' + (isNew ? ' new' : '');
|
|
card.dataset.guid = item.guid;
|
|
|
|
// Time column
|
|
const timeCol = document.createElement('div');
|
|
timeCol.className = 'card-time';
|
|
const timeSpan = document.createElement('span');
|
|
timeSpan.className = 'time';
|
|
timeSpan.textContent = formatTime(date);
|
|
const dateSpan = document.createElement('span');
|
|
dateSpan.className = 'date';
|
|
dateSpan.textContent = formatDate(date);
|
|
timeCol.append(timeSpan, dateSpan);
|
|
|
|
// Body column
|
|
const body = document.createElement('div');
|
|
body.className = 'card-body';
|
|
|
|
const titleEl = document.createElement('div');
|
|
titleEl.className = 'card-title';
|
|
titleEl.textContent = title; // textContent — no HTML injection
|
|
|
|
body.appendChild(titleEl);
|
|
|
|
if (imgs.length) {
|
|
const imgWrap = document.createElement('div');
|
|
imgWrap.className = 'card-img';
|
|
imgs.forEach(src => {
|
|
const img = document.createElement('img');
|
|
img.src = src;
|
|
img.loading = 'lazy';
|
|
img.referrerPolicy = 'no-referrer';
|
|
img.onerror = () => img.remove();
|
|
img.addEventListener('click', () => {
|
|
document.getElementById('lb-img').src = img.src;
|
|
document.getElementById('lb').classList.add('open');
|
|
});
|
|
imgWrap.appendChild(img);
|
|
});
|
|
body.appendChild(imgWrap);
|
|
}
|
|
|
|
const linkEl = document.createElement('a');
|
|
linkEl.className = 'card-link';
|
|
linkEl.href = item.link; // set via property, not innerHTML
|
|
linkEl.target = '_blank';
|
|
linkEl.rel = 'noopener noreferrer';
|
|
linkEl.textContent = UI.open_link || 'Open in Telegram';
|
|
body.appendChild(linkEl);
|
|
|
|
card.append(timeCol, body);
|
|
return card;
|
|
}
|
|
|
|
function updateTicker(items) {
|
|
const titles = items.slice(0, 10).map(i => cleanTitle(i.title));
|
|
const doubled = [...titles, ...titles];
|
|
const track = document.getElementById('ticker-track');
|
|
track.textContent = ''; // clear safely
|
|
doubled.forEach(t => {
|
|
const span = document.createElement('span');
|
|
span.textContent = t;
|
|
track.appendChild(span);
|
|
});
|
|
}
|
|
|
|
async function loadFeed(initial) {
|
|
try {
|
|
const res = await fetch(FEED + '?_=' + Date.now());
|
|
const lastMod = res.headers.get('Last-Modified');
|
|
const feedAge = lastMod ? new Date(lastMod) : new Date();
|
|
|
|
const xml = new DOMParser().parseFromString(await res.text(), 'application/xml');
|
|
const items = [...xml.querySelectorAll('item')].map(n => ({
|
|
title: n.querySelector('title')?.textContent?.trim() || '',
|
|
description: n.querySelector('description')?.textContent || '',
|
|
link: n.querySelector('link')?.textContent?.trim() || '',
|
|
pubDate: n.querySelector('pubDate')?.textContent?.trim() || '',
|
|
guid: n.querySelector('guid')?.textContent?.trim() || '',
|
|
}));
|
|
|
|
const feed = document.getElementById('feed');
|
|
const status = document.getElementById('status');
|
|
|
|
if (initial) {
|
|
status.remove();
|
|
items.forEach(item => {
|
|
knownGuids.add(item.guid);
|
|
feed.appendChild(buildCard(item, false));
|
|
});
|
|
} else {
|
|
items.filter(i => !knownGuids.has(i.guid)).reverse().forEach(item => {
|
|
knownGuids.add(item.guid);
|
|
const card = buildCard(item, true);
|
|
feed.prepend(card);
|
|
setTimeout(() => card.classList.remove('new'), 3000);
|
|
});
|
|
}
|
|
|
|
// Cap knownGuids to prevent unbounded growth on long-running pages
|
|
if (knownGuids.size > 500) {
|
|
const arr = [...knownGuids];
|
|
knownGuids = new Set(arr.slice(arr.length - 250));
|
|
}
|
|
|
|
updateTicker(items);
|
|
document.getElementById('last-updated').textContent = 'feed ' + timeAgo(feedAge);
|
|
|
|
} catch(e) {
|
|
const status = document.getElementById('status');
|
|
if (status) status.textContent = (UI.error || 'FEED ERROR: ') + e.message;
|
|
}
|
|
}
|
|
|
|
document.getElementById('lb').addEventListener('click', () => {
|
|
document.getElementById('lb').classList.remove('open');
|
|
});
|
|
|
|
document.getElementById('status').textContent = UI.connecting || 'CONNECTING TO FEED...';
|
|
|
|
if (FEED) {
|
|
loadFeed(true);
|
|
setInterval(() => loadFeed(false), 5 * 60 * 1000);
|
|
} else {
|
|
document.getElementById('status').textContent = 'NO FEED CONFIGURED — set RSSHUB_DEFAULT_PATH';
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|