diff --git a/cmd/main.go b/cmd/main.go index 90dd733..8461830 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -190,12 +190,13 @@ func runNewsIngestion(dryRun bool, dbPath string) error { fmt.Fprintf(os.Stderr, "news: refreshing feeds into %s\n", path) } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() result, err := news.Refresh(ctx, st, news.RefreshOptions{ Client: &http.Client{Timeout: 10 * time.Second}, DryRun: dryRun, Progress: os.Stderr, + EnrichOG: !dryRun, }) fmt.Fprintf( os.Stderr, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a62466a..ecdc40c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { LiveStateResponse, Meeting, NewsItem, RaceHub, Weekend } from './types' +import type { ArticleContent, LiveStateResponse, Meeting, NewsItem, RaceHub, Weekend } from './types' export async function fetchRaceHub(sessionKey: number): Promise { const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`) @@ -46,13 +46,29 @@ export async function fetchNews(limit?: number, source?: string): Promise { + const res = await fetch(`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`) + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + return res.json() +} + +export async function markNewsRead(articleUrl: string): Promise { + await fetch('/api/v1/news/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: articleUrl }), + }) +} diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index 6966786..202f65e 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -16,6 +16,9 @@ export function Nav() { Race Hub + + Briefing +
diff --git a/frontend/src/components/PaddockBriefing.tsx b/frontend/src/components/PaddockBriefing.tsx index 22a772d..83d9ee8 100644 --- a/frontend/src/components/PaddockBriefing.tsx +++ b/frontend/src/components/PaddockBriefing.tsx @@ -1,61 +1,73 @@ import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' import { fetchNews } from '../api' import { timeAgo } from '../utils' +const SOURCE_DISPLAY: Record = { + 'fia': 'FIA', + 'bbc-f1': 'BBC Sport', + 'autosport-f1': 'Autosport', + 'racefans-f1': 'RaceFans', + 'guardian-f1': 'Guardian', + 'racer-f1': 'RACER', + 'f1-youtube': 'F1 YouTube', +} + export function PaddockBriefing() { const { data: news, isLoading, isError } = useQuery({ queryKey: ['news'], - queryFn: () => fetchNews(12), + queryFn: () => fetchNews(100), staleTime: 60_000, }) - if (isLoading) { - return
loading paddock briefing…
- } - - if (isError || !news) { - return
Failed to load paddock briefing
- } - - if (news.length === 0) { - return
No briefing items available.
- } + const unreadCount = news?.filter((i) => !i.read_at).length ?? 0 + const preview = news?.slice(0, 5) ?? [] return (
- Paddock Briefing - Latest intel + + Paddock Briefing + {unreadCount > 0 && ( + {unreadCount} + )} + + + View all → +
-
- {news.map((item, i) => { - const age = timeAgo(item.published_at || item.fetched_at) - return ( - loading…
} + {isError &&
Failed to load briefing
} + + {!isLoading && !isError && preview.length === 0 && ( +
+ No items. Run box-box --ingest-news to populate. +
+ )} + + {preview.length > 0 && ( +
+ )}
) } diff --git a/frontend/src/pages/BriefingPage.tsx b/frontend/src/pages/BriefingPage.tsx new file mode 100644 index 0000000..6c58440 --- /dev/null +++ b/frontend/src/pages/BriefingPage.tsx @@ -0,0 +1,371 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { fetchNews, fetchNewsArticle, markNewsRead } from '../api' +import { stripHtml, timeAgo } from '../utils' +import type { ArticleContent, NewsItem } from '../types' + +type Category = 'all' | 'official' | 'news' | 'video' + +const CATEGORY_LABELS: Record = { + all: 'All', + official: 'Official', + news: 'News', + video: 'Video', +} + +const SOURCE_DISPLAY: Record = { + 'fia': 'FIA', + 'bbc-f1': 'BBC Sport', + 'autosport-f1': 'Autosport', + 'racefans-f1': 'RaceFans', + 'guardian-f1': 'Guardian', + 'racer-f1': 'RACER', + 'f1-youtube': 'F1 YouTube', +} + +function displaySource(id: string): string { + return SOURCE_DISPLAY[id] ?? id +} + +function categoryOf(item: NewsItem): Category { + const c = (item.category ?? '').toLowerCase() + if (c === 'official') return 'official' + if (c === 'video') return 'video' + return 'news' +} + +function CategoryTabs({ + active, + counts, + onChange, +}: { + active: Category + counts: Record + onChange: (c: Category) => void +}) { + return ( +
+ {(Object.keys(CATEGORY_LABELS) as Category[]).map((cat) => ( + + ))} +
+ ) +} + +function OGImage({ url, title }: { url?: string; title: string }) { + const [failed, setFailed] = useState(false) + const initial = (title[0] ?? '?').toUpperCase() + + if (!url || failed) { + return ( + + ) + } + return ( +
+ setFailed(true)} + /> +
+ ) +} + +function BriefingCard({ + item, + isActive, + onSelect, +}: { + item: NewsItem + isActive: boolean + onSelect: (item: NewsItem) => void +}) { + const isRead = !!item.read_at + const isVideo = categoryOf(item) === 'video' + + return ( +
onSelect(item)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && onSelect(item)} + aria-pressed={isActive} + > + + {isVideo && ▶ Video} +
+
+ {displaySource(item.source)} + {timeAgo(item.published_at ?? item.fetched_at)} +
+

{item.title}

+ {!isVideo && (item.og_description || item.summary) && ( +

+ {stripHtml(item.og_description || item.summary || '')} +

+ )} +
+
+ ) +} + +function ReaderPanel({ + item, + onClose, +}: { + item: NewsItem | null + onClose: () => void +}) { + const panelRef = useRef(null) + const [article, setArticle] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [onClose]) + + // Fetch article when item changes + useEffect(() => { + if (!item) { + setArticle(null) + return + } + setLoading(true) + setError(null) + setArticle(null) + fetchNewsArticle(item.url) + .then((data) => { setArticle(data); setLoading(false) }) + .catch((err) => { setError(String(err)); setLoading(false) }) + }, [item?.url]) + + // Scroll panel to top when item changes + useEffect(() => { + panelRef.current?.scrollTo({ top: 0 }) + }, [item?.url]) + + const isOpen = item !== null + + return ( + <> + {isOpen && ( +