feat(paddock): initial paddock briefing pages and store changes

This commit is contained in:
2026-05-25 15:16:00 -04:00
parent e060bcba24
commit e3453de788
19 changed files with 1354 additions and 156 deletions

View File

@@ -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,

View File

@@ -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<RaceHub> {
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
@@ -56,3 +56,19 @@ export async function fetchNews(limit?: number, source?: string): Promise<NewsIt
}
return res.json()
}
export async function fetchNewsArticle(articleUrl: string): Promise<ArticleContent> {
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<void> {
await fetch('/api/v1/news/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: articleUrl }),
})
}

View File

@@ -16,6 +16,9 @@ export function Nav() {
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub
</Link>
<Link to="/briefing" activeProps={{ className: 'active' }}>
Briefing
</Link>
</div>
<div className="nav-utility">
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}>

View File

@@ -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<string, string> = {
'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 <div className="briefing-state loading-state">loading paddock briefing</div>
}
if (isError || !news) {
return <div className="briefing-state error-box">Failed to load paddock briefing</div>
}
if (news.length === 0) {
return <div className="briefing-state empty-box">No briefing items available.</div>
}
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
const preview = news?.slice(0, 5) ?? []
return (
<section className="cc-briefing" data-testid="paddock-briefing">
<div className="sec-header">
<span className="sec-title">Paddock Briefing</span>
<span className="sec-meta mono">Latest intel</span>
<span className="sec-title">
Paddock Briefing
{unreadCount > 0 && (
<span className="cc-brief-unread">{unreadCount}</span>
)}
</span>
<Link to="/briefing" className="sec-action mono">
View all
</Link>
</div>
<div className="briefing-grid" role="list">
{news.map((item, i) => {
const age = timeAgo(item.published_at || item.fetched_at)
return (
<a
key={`${item.url}-${i}`}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="briefing-card"
{isLoading && <div className="briefing-state loading-state">loading</div>}
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
{!isLoading && !isError && preview.length === 0 && (
<div className="briefing-state">
No items. Run <code>box-box --ingest-news</code> to populate.
</div>
)}
{preview.length > 0 && (
<div className="cc-brief-strip" role="list">
{preview.map((item) => (
<Link
key={item.url}
to="/briefing"
className={`cc-brief-item${item.read_at ? ' is-read' : ''}`}
role="listitem"
>
<div className="briefing-card-head mono">
<span className="briefing-source">{item.source}</span>
<span className="briefing-age">{age}</span>
<div className="cc-brief-item-meta mono">
<span className="cc-brief-source">
{SOURCE_DISPLAY[item.source] ?? item.source}
</span>
<span className="cc-brief-age">
{timeAgo(item.published_at ?? item.fetched_at)}
</span>
</div>
<h3 className="briefing-title">{item.title}</h3>
{item.summary && (
<p className="briefing-summary">
{item.summary.length > 120 ? item.summary.substring(0, 117) + '...' : item.summary}
</p>
)}
{item.category && (
<div className="briefing-cat mono">{item.category.toLowerCase()}</div>
)}
</a>
)
})}
</div>
<span className="cc-brief-title">{item.title}</span>
</Link>
))}
</div>
)}
</section>
)
}

View File

@@ -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<Category, string> = {
all: 'All',
official: 'Official',
news: 'News',
video: 'Video',
}
const SOURCE_DISPLAY: Record<string, string> = {
'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<Category, number>
onChange: (c: Category) => void
}) {
return (
<div className="bp-cats" role="tablist" aria-label="Briefing categories">
{(Object.keys(CATEGORY_LABELS) as Category[]).map((cat) => (
<button
key={cat}
role="tab"
aria-selected={active === cat}
className={`bp-cat${active === cat ? ' active' : ''}`}
onClick={() => onChange(cat)}
>
{CATEGORY_LABELS[cat]}
{counts[cat] > 0 && (
<span className="bp-cat-count">{counts[cat]}</span>
)}
</button>
))}
</div>
)
}
function OGImage({ url, title }: { url?: string; title: string }) {
const [failed, setFailed] = useState(false)
const initial = (title[0] ?? '?').toUpperCase()
if (!url || failed) {
return (
<div className="bp-card-img bp-card-img-fallback" aria-hidden="true">
<span className="bp-card-img-initial">{initial}</span>
</div>
)
}
return (
<div className="bp-card-img-wrap">
<img
className="bp-card-img"
src={url}
alt=""
loading="lazy"
onError={() => setFailed(true)}
/>
</div>
)
}
function BriefingCard({
item,
isActive,
onSelect,
}: {
item: NewsItem
isActive: boolean
onSelect: (item: NewsItem) => void
}) {
const isRead = !!item.read_at
const isVideo = categoryOf(item) === 'video'
return (
<article
className={`bp-card${isActive ? ' bp-card-active' : ''}${isRead ? ' bp-card-read' : ''}`}
onClick={() => onSelect(item)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onSelect(item)}
aria-pressed={isActive}
>
<OGImage url={item.og_image_url} title={item.title} />
{isVideo && <span className="bp-video-badge"> Video</span>}
<div className="bp-card-body">
<div className="bp-card-meta mono">
<span className="bp-card-source">{displaySource(item.source)}</span>
<span className="bp-card-age">{timeAgo(item.published_at ?? item.fetched_at)}</span>
</div>
<h3 className="bp-card-title">{item.title}</h3>
{!isVideo && (item.og_description || item.summary) && (
<p className="bp-card-desc">
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
</div>
</article>
)
}
function ReaderPanel({
item,
onClose,
}: {
item: NewsItem | null
onClose: () => void
}) {
const panelRef = useRef<HTMLDivElement>(null)
const [article, setArticle] = useState<ArticleContent | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 && (
<div className="bp-reader-backdrop" onClick={onClose} aria-hidden="true" />
)}
<aside
ref={panelRef}
className={`bp-reader${isOpen ? ' open' : ''}`}
aria-label="Article reader"
aria-hidden={!isOpen}
>
{item && (
<>
<div className="bp-reader-toolbar">
<div className="bp-reader-toolbar-meta mono">
<span>{displaySource(item.source)}</span>
<span className="bp-reader-dot">·</span>
<span>{timeAgo(item.published_at ?? item.fetched_at)}</span>
</div>
<div className="bp-reader-toolbar-actions">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-open"
title="Open in browser"
>
</a>
<button
className="bp-reader-close"
onClick={onClose}
aria-label="Close reader"
>
</button>
</div>
</div>
{(article?.image_url || item.og_image_url) && (
<div className="bp-reader-hero">
<img
src={article?.image_url ?? item.og_image_url}
alt=""
loading="lazy"
/>
</div>
)}
<div className="bp-reader-content">
<h1 className="bp-reader-title">
{article?.title ?? item.title}
</h1>
{article?.byline && (
<div className="bp-reader-byline mono">{article.byline}</div>
)}
{loading && (
<div className="bp-reader-loading">Loading article</div>
)}
{error && !loading && (
<div className="bp-reader-error">
<p>Could not load full article.</p>
{(item.og_description || item.summary) && (
<p className="bp-reader-fallback">
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-ext-link"
>
Open in browser
</a>
</div>
)}
{article && !loading && article.content && (
<div
className="bp-reader-body"
// readability strips scripts/iframes; sources are all known news outlets
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: article.content }}
/>
)}
{article && !loading && !article.content && (
<div className="bp-reader-error">
<p>No article content found.</p>
{(item.og_description || item.summary) && (
<p className="bp-reader-fallback">
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-ext-link"
>
Open in browser
</a>
</div>
)}
</div>
</>
)}
</aside>
</>
)
}
export function BriefingPage() {
const [activeCategory, setActiveCategory] = useState<Category>('all')
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
const queryClient = useQueryClient()
const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: () => fetchNews(100),
staleTime: 60_000,
})
const filtered = allNews.filter(
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
)
const counts: Record<Category, number> = {
all: allNews.length,
official: allNews.filter((i) => categoryOf(i) === 'official').length,
news: allNews.filter((i) => categoryOf(i) === 'news').length,
video: allNews.filter((i) => categoryOf(i) === 'video').length,
}
const handleSelect = useCallback(
(item: NewsItem) => {
setSelectedItem((prev) => (prev?.url === item.url ? null : item))
if (!item.read_at) {
markNewsRead(item.url).then(() => {
queryClient.setQueryData<NewsItem[]>(['news'], (old) =>
old?.map((n) =>
n.url === item.url ? { ...n, read_at: new Date().toISOString() } : n,
),
)
})
}
},
[queryClient],
)
const handleClose = useCallback(() => setSelectedItem(null), [])
const unreadCount = allNews.filter((i) => !i.read_at).length
return (
<div className="bp-page" data-testid="briefing-page">
<div className="bp-topbar">
<span className="bp-topbar-label mono">box-box · paddock briefing</span>
<span className="bp-topbar-meta mono">
{unreadCount > 0 ? `${unreadCount} unread` : 'all read'}
</span>
</div>
{isLoading && <div className="loading-state">loading briefing</div>}
{isError && <div className="error-box">Failed to load paddock briefing.</div>}
{!isLoading && !isError && (
<>
<CategoryTabs
active={activeCategory}
counts={counts}
onChange={(c) => { setActiveCategory(c); setSelectedItem(null) }}
/>
{filtered.length === 0 ? (
<div className="bp-empty">
No {activeCategory !== 'all' ? activeCategory : ''} items available.
Run <code>box-box --ingest-news</code> to refresh feeds.
</div>
) : (
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{filtered.map((item) => (
<BriefingCard
key={item.url}
item={item}
isActive={selectedItem?.url === item.url}
onSelect={handleSelect}
/>
))}
</div>
)}
</>
)}
<ReaderPanel item={selectedItem} onClose={handleClose} />
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { CommandCenterPage } from './pages/CommandCenterPage'
import { RaceHubPage } from './pages/RaceHubPage'
import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage'
type RaceHubSearch = {
session_key?: number
@@ -56,12 +57,19 @@ export const liveTimingRoute = createRoute({
component: LiveTimingPage,
})
export const briefingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/briefing',
component: BriefingPage,
})
const routeTree = rootRoute.addChildren([
commandCenterRoute,
raceHubRoute,
adminRoute,
dataLibraryRoute,
liveTimingRoute,
briefingRoute,
])
export const router = createRouter({ routeTree })

View File

@@ -2298,69 +2298,266 @@ a { color: inherit; text-decoration: none; }
.cc-empty-title { font-size: 19px; }
}
/* ── Paddock Briefing ── */
/* ── Command Center — Paddock Briefing preview strip ── */
.cc-briefing {
margin-top: var(--s7);
}
.briefing-state {
margin-top: var(--s7);
margin-top: var(--s4);
color: var(--text-2);
font-size: 12px;
}
.briefing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
.sec-action {
font-size: 11px;
color: var(--text-2);
text-decoration: none;
transition: color 0.1s;
letter-spacing: 0.03em;
}
.sec-action:hover { color: var(--text); }
.cc-brief-unread {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 16px;
padding: 0 4px;
margin-left: var(--s2);
background: var(--red);
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: 8px;
font-family: var(--f-mono);
vertical-align: middle;
}
.cc-brief-strip {
display: flex;
flex-direction: column;
gap: 1px;
}
.cc-brief-item {
display: flex;
flex-direction: column;
gap: var(--s1);
padding: var(--s3) var(--s4);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
transition: background 0.1s, border-color 0.1s;
cursor: pointer;
}
.cc-brief-item:hover {
background: var(--surface-h);
border-color: var(--border-2);
}
.cc-brief-item.is-read { opacity: 0.55; }
.cc-brief-item-meta {
display: flex;
gap: var(--s3);
font-size: 10px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cc-brief-source { color: var(--text-2); font-weight: 700; }
.cc-brief-age { color: var(--text-3); }
.cc-brief-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Briefing Full Page ── */
.bp-page {
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--s6) var(--s7);
}
.bp-topbar {
display: flex;
align-items: center;
gap: var(--s4);
padding: var(--s5) 0 var(--s4);
border-bottom: 1px solid var(--border);
margin-bottom: var(--s5);
}
.bp-topbar-label { font-size: 11px; color: var(--text-2); letter-spacing: 0.05em; }
.bp-topbar-meta { font-size: 11px; color: var(--text-3); margin-left: auto; }
/* Category tabs */
.bp-cats {
display: flex;
gap: var(--s2);
margin-bottom: var(--s5);
border-bottom: 1px solid var(--border);
padding-bottom: var(--s3);
}
.bp-cat {
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s2) var(--s4);
background: none;
border: 1px solid transparent;
color: var(--text-2);
font-size: 12px;
cursor: pointer;
border-radius: 2px;
transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.bp-cat:hover { color: var(--text); background: var(--surface); }
.bp-cat.active {
color: var(--text);
border-color: var(--border-2);
background: var(--surface);
}
.bp-cat-count {
font-size: 10px;
color: var(--text-3);
font-family: var(--f-mono);
}
.briefing-card {
/* Card grid */
.bp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--s4);
transition: grid-template-columns 0.2s;
}
.bp-grid.bp-grid-narrow {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.bp-empty {
padding: var(--s7) 0;
color: var(--text-2);
font-size: 13px;
}
.bp-empty code {
font-family: var(--f-mono);
font-size: 11px;
background: var(--surface);
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: 2px;
}
/* Individual card */
.bp-card {
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
padding: var(--s4);
border-radius: 2px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.1s, background 0.1s;
position: relative;
user-select: none;
}
.bp-card:hover { border-color: var(--border-2); background: var(--surface-h); }
.bp-card.bp-card-active { border-color: var(--red); }
.bp-card.bp-card-read { opacity: 0.6; }
.bp-card-img-wrap {
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--surface-2);
}
.bp-card-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.2s;
}
.bp-card:hover .bp-card-img { transform: scale(1.02); }
.bp-card-img.bp-card-img-fallback,
.bp-card-img-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, var(--surface-2) 0%, var(--border) 100%);
}
.bp-card-img-initial {
font-size: 28px;
font-weight: 700;
color: var(--text-3);
font-family: var(--f-mono);
}
.briefing-card:hover {
background: var(--surface-h);
border-color: var(--border-2);
.bp-video-badge {
position: absolute;
top: var(--s3);
right: var(--s3);
font-size: 10px;
font-family: var(--f-mono);
background: rgba(0,0,0,0.75);
color: var(--text);
padding: 2px 6px;
border-radius: 2px;
letter-spacing: 0.04em;
}
.briefing-card-head {
.bp-card-body {
display: flex;
flex-direction: column;
gap: var(--s2);
padding: var(--s4);
flex: 1;
}
.bp-card-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 10px;
color: var(--text-3);
margin-bottom: var(--s3);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.bp-card-source { color: var(--text-2); font-weight: 700; }
.bp-card-age { color: var(--text-3); }
.briefing-source {
color: var(--text-2);
font-weight: 700;
}
.briefing-title {
.bp-card-title {
font-size: 13px;
font-weight: 600;
line-height: 1.4;
margin-bottom: var(--s3);
color: var(--text);
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.briefing-summary {
.bp-card-desc {
font-size: 12px;
color: var(--text-2);
line-height: 1.5;
margin-bottom: var(--s4);
flex-grow: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.briefing-cat {
.bp-card-cat {
font-size: 9px;
color: var(--text-3);
align-self: flex-start;
@@ -2371,6 +2568,175 @@ a { color: inherit; text-decoration: none; }
letter-spacing: 0.05em;
}
/* Reader overlay backdrop */
.bp-reader-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 49;
}
/* Reader panel */
.bp-reader {
position: fixed;
top: var(--nav-h);
right: 0;
bottom: 0;
width: min(640px, 92vw);
background: var(--surface);
border-left: 1px solid var(--border);
overflow-y: auto;
z-index: 50;
transform: translateX(100%);
transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.bp-reader.open { transform: translateX(0); }
.bp-reader-toolbar {
position: sticky;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s3) var(--s5);
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 2;
}
.bp-reader-toolbar-meta {
display: flex;
align-items: center;
gap: 0;
font-size: 11px;
color: var(--text-3);
letter-spacing: 0.04em;
}
.bp-reader-dot { margin: 0 var(--s2); }
.bp-reader-toolbar-actions {
display: flex;
align-items: center;
gap: var(--s3);
}
.bp-reader-open {
font-size: 14px;
color: var(--text-2);
text-decoration: none;
transition: color 0.1s;
line-height: 1;
}
.bp-reader-open:hover { color: var(--text); }
.bp-reader-close {
background: none;
border: none;
color: var(--text-3);
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
transition: color 0.1s;
line-height: 1;
}
.bp-reader-close:hover { color: var(--text); }
.bp-reader-hero {
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--surface-2);
}
.bp-reader-hero img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.bp-reader-content {
padding: var(--s6) var(--s6) var(--s7);
}
.bp-reader-title {
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: var(--text);
margin-bottom: var(--s4);
}
.bp-reader-byline {
font-size: 11px;
color: var(--text-3);
margin-bottom: var(--s5);
letter-spacing: 0.04em;
}
.bp-reader-loading {
color: var(--text-2);
font-size: 13px;
padding: var(--s6) 0;
}
.bp-reader-error {
display: flex;
flex-direction: column;
gap: var(--s4);
padding: var(--s5) 0;
color: var(--text-2);
font-size: 13px;
}
.bp-reader-fallback {
color: var(--text-2);
line-height: 1.6;
}
.bp-reader-ext-link {
color: var(--text-2);
text-decoration: underline;
font-size: 12px;
}
.bp-reader-ext-link:hover { color: var(--text); }
/* Extracted article body */
.bp-reader-body {
font-size: 14px;
line-height: 1.75;
color: var(--text);
}
.bp-reader-body p { margin-bottom: 1em; }
.bp-reader-body h1,
.bp-reader-body h2,
.bp-reader-body h3 { font-weight: 600; margin: 1.5em 0 0.5em; line-height: 1.3; }
.bp-reader-body h1 { font-size: 18px; }
.bp-reader-body h2 { font-size: 16px; }
.bp-reader-body h3 { font-size: 14px; }
.bp-reader-body a { color: var(--text-2); text-decoration: underline; }
.bp-reader-body a:hover { color: var(--text); }
.bp-reader-body ul,
.bp-reader-body ol { padding-left: 1.5em; margin-bottom: 1em; }
.bp-reader-body li { margin-bottom: 0.3em; }
.bp-reader-body blockquote {
border-left: 3px solid var(--border-2);
padding-left: var(--s5);
color: var(--text-2);
margin: 1em 0;
}
.bp-reader-body img {
max-width: 100%;
height: auto;
display: block;
margin: var(--s5) 0;
}
.bp-reader-body figure { margin: var(--s5) 0; }
.bp-reader-body figcaption {
font-size: 11px;
color: var(--text-3);
margin-top: var(--s2);
}
@media (max-width: 600px) {
.bp-grid { grid-template-columns: 1fr; }
.bp-reader { width: 100vw; }
.bp-reader-content { padding: var(--s5) var(--s5) var(--s7); }
}
/* ── Race Story Canvas ── */
.race-story-canvas {
display: flex;

View File

@@ -275,4 +275,16 @@ export interface NewsItem {
summary?: string
category?: string
fetched_at: string
og_image_url?: string
og_description?: string
read_at?: string
}
export interface ArticleContent {
title: string
byline?: string
excerpt?: string
image_url?: string
content: string
site_name?: string
}

View File

@@ -74,6 +74,13 @@ export function compareFinishPosition(a: number, b: number): number {
return finishPositionOrder(a) - finishPositionOrder(b)
}
export function stripHtml(html: string): string {
if (!html) return ''
const div = document.createElement('div')
div.innerHTML = html
return div.textContent ?? ''
}
export function timeAgo(dateStr: string): string {
if (!dateStr) return ''
const d = new Date(dateStr)

10
go.mod
View File

@@ -3,15 +3,19 @@ module github.com/AmanTahiliani/box-box
go 1.25.6
require (
codeberg.org/readeck/go-readability/v2 v2.1.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/websocket v1.5.3
github.com/sahilm/fuzzy v0.1.1
golang.org/x/net v0.55.0
modernc.org/sqlite v1.47.0
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
@@ -23,6 +27,8 @@ require (
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -35,8 +41,8 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

115
go.sum
View File

@@ -1,3 +1,9 @@
codeberg.org/readeck/go-readability/v2 v2.1.1 h1:1tEwxFuUqDRP5JABzDHXGWRx5p9S7TElS3U8qQwXC5Y=
codeberg.org/readeck/go-readability/v2 v2.1.1/go.mod h1:x3WG9GpWWnkRb7ajP1NmOKSHbafxNUb736lrDZXeXrs=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -22,10 +28,18 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -42,6 +56,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -52,28 +67,108 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=

View File

@@ -10,30 +10,35 @@ import (
"sort"
"strings"
"time"
"golang.org/x/net/html"
)
const UserAgent = "box-box/phase-19b-rss-spike"
// Source describes a feed that can be fetched and normalized.
type Source struct {
ID string
Name string
URL string
Category string
ID string
Name string
URL string
Category string
SkipSummary bool // omit summary for sources where it's useless (e.g. YouTube)
}
// Item is the normalized shape consumed by storage and API layers.
type Item struct {
Source string
Title string
URL string
PublishedAt time.Time
Summary string
Category string
FetchedAt time.Time
Source string
Title string
URL string
PublishedAt time.Time
Summary string
Category string
FetchedAt time.Time
OGImageURL string
OGDescription string
}
// DefaultSources are free RSS/Atom feeds worth using for the Paddock Briefing spike.
// DefaultSources are free RSS/Atom feeds for the Paddock Briefing.
var DefaultSources = []Source{
{ID: "fia", Name: "FIA", URL: "https://www.fia.com/rss/news", Category: "official"},
{ID: "bbc-f1", Name: "BBC Sport F1", URL: "https://feeds.bbci.co.uk/sport/formula1", Category: "news"},
@@ -41,7 +46,7 @@ var DefaultSources = []Source{
{ID: "racefans-f1", Name: "RaceFans F1", URL: "https://www.racefans.net/category/f1-news/feed/", Category: "news"},
{ID: "guardian-f1", Name: "Guardian Formula One", URL: "https://www.theguardian.com/sport/formulaone/rss", Category: "news"},
{ID: "racer-f1", Name: "RACER F1", URL: "https://racer.com/f1/feed", Category: "news"},
{ID: "f1-youtube", Name: "Formula 1 YouTube", URL: "https://www.youtube.com/feeds/videos.xml?channel_id=UCB_qr75-ydFVKSF9Dmo6izg", Category: "video"},
{ID: "f1-youtube", Name: "Formula 1 YouTube", URL: "https://www.youtube.com/feeds/videos.xml?channel_id=UCB_qr75-ydFVKSF9Dmo6izg", Category: "video", SkipSummary: true},
}
// Fetch retrieves and parses one RSS or Atom feed with the provided HTTP client.
@@ -88,6 +93,83 @@ func Parse(source Source, r io.Reader, fetchedAt time.Time) ([]Item, error) {
return normalizeAtom(source, atom.Entries, fetchedAt), nil
}
// FetchOGMeta fetches only the <head> of a page and extracts og:image and og:description.
// It reads at most 64 KB to avoid full-page downloads.
func FetchOGMeta(ctx context.Context, client *http.Client, rawURL string) (imageURL, description string, err error) {
if client == nil {
client = &http.Client{Timeout: 8 * time.Second}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", "", err
}
req.Header.Set("User-Agent", UserAgent)
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", "", fmt.Errorf("og fetch %s: status %d", rawURL, resp.StatusCode)
}
limited := io.LimitReader(resp.Body, 64<<10)
tokenizer := html.NewTokenizer(limited)
for {
tt := tokenizer.Next()
if tt == html.ErrorToken {
break
}
if tt == html.EndTagToken {
tag, _ := tokenizer.TagName()
if string(tag) == "head" {
break
}
}
if tt != html.SelfClosingTagToken && tt != html.StartTagToken {
continue
}
tag, hasAttr := tokenizer.TagName()
if !hasAttr || string(tag) != "meta" {
continue
}
var property, name, content string
for {
k, v, more := tokenizer.TagAttr()
switch string(k) {
case "property":
property = string(v)
case "name":
name = string(v)
case "content":
content = string(v)
}
if !more {
break
}
}
switch {
case property == "og:image" && imageURL == "":
imageURL = strings.TrimSpace(content)
case property == "og:description" && description == "":
description = stripHTML(content)
case name == "description" && description == "":
description = stripHTML(content)
}
if imageURL != "" && description != "" {
break
}
}
return imageURL, description, nil
}
// DeduplicateByURL keeps the newest instance of each canonical URL.
func DeduplicateByURL(items []Item) []Item {
byURL := make(map[string]Item, len(items))
@@ -157,12 +239,16 @@ func normalizeRSS(source Source, raw []rssItem, fetchedAt time.Time) []Item {
if link == "" {
link = strings.TrimSpace(entry.GUID)
}
summary := ""
if !source.SkipSummary {
summary = stripHTML(entry.Description)
}
items = append(items, Item{
Source: source.ID,
Title: cleanText(entry.Title),
URL: link,
PublishedAt: parseFeedTime(entry.PubDate),
Summary: cleanText(entry.Description),
Summary: summary,
Category: firstNonEmpty(entry.Categories, source.Category),
FetchedAt: fetchedAt,
})
@@ -177,12 +263,17 @@ func normalizeAtom(source Source, raw []atomEntry, fetchedAt time.Time) []Item {
if len(entry.Categories) > 0 {
category = firstNonEmpty([]string{entry.Categories[0].Label, entry.Categories[0].Term}, source.Category)
}
summary := ""
if !source.SkipSummary {
raw := firstNonEmpty([]string{entry.Summary, entry.Content}, "")
summary = stripHTML(raw)
}
items = append(items, Item{
Source: source.ID,
Title: cleanText(entry.Title),
URL: atomEntryURL(entry),
PublishedAt: parseFeedTime(firstNonEmpty([]string{entry.Published, entry.Updated}, "")),
Summary: cleanText(firstNonEmpty([]string{entry.Summary, entry.Content}, "")),
Summary: summary,
Category: category,
FetchedAt: fetchedAt,
})
@@ -223,6 +314,33 @@ func parseFeedTime(value string) time.Time {
return time.Time{}
}
// stripHTML removes HTML tags and decodes entities, inserting line breaks for
// block-level elements so the resulting text remains readable as prose.
func stripHTML(value string) string {
if value == "" {
return ""
}
tokenizer := html.NewTokenizer(strings.NewReader(value))
var b strings.Builder
for {
tt := tokenizer.Next()
if tt == html.ErrorToken {
break
}
switch tt {
case html.TextToken:
b.WriteString(tokenizer.Token().Data)
case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken:
tag, _ := tokenizer.TagName()
switch string(tag) {
case "p", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6", "div", "blockquote", "tr":
b.WriteByte('\n')
}
}
}
return cleanText(b.String())
}
func cleanText(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, "\n", " ")

View File

@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/AmanTahiliani/box-box/internal/store"
@@ -22,12 +23,14 @@ type Store interface {
// RefreshOptions configures one local news refresh run.
type RefreshOptions struct {
Sources []Source
Client *http.Client
TTL time.Duration
DryRun bool
Now func() time.Time
Progress io.Writer
Sources []Source
Client *http.Client
TTL time.Duration
DryRun bool
Now func() time.Time
Progress io.Writer
EnrichOG bool // fetch og:image and og:description for each new item
OGParallel int // max concurrent OG fetches (default 5)
}
// RefreshResult summarizes one local news refresh run.
@@ -52,6 +55,9 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
if opts.TTL <= 0 {
opts.TTL = DefaultTTL
}
if opts.OGParallel <= 0 {
opts.OGParallel = 5
}
now := func() time.Time { return time.Now().UTC() }
if opts.Now != nil {
now = func() time.Time { return opts.Now().UTC() }
@@ -59,6 +65,10 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
var result RefreshResult
var failures []string
// Collect all items across sources for OG enrichment.
var allItems []Item
for _, source := range opts.Sources {
fetchedAt := now()
expiresAt := fetchedAt.Add(opts.TTL)
@@ -86,6 +96,7 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
result.ItemsFetched += len(items)
progressf(opts.Progress, "news: %s fetched %d items\n", source.ID, len(items))
if opts.DryRun {
allItems = append(allItems, items...)
continue
}
@@ -101,22 +112,40 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
}); err != nil {
return result, err
}
for _, item := range items {
item.FetchedAt = fetchedAt
publishedAt := timePtr(item.PublishedAt)
if err := st.UpsertNewsItem(store.NewsItem{
URL: item.URL,
Source: item.Source,
Title: item.Title,
PublishedAt: publishedAt,
Summary: item.Summary,
Category: item.Category,
FetchedAt: item.FetchedAt,
}); err != nil {
return result, err
}
result.ItemsUpserted++
for i := range items {
items[i].FetchedAt = fetchedAt
}
allItems = append(allItems, items...)
}
// OG enrichment pass: fetch og:image + og:description for each item.
if opts.EnrichOG && len(allItems) > 0 {
progressf(opts.Progress, "news: enriching %d items with OG metadata\n", len(allItems))
enrichOG(ctx, opts.Client, allItems, opts.OGParallel, opts.Progress)
}
if opts.DryRun {
return result, nil
}
// Store all items (with OG data already populated).
for _, item := range allItems {
publishedAt := timePtr(item.PublishedAt)
if err := st.UpsertNewsItem(store.NewsItem{
URL: item.URL,
Source: item.Source,
Title: item.Title,
PublishedAt: publishedAt,
Summary: item.Summary,
Category: item.Category,
FetchedAt: item.FetchedAt,
OGImageURL: item.OGImageURL,
OGDescription: item.OGDescription,
}); err != nil {
return result, err
}
result.ItemsUpserted++
}
if len(failures) > 0 {
@@ -125,6 +154,38 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
return result, nil
}
// enrichOG fetches og:image and og:description for each item concurrently.
func enrichOG(ctx context.Context, client *http.Client, items []Item, parallel int, progress io.Writer) {
sem := make(chan struct{}, parallel)
var mu sync.Mutex
var wg sync.WaitGroup
ogClient := &http.Client{Timeout: 8 * time.Second}
if client != nil {
ogClient = &http.Client{Timeout: 8 * time.Second, Transport: client.Transport}
}
for i := range items {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
imgURL, desc, err := FetchOGMeta(ctx, ogClient, items[idx].URL)
if err != nil {
progressf(progress, "news: og fetch failed %s: %v\n", items[idx].URL, err)
return
}
mu.Lock()
items[idx].OGImageURL = imgURL
items[idx].OGDescription = desc
mu.Unlock()
}(i)
}
wg.Wait()
}
func progressf(w io.Writer, format string, args ...any) {
if w == nil {
return

View File

@@ -8,13 +8,16 @@ import (
// NewsItem is the frontend-facing cached briefing shape.
type NewsItem struct {
Source string `json:"source"`
Title string `json:"title"`
URL string `json:"url"`
PublishedAt *time.Time `json:"published_at,omitempty"`
Summary string `json:"summary,omitempty"`
Category string `json:"category,omitempty"`
FetchedAt time.Time `json:"fetched_at"`
Source string `json:"source"`
Title string `json:"title"`
URL string `json:"url"`
PublishedAt *time.Time `json:"published_at,omitempty"`
Summary string `json:"summary,omitempty"`
Category string `json:"category,omitempty"`
FetchedAt time.Time `json:"fetched_at"`
OGImageURL string `json:"og_image_url,omitempty"`
OGDescription string `json:"og_description,omitempty"`
ReadAt *time.Time `json:"read_at,omitempty"`
}
// ListNews returns cached briefing items.
@@ -26,26 +29,36 @@ func (s *Service) ListNews(limit int, source string) ([]NewsItem, error) {
out := make([]NewsItem, 0, len(rows))
for _, row := range rows {
out = append(out, NewsItem{
Source: row.Source,
Title: row.Title,
URL: row.URL,
PublishedAt: row.PublishedAt,
Summary: row.Summary,
Category: row.Category,
FetchedAt: row.FetchedAt,
Source: row.Source,
Title: row.Title,
URL: row.URL,
PublishedAt: row.PublishedAt,
Summary: row.Summary,
Category: row.Category,
FetchedAt: row.FetchedAt,
OGImageURL: row.OGImageURL,
OGDescription: row.OGDescription,
ReadAt: row.ReadAt,
})
}
return out, nil
}
// MarkNewsRead marks a news item as read by URL.
func (s *Service) MarkNewsRead(url string) error {
return s.store.MarkNewsItemRead(url)
}
func NewsItemToStore(item NewsItem) store.NewsItem {
return store.NewsItem{
URL: item.URL,
Source: item.Source,
Title: item.Title,
PublishedAt: item.PublishedAt,
Summary: item.Summary,
Category: item.Category,
FetchedAt: item.FetchedAt,
URL: item.URL,
Source: item.Source,
Title: item.Title,
PublishedAt: item.PublishedAt,
Summary: item.Summary,
Category: item.Category,
FetchedAt: item.FetchedAt,
OGImageURL: item.OGImageURL,
OGDescription: item.OGDescription,
}
}

View File

@@ -0,0 +1,3 @@
ALTER TABLE news_items ADD COLUMN og_image_url TEXT;
ALTER TABLE news_items ADD COLUMN og_description TEXT;
ALTER TABLE news_items ADD COLUMN read_at INTEGER;

View File

@@ -42,13 +42,16 @@ type NewsSource struct {
// NewsItem stores a normalized feed item deduplicated by URL.
type NewsItem struct {
URL string
Source string
Title string
PublishedAt *time.Time
Summary string
Category string
FetchedAt time.Time
URL string
Source string
Title string
PublishedAt *time.Time
Summary string
Category string
FetchedAt time.Time
OGImageURL string
OGDescription string
ReadAt *time.Time
}
// Meeting is a race weekend record.

View File

@@ -41,18 +41,23 @@ func (s *Store) UpsertNewsSource(src NewsSource) error {
}
// UpsertNewsItem inserts or updates a normalized feed item.
// read_at is never overwritten by a re-fetch.
// og_image_url and og_description preserve an existing value when the new one is empty.
func (s *Store) UpsertNewsItem(item NewsItem) error {
_, err := s.db.Exec(`
INSERT INTO news_items (
url, source, title, published_at, summary, category, fetched_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
url, source, title, published_at, summary, category, fetched_at,
og_image_url, og_description
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET
source = excluded.source,
title = excluded.title,
published_at = excluded.published_at,
summary = excluded.summary,
category = excluded.category,
fetched_at = excluded.fetched_at
source = excluded.source,
title = excluded.title,
published_at = excluded.published_at,
summary = excluded.summary,
category = excluded.category,
fetched_at = excluded.fetched_at,
og_image_url = COALESCE(excluded.og_image_url, news_items.og_image_url),
og_description= COALESCE(excluded.og_description, news_items.og_description)
`,
item.URL,
item.Source,
@@ -61,6 +66,8 @@ func (s *Store) UpsertNewsItem(item NewsItem) error {
nullString(item.Summary),
nullString(item.Category),
item.FetchedAt.Unix(),
nullString(item.OGImageURL),
nullString(item.OGDescription),
)
if err != nil {
return fmt.Errorf("upsert news item: %w", err)
@@ -68,6 +75,15 @@ func (s *Store) UpsertNewsItem(item NewsItem) error {
return nil
}
// MarkNewsItemRead sets read_at to now for the given URL.
func (s *Store) MarkNewsItemRead(url string) error {
_, err := s.db.Exec(
`UPDATE news_items SET read_at = ? WHERE url = ?`,
time.Now().Unix(), url,
)
return err
}
// ListNewsItems returns newest cached news items, optionally filtered by source.
func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
if limit <= 0 || limit > 100 {
@@ -75,7 +91,8 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
}
query := `
SELECT url, source, title, published_at, summary, category, fetched_at
SELECT url, source, title, published_at, summary, category, fetched_at,
og_image_url, og_description, read_at
FROM news_items
`
var args []any
@@ -95,8 +112,8 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
var out []NewsItem
for rows.Next() {
var item NewsItem
var published sql.NullInt64
var summary, category sql.NullString
var published, readAt sql.NullInt64
var summary, category, ogImage, ogDesc sql.NullString
var fetched int64
if err := rows.Scan(
&item.URL,
@@ -106,6 +123,9 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
&summary,
&category,
&fetched,
&ogImage,
&ogDesc,
&readAt,
); err != nil {
return nil, err
}
@@ -113,6 +133,9 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
item.Summary = summary.String
item.Category = category.String
item.FetchedAt = time.Unix(fetched, 0).UTC()
item.OGImageURL = ogImage.String
item.OGDescription = ogDesc.String
item.ReadAt = nullTimePtr(readAt)
out = append(out, item)
}
return out, rows.Err()

View File

@@ -1,9 +1,11 @@
package web
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
@@ -11,6 +13,8 @@ import (
"sync"
"time"
readability "codeberg.org/readeck/go-readability/v2"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query"
)
@@ -133,6 +137,80 @@ func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
writeJSON(w, items)
}
// --- /api/v1/news/article ---
type articleResponse struct {
Title string `json:"title"`
Byline string `json:"byline,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
ImageURL string `json:"image_url,omitempty"`
Content string `json:"content"`
SiteName string `json:"site_name,omitempty"`
}
func (s *Server) handleNewsArticle(w http.ResponseWriter, r *http.Request) {
rawURL := strings.TrimSpace(r.URL.Query().Get("url"))
if rawURL == "" {
http.Error(w, "url required", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
withCtx := readability.RequestWith(func(req *http.Request) {
*req = *req.WithContext(ctx)
})
article, err := readability.FromURL(rawURL, 20*time.Second, withCtx)
if err != nil {
writeError(w, fmt.Errorf("article fetch: %w", err), http.StatusBadGateway, false)
return
}
var buf strings.Builder
if article.Node != nil {
if rerr := article.RenderHTML(&buf); rerr != nil {
writeError(w, rerr, http.StatusInternalServerError, false)
return
}
}
writeJSON(w, articleResponse{
Title: article.Title(),
Byline: article.Byline(),
Excerpt: article.Excerpt(),
ImageURL: article.ImageURL(),
Content: buf.String(),
SiteName: article.SiteName(),
})
}
// --- /api/v1/news/read ---
func (s *Server) handleNewsRead(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.hasLocalQuery() {
w.WriteHeader(http.StatusNoContent)
return
}
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
http.Error(w, "url required", http.StatusBadRequest)
return
}
if err := s.query.MarkNewsRead(body.URL); err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- /api/v1/drivers ---
func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {

View File

@@ -64,6 +64,8 @@ func (s *Server) routes() (http.Handler, error) {
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
mux.HandleFunc("/api/v1/news/article", s.handleNewsArticle)
mux.HandleFunc("/api/v1/news/read", s.handleNewsRead)
mux.HandleFunc("/api/v1/news", s.handleNews)
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
mux.HandleFunc("/api/v1/sessions", s.handleSessions)