From 3eb74a908323acb7ef84c55946ae8fc0ff585593 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 15:27:34 -0400 Subject: [PATCH] Paginate briefing cards and add video previews --- frontend/src/pages/BriefingPage.tsx | 157 ++++++++++++++++++++++++---- frontend/src/styles/app.css | 105 +++++++++++++++++-- internal/news/news.go | 37 +++++-- internal/news/refresh.go | 3 + internal/store/coverage_test.go | 6 +- internal/store/store_test.go | 10 +- 6 files changed, 277 insertions(+), 41 deletions(-) diff --git a/frontend/src/pages/BriefingPage.tsx b/frontend/src/pages/BriefingPage.tsx index 6c58440..a283f05 100644 --- a/frontend/src/pages/BriefingPage.tsx +++ b/frontend/src/pages/BriefingPage.tsx @@ -6,6 +6,8 @@ import type { ArticleContent, NewsItem } from '../types' type Category = 'all' | 'official' | 'news' | 'video' +const PAGE_SIZE = 16 + const CATEGORY_LABELS: Record = { all: 'All', official: 'Official', @@ -34,6 +36,12 @@ function categoryOf(item: NewsItem): Category { return 'news' } +function getYouTubeVideoId(url: string): string | null { + const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/ + const match = url.match(regExp) + return match && match[2].length === 11 ? match[2] : null +} + function CategoryTabs({ active, counts, @@ -116,7 +124,7 @@ function BriefingCard({ {timeAgo(item.published_at ?? item.fetched_at)}

{item.title}

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

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

@@ -153,13 +161,19 @@ function ReaderPanel({ setArticle(null) return } + if (categoryOf(item) === 'video') { + setArticle(null) + setLoading(false) + setError(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]) + }, [item?.url, item ? categoryOf(item) : '']) // Scroll panel to top when item changes useEffect(() => { @@ -207,14 +221,27 @@ function ReaderPanel({ - {(article?.image_url || item.og_image_url) && ( -
- + {categoryOf(item) === 'video' ? ( +
+
+ ) : ( + (article?.image_url || item.og_image_url) && ( +
+ +
+ ) )}
@@ -225,6 +252,25 @@ function ReaderPanel({
{article.byline}
)} + {categoryOf(item) === 'video' && ( +
+ {(item.og_description || item.summary) && ( +

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

+ )} + + Open in YouTube ↗ + +
+ )} + {loading && (
Loading article…
)} @@ -286,6 +332,7 @@ function ReaderPanel({ export function BriefingPage() { const [activeCategory, setActiveCategory] = useState('all') const [selectedItem, setSelectedItem] = useState(null) + const [page, setPage] = useState(1) const queryClient = useQueryClient() const { data: allNews = [], isLoading, isError } = useQuery({ @@ -297,6 +344,10 @@ export function BriefingPage() { const filtered = allNews.filter( (item) => activeCategory === 'all' || categoryOf(item) === activeCategory, ) + const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) + const safePage = Math.min(page, pageCount) + const pageStart = (safePage - 1) * PAGE_SIZE + const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE) const counts: Record = { all: allNews.length, @@ -323,7 +374,22 @@ export function BriefingPage() { const handleClose = useCallback(() => setSelectedItem(null), []) + useEffect(() => { + setPage((current) => Math.min(current, pageCount)) + }, [pageCount]) + + const handlePageChange = useCallback( + (nextPage: number) => { + setPage(Math.min(Math.max(nextPage, 1), pageCount)) + setSelectedItem(null) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }, + [pageCount], + ) + const unreadCount = allNews.filter((i) => !i.read_at).length + const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length) + const showPagination = filtered.length > PAGE_SIZE return (
@@ -342,7 +408,7 @@ export function BriefingPage() { { setActiveCategory(c); setSelectedItem(null) }} + onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }} /> {filtered.length === 0 ? ( @@ -351,16 +417,67 @@ export function BriefingPage() { Run box-box --ingest-news to refresh feeds.
) : ( -
- {filtered.map((item) => ( - - ))} -
+ <> + {showPagination && ( +
+ + {pageStart + 1}-{pageEnd} of {filtered.length} + +
+ + + Page {safePage} / {pageCount} + + +
+
+ )} + +
+ {pagedItems.map((item) => ( + + ))} +
+ + {showPagination && ( +
+ + + Page {safePage} / {pageCount} + + +
+ )} + )} )} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 8b50034..1ee0aa3 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -2455,22 +2455,76 @@ a { color: inherit; text-decoration: none; } border-radius: 2px; } +.bp-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s4); +} +.bp-pagination-top { + margin-bottom: var(--s4); + padding-bottom: var(--s3); + border-bottom: 1px solid var(--border); +} +.bp-pagination-bottom { + justify-content: center; + margin-top: var(--s5); +} +.bp-pagination-meta, +.bp-page-current { + font-size: 11px; + color: var(--text-3); + letter-spacing: 0.04em; +} +.bp-pagination-actions { + display: flex; + align-items: center; + gap: var(--s3); +} +.bp-page-btn { + min-width: 76px; + padding: var(--s2) var(--s4); + background: var(--surface); + border: 1px solid var(--border-2); + border-radius: 2px; + color: var(--text-2); + cursor: pointer; + font-size: 12px; +} +.bp-page-btn:hover:not(:disabled) { + color: var(--text); + background: var(--surface-h); +} +.bp-page-btn:disabled { + color: var(--text-3); + cursor: not-allowed; + opacity: 0.45; +} + /* Individual card */ .bp-card { display: flex; flex-direction: column; background: var(--surface); border: 1px solid var(--border); - border-radius: 2px; + border-radius: 6px; overflow: hidden; cursor: pointer; - transition: border-color 0.1s, background 0.1s; + transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; 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:hover { + border-color: var(--border-2); + background: var(--surface-h); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); + transform: translateY(-2px); +} +.bp-card.bp-card-active { + border-color: var(--red); + box-shadow: 0 0 0 1px var(--red), 0 4px 20px rgba(225, 6, 0, 0.15); +} +.bp-card.bp-card-read { opacity: 0.5; } .bp-card-img-wrap { width: 100%; @@ -2483,9 +2537,9 @@ a { color: inherit; text-decoration: none; } height: 100%; object-fit: cover; display: block; - transition: transform 0.2s; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); } -.bp-card:hover .bp-card-img { transform: scale(1.02); } +.bp-card:hover .bp-card-img { transform: scale(1.04); } .bp-card-img.bp-card-img-fallback, .bp-card-img-fallback { @@ -2638,11 +2692,34 @@ a { color: inherit; text-decoration: none; } } .bp-reader-close:hover { color: var(--text); } +.bp-reader-video-container { + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + background: #000; + position: relative; + border-bottom: 1px solid var(--border); +} +.bp-reader-video-iframe { + width: 100%; + height: 100%; + border: 0; + display: block; +} +.bp-reader-video-desc { + margin-bottom: var(--s5); + padding: var(--s4); + background: var(--surface-2); + border-left: 3px solid var(--red); + border-radius: 2px; +} + .bp-reader-hero { width: 100%; aspect-ratio: 16 / 9; overflow: hidden; background: var(--surface-2); + border-bottom: 1px solid var(--border); } .bp-reader-hero img { width: 100%; @@ -2733,6 +2810,20 @@ a { color: inherit; text-decoration: none; } @media (max-width: 600px) { .bp-grid { grid-template-columns: 1fr; } + .bp-pagination { + align-items: stretch; + flex-direction: column; + } + .bp-pagination-actions, + .bp-pagination-bottom { + display: grid; + grid-template-columns: 1fr auto 1fr; + width: 100%; + } + .bp-page-current { + align-self: center; + text-align: center; + } .bp-reader { width: 100vw; } .bp-reader-content { padding: var(--s5) var(--s5) var(--s7); } } diff --git a/internal/news/news.go b/internal/news/news.go index 3ff8171..499ca51 100644 --- a/internal/news/news.go +++ b/internal/news/news.go @@ -14,7 +14,7 @@ import ( "golang.org/x/net/html" ) -const UserAgent = "box-box/phase-19b-rss-spike" +const UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" // Source describes a feed that can be fetched and normalized. type Source struct { @@ -207,6 +207,12 @@ type rssItem struct { PubDate string `xml:"pubDate"` Description string `xml:"description"` Categories []string `xml:"category"` + Thumbnail struct { + URL string `xml:"url,attr"` + } `xml:"http://search.yahoo.com/mrss/ thumbnail"` + Content struct { + URL string `xml:"url,attr"` + } `xml:"http://search.yahoo.com/mrss/ content"` } type atomFeed struct { @@ -225,6 +231,12 @@ type atomEntry struct { Term string `xml:"term,attr"` Label string `xml:"label,attr"` } `xml:"category"` + MediaGroup struct { + Thumbnail struct { + URL string `xml:"url,attr"` + } `xml:"http://search.yahoo.com/mrss/ thumbnail"` + Description string `xml:"http://search.yahoo.com/mrss/ description"` + } `xml:"http://search.yahoo.com/mrss/ group"` } type atomLink struct { @@ -243,6 +255,10 @@ func normalizeRSS(source Source, raw []rssItem, fetchedAt time.Time) []Item { if !source.SkipSummary { summary = stripHTML(entry.Description) } + imgURL := entry.Thumbnail.URL + if imgURL == "" { + imgURL = entry.Content.URL + } items = append(items, Item{ Source: source.ID, Title: cleanText(entry.Title), @@ -251,6 +267,7 @@ func normalizeRSS(source Source, raw []rssItem, fetchedAt time.Time) []Item { Summary: summary, Category: firstNonEmpty(entry.Categories, source.Category), FetchedAt: fetchedAt, + OGImageURL: imgURL, }) } return DeduplicateByURL(items) @@ -265,17 +282,19 @@ func normalizeAtom(source Source, raw []atomEntry, fetchedAt time.Time) []Item { } summary := "" if !source.SkipSummary { - raw := firstNonEmpty([]string{entry.Summary, entry.Content}, "") + raw := firstNonEmpty([]string{entry.Summary, entry.Content, entry.MediaGroup.Description}, "") 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: summary, - Category: category, - FetchedAt: fetchedAt, + Source: source.ID, + Title: cleanText(entry.Title), + URL: atomEntryURL(entry), + PublishedAt: parseFeedTime(firstNonEmpty([]string{entry.Published, entry.Updated}, "")), + Summary: summary, + Category: category, + FetchedAt: fetchedAt, + OGImageURL: entry.MediaGroup.Thumbnail.URL, + OGDescription: stripHTML(entry.MediaGroup.Description), }) } return DeduplicateByURL(items) diff --git a/internal/news/refresh.go b/internal/news/refresh.go index fae346f..7d174c2 100644 --- a/internal/news/refresh.go +++ b/internal/news/refresh.go @@ -166,6 +166,9 @@ func enrichOG(ctx context.Context, client *http.Client, items []Item, parallel i } for i := range items { + if items[i].OGImageURL != "" { + continue // Already has preview from feed, skip OG head fetch + } wg.Add(1) go func(idx int) { defer wg.Done() diff --git a/internal/store/coverage_test.go b/internal/store/coverage_test.go index e7767e3..4c3aa3b 100644 --- a/internal/store/coverage_test.go +++ b/internal/store/coverage_test.go @@ -7,13 +7,13 @@ import ( func TestCoverageCRUD(t *testing.T) { s := openTestStore(t) - // Verify schema migration version is 4 (since we added 004_coverage.sql) + // Verify schema migration version is 5 (since we added 005_news_enriched.sql) version, err := s.SchemaVersion() if err != nil { t.Fatalf("SchemaVersion() error = %v", err) } - if version != 4 { - t.Fatalf("SchemaVersion() = %d, want 4", version) + if version != 5 { + t.Fatalf("SchemaVersion() = %d, want 5", version) } // Verify session_coverage table exists diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 9f338b7..9056c46 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) { if err != nil { t.Fatalf("SchemaVersion() error = %v", err) } - if version != 4 { - t.Fatalf("SchemaVersion() = %d, want 4", version) + if version != 5 { + t.Fatalf("SchemaVersion() = %d, want 5", version) } tables := []string{ @@ -99,6 +99,12 @@ func TestMigrationsAreIdempotent(t *testing.T) { if count != 1 { t.Fatalf("schema_migrations v4 count = %d, want 1", count) } + if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 5`).Scan(&count); err != nil { + t.Fatalf("count schema_migrations v5: %v", err) + } + if count != 1 { + t.Fatalf("schema_migrations v5 count = %d, want 1", count) + } } func TestRawPayloadInsertAndRead(t *testing.T) {