rsshubtrans/translator/templates/reader.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>