import { useSignal } from '@preact/signals'; import { NewsFeed } from '/lib/types.ts'; import { escapeHtml, validateUrl } from '/lib/utils/misc.ts'; import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/news/add-feed.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/news/delete-feed.tsx'; import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody, } from '/routes/api/news/import-feeds.tsx'; interface FeedsProps { initialFeeds: NewsFeed[]; } function formatNewsFeedsToOpml(feeds: NewsFeed[]) { return ` Subscriptions ${ feeds.map((feed) => `` ).join('\n ') } `; } function parseOpmlFromTextContents(html: string): string[] { const feedUrls: string[] = []; const document = new DOMParser().parseFromString(html, 'text/html'); const feeds = Array.from(document.getElementsByTagName('outline')); for (const feed of feeds) { const url = (feed.getAttribute('xmlUrl') || feed.getAttribute('htmlUrl') || '').trim(); if (validateUrl(url)) { feedUrls.push(url); } } return feedUrls; } export default function Feeds({ initialFeeds }: FeedsProps) { const isAdding = useSignal(false); const isDeleting = useSignal(false); const isExporting = useSignal(false); const isImporting = useSignal(false); const feeds = useSignal(initialFeeds); const isOptionsDropdownOpen = useSignal(false); const dateFormatOptions: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }; const dateFormat = new Intl.DateTimeFormat('en-GB', dateFormatOptions); async function onClickAddFeed() { if (isAdding.value) { return; } const url = (prompt(`What's the **URL** for the new feed?`) || '').trim(); if (!url) { alert('A URL is required for a new feed!'); return; } if (!validateUrl(url)) { alert('Invalid URL!'); return; } isAdding.value = true; try { const requestBody: AddRequestBody = { feedUrl: url }; const response = await fetch(`/api/news/add-feed`, { method: 'POST', body: JSON.stringify(requestBody), }); if (!response.ok) { throw new Error(`Failed to add feed. ${response.statusText} ${await response.text()}`); } const result = await response.json() as AddResponseBody; if (!result.success) { throw new Error('Failed to add feed!'); } feeds.value = [...result.newFeeds]; } catch (error) { console.error(error); } isAdding.value = false; } function toggleOptionsDropdown() { isOptionsDropdownOpen.value = !isOptionsDropdownOpen.value; } async function onClickDeleteFeed(feedId: string) { if (confirm('Are you sure you want to delete this feed and all its articles?')) { if (isDeleting.value) { return; } isDeleting.value = true; try { const requestBody: DeleteRequestBody = { feedId }; const response = await fetch(`/api/news/delete-feed`, { method: 'POST', body: JSON.stringify(requestBody), }); if (!response.ok) { throw new Error(`Failed to delete feed. ${response.statusText} ${await response.text()}`); } const result = await response.json() as DeleteResponseBody; if (!result.success) { throw new Error('Failed to delete feed!'); } feeds.value = [...result.newFeeds]; } catch (error) { console.error(error); } isDeleting.value = false; } } function onClickImportOpml() { isOptionsDropdownOpen.value = false; if (isImporting.value) { return; } const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.click(); fileInput.onchange = (event) => { const files = (event.target as HTMLInputElement)?.files!; const file = files[0]; if (!file) { return; } const reader = new FileReader(); reader.onload = async (fileRead) => { const importFileContents = fileRead.target?.result; if (!importFileContents || isImporting.value) { return; } isImporting.value = true; try { const feedUrls = parseOpmlFromTextContents(importFileContents!.toString()); const requestBody: ImportRequestBody = { feedUrls }; const response = await fetch(`/api/news/import-feeds`, { method: 'POST', body: JSON.stringify(requestBody), }); if (!response.ok) { throw new Error(`Failed to import feeds. ${response.statusText} ${await response.text()}`); } const result = await response.json() as ImportResponseBody; if (!result.success) { throw new Error('Failed to import feeds!'); } feeds.value = [...result.newFeeds]; } catch (error) { console.error(error); } isImporting.value = false; }; reader.readAsText(file, 'UTF-8'); }; } function onClickExportOpml() { isOptionsDropdownOpen.value = false; if (isExporting.value) { return; } isExporting.value = true; const fileName = `feeds-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.opml`; const exportContents = formatNewsFeedsToOpml([...feeds.peek()]); // Add content-type const xmlContent = `data:application/xml; charset=utf-8,${exportContents}`; // Download the file const data = encodeURI(xmlContent); const link = document.createElement('a'); link.setAttribute('href', data); link.setAttribute('download', fileName); link.click(); link.remove(); isExporting.value = false; } return ( <>
View articles
{feeds.value.map((newsFeed) => ( ))} {feeds.value.length === 0 ? ( ) : null}
Title & URL Last Crawl Type
{newsFeed.extra.title || 'N/A'}
{newsFeed.feed_url}
{newsFeed.last_crawled_at ? dateFormat.format(new Date(newsFeed.last_crawled_at)) : 'N/A'}
{newsFeed.extra.feed_type?.split('').map((character) => character.toUpperCase()).join('') || 'N/A'}
No feeds to show
{isDeleting.value ? ( <> Deleting... ) : null} {isExporting.value ? ( <> Exporting... ) : null} {isImporting.value ? ( <> Importing... ) : null} {!isDeleting.value && !isExporting.value && !isImporting.value ? <>  : null}
); }