From e204c70e009b5bcf5012f4906d7de7d341fc6afd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 18:09:20 +0000 Subject: [PATCH] Fix source deletion, add content clearing, multi-category/editable sources, News category, wider layout, and a YouTube source module - Fix "Body cannot be empty" error on DELETE by making the JSON content-type parser tolerate empty bodies, and by only sending Content-Type from the frontend when a request actually has one. - Deleting a source now cascades: raw content items and any article composed entirely from that source are removed too, plus their media. - Add admin endpoints/UI to clear all articles, all media, or a single source's content without deleting the source, so things can be repopulated fresh. - Sources can now be assigned multiple categories via checkboxes (instead of free text) and edited in place, not just added/deleted. - Add a "News" default category (seeded fresh, backfilled on existing DBs) so general news sources have a real home instead of the pseudo-category "Top stories", which is just the homepage's all-categories chronological view. - Widen the site's content column 15% (1080px -> 1242px). - Add YouTube as its own source type/ingestion module: pulls a channel's public Atom feed, and each video always publishes directly as its own article (title, embedded video, publish date, description) rather than going through the cross-source clustering/synthesis pipeline. --- backend/src/api/admin.ts | 22 ++ backend/src/index.ts | 14 ++ backend/src/ingestion/adapters/youtube.ts | 90 ++++++++ backend/src/ingestion/poller.ts | 2 + backend/src/pipeline/publish.ts | 11 +- backend/src/queue/priorityQueue.ts | 29 ++- backend/src/storage/contentCascade.ts | 61 ++++++ backend/src/storage/db/articles.ts | 4 + backend/src/storage/db/contentItems.ts | 13 ++ backend/src/storage/db/index.ts | 18 +- backend/src/storage/db/types.ts | 4 +- backend/src/storage/media/index.ts | 33 +++ frontend/src/lib/adminApi.ts | 17 +- frontend/src/lib/adminTypes.ts | 4 +- .../lib/components/admin/RetentionTab.svelte | 69 +++++- .../lib/components/admin/SourcesTab.svelte | 197 +++++++++++++++--- frontend/src/lib/styles/app.css | 2 +- frontend/src/lib/types.ts | 2 +- .../src/routes/admin/settings/+page.svelte | 2 +- frontend/src/routes/article/[id]/+page.svelte | 73 +++++-- 20 files changed, 609 insertions(+), 58 deletions(-) create mode 100644 backend/src/ingestion/adapters/youtube.ts create mode 100644 backend/src/storage/contentCascade.ts diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 811fcd8..af7527a 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -3,6 +3,7 @@ import * as settingsDb from '../storage/db/settings.js'; import * as sourcesDb from '../storage/db/sources.js'; import * as eventsDb from '../storage/db/events.js'; import * as categoriesDb from '../storage/db/categories.js'; +import { clearSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; import { pollSourceNow } from '../ingestion/poller.js'; import { logger, listLogs } from '../storage/db/logs.js'; @@ -58,10 +59,31 @@ export async function registerAdminRoutes(app: FastifyInstance) { app.delete('/api/admin/sources/:id', async (req, reply) => { const { id } = req.params as { id: string }; + // Deleting a source deletes its raw content and any article composed entirely + // from it too — otherwise stale articles from a source the admin just removed + // keep showing up on the site pointing at nothing. + clearSourceContent(id); sourcesDb.deleteSource(id); return reply.code(204).send(); }); + // --- Content clearing (re-populate a source, or the whole site, from scratch) --- + app.delete('/api/admin/content/sources/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const result = clearSourceContent(id); + return reply.code(200).send(result); + }); + + app.delete('/api/admin/content/articles', async (_req, reply) => { + const deleted = clearAllArticles(); + return reply.code(200).send({ deleted }); + }); + + app.delete('/api/admin/content/media', async (_req, reply) => { + const deleted = clearAllMedia(); + return reply.code(200).send({ deleted }); + }); + // Manual "poll now" — the refresh icon on each source in the admin panel. app.post('/api/admin/sources/:id/poll', async (req, reply) => { const { id } = req.params as { id: string }; diff --git a/backend/src/index.ts b/backend/src/index.ts index 3d0b1f0..21efe39 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -37,6 +37,20 @@ async function main() { }); await app.register(cookie); + // Overrides Fastify's default JSON body parser, which throws "Body cannot be empty + // when content-type is set to 'application/json'" for any bodyless request (DELETE, + // or POST with no payload) that still carries a Content-Type header — exactly what + // browsers' fetch() does when a client sets that header unconditionally. An empty + // body is just as valid as `{}` for routes that don't read req.body at all. + app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { + if (typeof body !== 'string' || body.trim() === '') return done(null, {}); + try { + done(null, JSON.parse(body)); + } catch (err) { + done(err as Error, undefined); + } + }); + await registerAuth(app); await registerPublicRoutes(app); await registerAdminRoutes(app); diff --git a/backend/src/ingestion/adapters/youtube.ts b/backend/src/ingestion/adapters/youtube.ts new file mode 100644 index 0000000..b577acd --- /dev/null +++ b/backend/src/ingestion/adapters/youtube.ts @@ -0,0 +1,90 @@ +// YouTube is its own ingestion module, deliberately separate from the RSS/Telegram +// adapters: a channel's public Atom feed (no API key needed) is fetched directly, and +// each entry becomes its own single-video article — see pipeline/publish.ts and +// queue/priorityQueue.ts, which route youtube-sourced items straight to publishDirect +// rather than through the LLM clustering/synthesis pipeline. Merging two unrelated +// videos into one AI-rewritten story would make no sense the way merging two outlets' +// coverage of the same news event does. + +import Parser from 'rss-parser'; +import type { Source } from '../../storage/db/types.js'; +import type { SourceAdapter, FetchedItem } from './base.js'; +import { logger } from '../../storage/db/logs.js'; + +type YoutubeEntry = Parser.Item & { + 'media:group'?: { + 'media:description'?: string[]; + 'media:thumbnail'?: { $?: { url?: string } }[]; + }[]; +}; + +const parser = new Parser, YoutubeEntry>({ + customFields: { + item: [['media:group', 'media:group']] + } +}); + +/** Builds the channel's Atom feed URL — YouTube publishes these publicly with no API key required. */ +function feedUrl(source: Source): string | null { + if (source.url) return source.url; + const channelId = source.config?.channelId as string | undefined; + if (channelId) return `https://www.youtube.com/feeds/videos.xml?channel_id=${encodeURIComponent(channelId)}`; + const playlistId = source.config?.playlistId as string | undefined; + if (playlistId) return `https://www.youtube.com/feeds/videos.xml?playlist_id=${encodeURIComponent(playlistId)}`; + return null; +} + +function extractVideoId(url: string): string | null { + const match = + url.match(/[?&]v=([\w-]{6,})/) || url.match(/youtu\.be\/([\w-]{6,})/) || url.match(/\/embed\/([\w-]{6,})/); + return match ? match[1] : null; +} + +function extractDescription(item: YoutubeEntry): string { + const group = item['media:group']?.[0]; + return group?.['media:description']?.[0] ?? ''; +} + +function extractThumbnail(item: YoutubeEntry): string | null { + const group = item['media:group']?.[0]; + return group?.['media:thumbnail']?.[0]?.$?.url ?? null; +} + +export const youtubeAdapter: SourceAdapter = { + async fetch(source: Source): Promise { + const url = feedUrl(source); + if (!url) { + logger.warn('youtube', `Source "${source.name}" has no url, channelId, or playlistId configured — skipping`); + return []; + } + + const feed = await parser.parseURL(url); + const items: FetchedItem[] = []; + + for (const item of feed.items) { + if (!item.link || !item.title) continue; + const videoId = extractVideoId(item.link); + const description = extractDescription(item); + const thumbnail = extractThumbnail(item); + + items.push({ + title: item.title, + summary: description.slice(0, 500), + body: description || null, + images: thumbnail ? [{ url: thumbnail }] : [], + videos: [ + { + url: item.link, + provider: 'youtube', + embedHtml: videoId ? `https://www.youtube.com/embed/${videoId}` : undefined + } + ], + link: item.link, + publishedAt: item.isoDate ?? item.pubDate ?? new Date().toISOString(), + raw: item + }); + } + + return items; + } +}; diff --git a/backend/src/ingestion/poller.ts b/backend/src/ingestion/poller.ts index b9e8299..525d7ca 100644 --- a/backend/src/ingestion/poller.ts +++ b/backend/src/ingestion/poller.ts @@ -4,6 +4,7 @@ import { logger } from '../storage/db/logs.js'; import { rssAdapter } from './adapters/rss.js'; import { telegramAdapter } from './adapters/telegram.js'; import { apiAdapter } from './adapters/api.js'; +import { youtubeAdapter } from './adapters/youtube.js'; import { toContentItem, type SourceAdapter, type FetchedItem } from './adapters/base.js'; import { fetchFullArticle } from './articleFetcher.js'; import type { Source } from '../storage/db/types.js'; @@ -12,6 +13,7 @@ const adapters: Record = { rss: rssAdapter, telegram: telegramAdapter, api: apiAdapter, + youtube: youtubeAdapter, custom: apiAdapter }; diff --git a/backend/src/pipeline/publish.ts b/backend/src/pipeline/publish.ts index 08fc93a..c2ffd90 100644 --- a/backend/src/pipeline/publish.ts +++ b/backend/src/pipeline/publish.ts @@ -77,7 +77,9 @@ async function resolveHeroImage( export async function publishDirect(item: ContentItem): Promise { const category = uniqueCategories([item]); const { heroImage, storedMediaId } = await resolveHeroImage([item], item.link); - const video = item.videos[0] ? { url: item.videos[0].url, provider: item.videos[0].provider, sourceItemId: item.id } : null; + const video = item.videos[0] + ? { url: item.videos[0].url, provider: item.videos[0].provider, embedUrl: item.videos[0].embedHtml, sourceItemId: item.id } + : null; const article = await articles.insertArticle({ title: item.title, @@ -140,7 +142,12 @@ export async function publishCluster( const { heroImage, storedMediaId } = await resolveHeroImage(items, items[0]?.link ?? ''); const videoItem = items.find((i) => i.videos.length > 0); const video = videoItem - ? { url: videoItem.videos[0].url, provider: videoItem.videos[0].provider, sourceItemId: videoItem.id } + ? { + url: videoItem.videos[0].url, + provider: videoItem.videos[0].provider, + embedUrl: videoItem.videos[0].embedHtml, + sourceItemId: videoItem.id + } : null; const category = uniqueCategories(items); diff --git a/backend/src/queue/priorityQueue.ts b/backend/src/queue/priorityQueue.ts index e399eee..181809e 100644 --- a/backend/src/queue/priorityQueue.ts +++ b/backend/src/queue/priorityQueue.ts @@ -9,6 +9,13 @@ import { publishCluster, publishDirect } from '../pipeline/publish.js'; import { logger } from '../storage/db/logs.js'; import type { GlobalSettings, ContentItem } from '../storage/db/types.js'; +function partition(items: T[], predicate: (item: T) => boolean): [T[], T[]] { + const matches: T[] = []; + const rest: T[] = []; + for (const item of items) (predicate(item) ? matches : rest).push(item); + return [matches, rest]; +} + function primaryCategoryRank(item: ContentItem, rankByName: Map): number { const source = sourcesDb.getSource(item.sourceId); const cats = source?.category ?? []; @@ -70,10 +77,28 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G const items = contentItemsDb.unclusteredItemsExcludingSources(eventSourceIds); if (items.length === 0) return 0; + // YouTube videos never get LLM-merged with anything — each is always its own + // article (title/video/date/description), same shape whether the AI service is up + // or not. Route them straight to publishDirect, same as the no-AI passthrough path. + const youtubeSourceIds = new Set(sourcesDb.listSources().filter((s) => s.type === 'youtube').map((s) => s.id)); + const [youtubeItems, mergeableItems] = partition(items, (item) => youtubeSourceIds.has(item.sourceId)); + + let publishedDirect = 0; + for (const item of youtubeItems) { + try { + const article = await publishDirect(item); + contentItemsDb.assignCluster([item.id], article.id); + publishedDirect++; + logger.info('synthesis', `Published "${article.title}" directly (YouTube)`); + } catch (err) { + logger.error('synthesis', `Direct publish failed for "${item.title}": ${(err as Error).message}`); + } + } + const categories = categoriesDb.listCategories(); const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank])); - const ranked = items + const ranked = mergeableItems .map((item) => ({ item, rank: primaryCategoryRank(item, rankByName) })) .sort((a, b) => a.rank - b.rank) .map((r) => r.item); @@ -119,5 +144,5 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G ); } - return published; + return published + publishedDirect; } diff --git a/backend/src/storage/contentCascade.ts b/backend/src/storage/contentCascade.ts new file mode 100644 index 0000000..eea9bca --- /dev/null +++ b/backend/src/storage/contentCascade.ts @@ -0,0 +1,61 @@ +// Deleting or "clearing" a source shouldn't leave orphaned merged articles behind +// pointing at raw items that no longer exist — this is the one place that coordinates +// content_items, merged_articles, and media_assets together, since none of those three +// tables have a single FK chain connecting them all (see storage/db/index.ts schema +// comments: sources are copied into merged_articles.sources at publish time, not +// referenced live). + +import * as contentItemsDb from './db/contentItems.js'; +import * as articlesDb from './db/articles.js'; +import { deleteMediaByArticleId, deleteMediaByContentItemIds, deleteAllMedia } from './media/index.js'; +import { logger } from './db/logs.js'; + +export interface ClearResult { + itemsDeleted: number; + articlesDeleted: number; +} + +/** + * Removes every raw content item ingested from a source, plus any merged article that + * was composed entirely from that source's items (so it doesn't linger on the site + * pointing at deleted raw data). Articles that merged this source's coverage together + * with other sources' are left alone — stripping just this source's contribution back + * out of an already-published multi-source article isn't something the merge pipeline + * supports undoing. + */ +export function clearSourceContent(sourceId: string): ClearResult { + const items = contentItemsDb.itemsForSource(sourceId); + const itemIds = new Set(items.map((i) => i.id)); + + let articlesDeleted = 0; + if (itemIds.size > 0) { + for (const article of articlesDb.allArticlesNewestFirst()) { + if (article.sources.length > 0 && article.sources.every((s) => itemIds.has(s.itemId))) { + deleteMediaByArticleId(article.id); + articlesDb.deleteArticle(article.id); + articlesDeleted++; + } + } + deleteMediaByContentItemIds([...itemIds]); + } + + contentItemsDb.deleteContentItemsForSource(sourceId); + logger.info('admin', `Cleared content for source ${sourceId}: ${itemIds.size} item(s), ${articlesDeleted} article(s)`); + return { itemsDeleted: itemIds.size, articlesDeleted }; +} + +/** Wipes every published article and its media, keeping raw ingested items intact so they can be re-synthesized fresh. */ +export function clearAllArticles(): number { + const articles = articlesDb.allArticlesNewestFirst(); + for (const article of articles) deleteMediaByArticleId(article.id); + articlesDb.deleteAllArticles(); + logger.info('admin', `Cleared all articles: ${articles.length} removed`); + return articles.length; +} + +/** Wipes every locally-stored media file (both candidate and published tiers). */ +export function clearAllMedia(): number { + const count = deleteAllMedia(); + logger.info('admin', `Cleared all media: ${count} file(s) removed`); + return count; +} diff --git a/backend/src/storage/db/articles.ts b/backend/src/storage/db/articles.ts index c19c417..286ce50 100644 --- a/backend/src/storage/db/articles.ts +++ b/backend/src/storage/db/articles.ts @@ -131,3 +131,7 @@ export function findRecentArticleByTags(tagIds: string[], sinceDays: number): Me export function deleteArticle(id: string) { db.prepare('DELETE FROM merged_articles WHERE id = ?').run(id); } + +export function deleteAllArticles() { + db.prepare('DELETE FROM merged_articles').run(); +} diff --git a/backend/src/storage/db/contentItems.ts b/backend/src/storage/db/contentItems.ts index 2d0e05e..e33a35b 100644 --- a/backend/src/storage/db/contentItems.ts +++ b/backend/src/storage/db/contentItems.ts @@ -98,3 +98,16 @@ export function deleteContentItems(ids: string[]) { const stmt = db.prepare('DELETE FROM content_items WHERE id = ?'); for (const id of ids) stmt.run(id); } + +export function itemsForSource(sourceId: string): ContentItem[] { + const rows = db.prepare('SELECT * FROM content_items WHERE source_id = ?').all(sourceId); + return rows.map(rowToItem); +} + +export function deleteContentItemsForSource(sourceId: string) { + db.prepare('DELETE FROM content_items WHERE source_id = ?').run(sourceId); +} + +export function deleteAllContentItems() { + db.prepare('DELETE FROM content_items').run(); +} diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 4d700c0..8baf1a1 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -182,15 +182,29 @@ export function migrate() { ).run(); } - // Seed default categories if none exist yet. + // Seed default categories if none exist yet. "News" sits right under "Top stories" — + // general news sources belong here, not on "Top stories" itself, which isn't a real + // filterable tag: it's the homepage's all-categories-chronological view (see + // +layout.svelte's nav mapping and /api/feed's no-category-filter default). const catCount = db.prepare('SELECT COUNT(*) as c FROM categories').get() as { c: number }; if (catCount.c === 0) { - const defaults = ['Top stories', 'Local', 'World', 'Business', 'Tech', 'Culture']; + const defaults = ['Top stories', 'News', 'Local', 'World', 'Business', 'Tech', 'Culture']; const stmt = db.prepare( 'INSERT INTO categories (id, name, priority_rank, is_default) VALUES (?, ?, ?, 1)' ); defaults.forEach((name, i) => { stmt.run(`cat-${name.toLowerCase().replace(/\s+/g, '-')}`, name, i + 1); }); + } else { + // Backfill for installs seeded before "News" existed. + const hasNews = db.prepare("SELECT id FROM categories WHERE lower(name) = 'news'").get(); + if (!hasNews) { + const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM categories').get() as { m: number }; + db.prepare('INSERT INTO categories (id, name, priority_rank, is_default) VALUES (?, ?, ?, 1)').run( + 'cat-news', + 'News', + maxRank.m + 1 + ); + } } } diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index cffd27d..5da4f9f 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -1,7 +1,7 @@ export interface Source { id: string; name: string; - type: 'rss' | 'api' | 'telegram' | 'custom'; + type: 'rss' | 'api' | 'telegram' | 'youtube' | 'custom'; category: string[]; url: string | null; config: Record; @@ -44,7 +44,7 @@ export interface MergedArticle { title: string; body: string; heroImage: { url: string; sourceItemId: string; selectionReason: string } | null; - video: { url: string; provider?: string; sourceItemId: string } | null; + video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null; category: string[]; geo: string | null; eventId: string | null; diff --git a/backend/src/storage/media/index.ts b/backend/src/storage/media/index.ts index f0c588c..a911f27 100644 --- a/backend/src/storage/media/index.ts +++ b/backend/src/storage/media/index.ts @@ -70,6 +70,39 @@ export function deleteCandidateMediaOlderThan(days: number): number { return rows.length; } +function deleteRows(rows: { id: string; local_path: string }[]) { + for (const row of rows) fs.rm(row.local_path, () => {}); + return rows.length; +} + +export function deleteMediaByArticleId(articleId: string): number { + const rows = db.prepare('SELECT id, local_path FROM media_assets WHERE article_id = ?').all(articleId) as { + id: string; + local_path: string; + }[]; + const count = deleteRows(rows); + db.prepare('DELETE FROM media_assets WHERE article_id = ?').run(articleId); + return count; +} + +export function deleteMediaByContentItemIds(contentItemIds: string[]): number { + if (contentItemIds.length === 0) return 0; + const placeholders = contentItemIds.map(() => '?').join(','); + const rows = db + .prepare(`SELECT id, local_path FROM media_assets WHERE content_item_id IN (${placeholders})`) + .all(...contentItemIds) as { id: string; local_path: string }[]; + const count = deleteRows(rows); + db.prepare(`DELETE FROM media_assets WHERE content_item_id IN (${placeholders})`).run(...contentItemIds); + return count; +} + +export function deleteAllMedia(): number { + const rows = db.prepare('SELECT id, local_path FROM media_assets').all() as { id: string; local_path: string }[]; + const count = deleteRows(rows); + db.prepare('DELETE FROM media_assets').run(); + return count; +} + function guessExtension(contentType: string, url: string): string { if (contentType.includes('jpeg')) return '.jpg'; if (contentType.includes('png')) return '.png'; diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index 48ce307..ed17f0e 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -9,10 +9,15 @@ import type { } from './adminTypes'; async function request(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise { + // Fastify's default JSON body parser rejects an empty body when Content-Type is + // application/json ("Body cannot be empty when content-type is set to + // 'application/json'") — so this header is only attached when there's actually a + // body to send (PATCH/POST with a JSON payload), never for bodyless DELETE/POST calls. + const headers = options.body ? { 'Content-Type': 'application/json', ...(options.headers || {}) } : options.headers; const res = await fetchFn(`${getBackendUrl()}${path}`, { ...options, credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } + headers }); if (res.status === 401) { const err = new Error('unauthorized') as Error & { status?: number }; @@ -76,6 +81,16 @@ export const deleteSource = (id: string, fetchFn?: typeof fetch) => export const pollSourceNow = (id: string, fetchFn?: typeof fetch) => request<{ ingested: number; source: AdminSource }>(`/api/admin/sources/${id}/poll`, { method: 'POST' }, fetchFn); +// Content clearing — wipe articles/media/a source's raw items so they can be repopulated fresh. +export const clearSourceContent = (id: string, fetchFn?: typeof fetch) => + request<{ itemsDeleted: number; articlesDeleted: number }>(`/api/admin/content/sources/${id}`, { method: 'DELETE' }, fetchFn); + +export const clearAllArticles = (fetchFn?: typeof fetch) => + request<{ deleted: number }>('/api/admin/content/articles', { method: 'DELETE' }, fetchFn); + +export const clearAllMedia = (fetchFn?: typeof fetch) => + request<{ deleted: number }>('/api/admin/content/media', { method: 'DELETE' }, fetchFn); + // Tracked events export const getEvents = (fetchFn?: typeof fetch) => request('/api/admin/events', {}, fetchFn); diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index 520664c..c367191 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -11,6 +11,7 @@ export interface CategoryPriority { id: string; name: string; priorityRank: number; + isDefault: boolean; } export interface AdminSettings { @@ -31,9 +32,10 @@ export interface AdminSettings { export interface AdminSource { id: string; name: string; - type: 'rss' | 'api' | 'telegram' | 'custom'; + type: 'rss' | 'api' | 'telegram' | 'youtube' | 'custom'; category: string[]; url: string; + config?: Record; pollIntervalMinutes: number; enabled: boolean; lastPolledAt: string | null; diff --git a/frontend/src/lib/components/admin/RetentionTab.svelte b/frontend/src/lib/components/admin/RetentionTab.svelte index 08738de..ad16ea6 100644 --- a/frontend/src/lib/components/admin/RetentionTab.svelte +++ b/frontend/src/lib/components/admin/RetentionTab.svelte @@ -1,6 +1,6 @@
{sources.length} sources - +
{#if showAdd}
- - + - - + {#if form.type === 'youtube'} + + {:else} + + {/if} + +
+
Categories
+
+ {#each assignableCategories as cat (cat.id)} + + {/each}
- - + +
{/if} @@ -99,8 +206,14 @@
{source.name}
-
- {#if justPolled?.id === source.id} +
+ {#if justCleared?.id === source.id} + ✓ cleared {justCleared.items} item(s), {justCleared.articles} article(s) + {:else if justPolled?.id === source.id} {justPolled.count > 0 ? `✓ ${justPolled.count} new item(s)` : '✓ up to date, nothing new'} {:else if source.lastError} last poll failed · {source.lastError} @@ -113,9 +226,18 @@ {source.category.join(', ')} {source.pollIntervalMinutes} min
+ +
@@ -149,6 +271,27 @@ gap: 8px; margin-bottom: 10px; } + .categories-label { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 6px; + } + .category-checks { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 12px; + } + .category-check { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--text-secondary); + } + .category-check input { + width: auto; + } .add-actions { display: flex; gap: 8px; @@ -165,7 +308,7 @@ } .row { display: grid; - grid-template-columns: 20px 1.4fr 0.7fr 0.9fr 0.7fr 60px; + grid-template-columns: 20px 1.4fr 0.7fr 0.9fr 0.7fr 96px; gap: 10px; padding: 10px; align-items: center; @@ -231,7 +374,7 @@ } .actions { display: flex; - gap: 6px; + gap: 4px; } .icon-btn { font-size: 12px; diff --git a/frontend/src/lib/styles/app.css b/frontend/src/lib/styles/app.css index 5532fc9..5d014fd 100644 --- a/frontend/src/lib/styles/app.css +++ b/frontend/src/lib/styles/app.css @@ -77,7 +77,7 @@ button { } .page { - max-width: 1080px; + max-width: 1242px; /* 1080px + 15% */ margin: 0 auto; padding: 0 24px 60px; } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 57f3c74..843cd8a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -13,7 +13,7 @@ export interface MergedArticle { title: string; body: string; heroImage: { url: string; sourceItemId: string; selectionReason: string } | null; - video: { url: string; provider?: string; sourceItemId: string } | null; + video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null; category: string[]; geo: string | null; eventId: string | null; diff --git a/frontend/src/routes/admin/settings/+page.svelte b/frontend/src/routes/admin/settings/+page.svelte index 10fa93a..85b35de 100644 --- a/frontend/src/routes/admin/settings/+page.svelte +++ b/frontend/src/routes/admin/settings/+page.svelte @@ -38,7 +38,7 @@ {#if active === 'merge'} {:else if active === 'sources'} - + {:else if active === 'models'} {:else if active === 'retention'} diff --git a/frontend/src/routes/article/[id]/+page.svelte b/frontend/src/routes/article/[id]/+page.svelte index c4d75ff..3788cd0 100644 --- a/frontend/src/routes/article/[id]/+page.svelte +++ b/frontend/src/routes/article/[id]/+page.svelte @@ -24,26 +24,49 @@

{a.title}

-
- Published {timeAgo(a.publishedAt)} · {exactTime(a.publishedAt)} - {#if a.updatedAt !== a.publishedAt} - · Updated {timeAgo(a.updatedAt)} · {exactTime(a.updatedAt)} - {/if} -
- - {#if a.heroImage} - -
- Image via {a.sources[0]?.sourceName ?? 'source'} + {#if a.video?.provider === 'youtube'} + +
+
- {/if} - {#each a.body.split('\n\n') as paragraph} -

{paragraph}

- {/each} +
+ Published {timeAgo(a.publishedAt)} · {exactTime(a.publishedAt)} +
- {#if a.video} -
▶ video embed · via {a.video.provider}
+ {#each a.body.split('\n\n') as paragraph} +

{paragraph}

+ {/each} + {:else} +
+ Published {timeAgo(a.publishedAt)} · {exactTime(a.publishedAt)} + {#if a.updatedAt !== a.publishedAt} + · Updated {timeAgo(a.updatedAt)} · {exactTime(a.updatedAt)} + {/if} +
+ + {#if a.heroImage} + +
+ Image via {a.sources[0]?.sourceName ?? 'source'} +
+ {/if} + + {#each a.body.split('\n\n') as paragraph} +

{paragraph}

+ {/each} + + {#if a.video} +
▶ video embed · via {a.video.provider}
+ {/if} {/if} {#if data.tagLabels.length} @@ -141,6 +164,22 @@ font-size: 12px; margin-bottom: 24px; } + .video-frame { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 12px; + overflow: hidden; + background: var(--surface-1); + margin-bottom: 20px; + } + .video-frame iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: none; + } .tags { display: flex; gap: 8px;