import { ref, nextTick } from 'vue'; import axios from 'axios'; import { Readability } from '@mozilla/readability'; // Module-level state — declared outside useFeeds() so every caller shares the // same singleton refs (a Pinia-free "store" for the feed list and its UI state). const showMessage = ref(false) const feeds = ref([]); const message = ref('') const showModal = ref(false) const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu const currentIndex = ref(0) const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu let observer; // Declare observer outside the setup function let initialLoad = false export function authHeaders() { return { headers: { 'Content-Type': 'application/json', 'user-token': localStorage.getItem("user-token") } } } // Tells the server to revoke the current token (bumps token_version, so any // other outstanding tokens for this account are invalidated too) before // clearing the local session. Best-effort: if the request fails (e.g. the // token already expired) the local session is cleared regardless. export async function logout() { try { await axios.post('/api/v1/auth/logout', null, authHeaders()) } catch (error) { console.error('Error logging out', error) } localStorage.removeItem('user-token') localStorage.removeItem('user-id') } // Some feeds (e.g. Deutsche Welle) ship tags whose `src` and various // lazy-load attributes (`data-url`, `data-src`, `srcset`, ...) contain an // unresolved `${placeholderName}` template — or its URL-encoded `%7B...%7D` // form — that their own frontend fills in from the sibling `data-format` // attribute before loading; verbatim they 404. Resolve every such attribute // the same way (so Readability's own lazy-image handling can't resurrect a // stale template into `src`), preferring `data-url` as the source of truth // for `src`, and drop the entirely if a template still remains. const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/ const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g // `data-format` holds a symbolic name from DW's CMS (e.g. "MASTER_LANDSCAPE"), // but their image CDN only accepts numeric format ids in the URL — the // template's `${formatId}` literally means a number. Substituting the // symbolic name verbatim produces a 400 (image fails to load). DW generates // the same fixed set of numeric variants for every image, so map the // symbolic names we've seen to their numeric equivalent. const DW_FORMAT_IDS = { MASTER_LANDSCAPE: '6', // 940x529, 16:9 — matches DW's `16/9` aspect ratio } function resolveTemplatedImage(img) { const rawFormat = img.getAttribute('data-format') const format = rawFormat && (DW_FORMAT_IDS[rawFormat] ?? (/^\d+$/.test(rawFormat) ? rawFormat : null)) const dataUrl = img.getAttribute('data-url') if (format) { if (dataUrl && TEMPLATE_PATTERN.test(dataUrl)) { img.setAttribute('src', dataUrl.replace(TEMPLATE_PATTERN_GLOBAL, format)) } for (const attr of [...img.attributes]) { if (attr.name !== 'src' && TEMPLATE_PATTERN.test(attr.value)) { img.setAttribute(attr.name, attr.value.replace(TEMPLATE_PATTERN_GLOBAL, format)) } } } if (TEMPLATE_PATTERN.test(img.getAttribute('src') ?? '')) { img.remove() } } function showMessageForXSeconds(text, seconds) { message.value = text; showMessage.value = true; // Set a timeout to hide the message after x seconds setTimeout(() => { showMessage.value = false; message.value = ''; }, seconds * 1000); // Convert seconds to milliseconds } async function getReadable(feed, index) { try { const response = await axios.post("/api/v1/article/read", { url: feed.url }, authHeaders()) const doc = new DOMParser().parseFromString(response.data.content, 'text/html'); // Scraped articles often contain image/link URLs that are relative to the // source site. A tag makes the browser (and Readability) resolve // them against the article's original URL instead of our own origin. const base = doc.createElement('base'); base.setAttribute('href', feed.url); doc.head.prepend(base); doc.querySelectorAll('img').forEach(resolveTemplatedImage); doc.querySelectorAll('video, audio').forEach(el => el.remove()); // Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget // (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the // article body. It's not part of the article, so strip it before Readability // pulls it into the parsed content. doc.querySelectorAll('#article-social-bar').forEach(el => el.remove()) // Some feeds (e.g. Deutsche Welle) leave behind a heading + play-icon SVG // for an embedded video/audio player whose actual