Paginate briefing cards and add video previews

This commit is contained in:
2026-05-25 15:27:34 -04:00
parent e3453de788
commit 3eb74a9083
6 changed files with 277 additions and 41 deletions

View File

@@ -6,6 +6,8 @@ import type { ArticleContent, NewsItem } from '../types'
type Category = 'all' | 'official' | 'news' | 'video' type Category = 'all' | 'official' | 'news' | 'video'
const PAGE_SIZE = 16
const CATEGORY_LABELS: Record<Category, string> = { const CATEGORY_LABELS: Record<Category, string> = {
all: 'All', all: 'All',
official: 'Official', official: 'Official',
@@ -34,6 +36,12 @@ function categoryOf(item: NewsItem): Category {
return 'news' 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({ function CategoryTabs({
active, active,
counts, counts,
@@ -116,7 +124,7 @@ function BriefingCard({
<span className="bp-card-age">{timeAgo(item.published_at ?? item.fetched_at)}</span> <span className="bp-card-age">{timeAgo(item.published_at ?? item.fetched_at)}</span>
</div> </div>
<h3 className="bp-card-title">{item.title}</h3> <h3 className="bp-card-title">{item.title}</h3>
{!isVideo && (item.og_description || item.summary) && ( {(item.og_description || item.summary) && (
<p className="bp-card-desc"> <p className="bp-card-desc">
{stripHtml(item.og_description || item.summary || '')} {stripHtml(item.og_description || item.summary || '')}
</p> </p>
@@ -153,13 +161,19 @@ function ReaderPanel({
setArticle(null) setArticle(null)
return return
} }
if (categoryOf(item) === 'video') {
setArticle(null)
setLoading(false)
setError(null)
return
}
setLoading(true) setLoading(true)
setError(null) setError(null)
setArticle(null) setArticle(null)
fetchNewsArticle(item.url) fetchNewsArticle(item.url)
.then((data) => { setArticle(data); setLoading(false) }) .then((data) => { setArticle(data); setLoading(false) })
.catch((err) => { setError(String(err)); setLoading(false) }) .catch((err) => { setError(String(err)); setLoading(false) })
}, [item?.url]) }, [item?.url, item ? categoryOf(item) : ''])
// Scroll panel to top when item changes // Scroll panel to top when item changes
useEffect(() => { useEffect(() => {
@@ -207,14 +221,27 @@ function ReaderPanel({
</div> </div>
</div> </div>
{(article?.image_url || item.og_image_url) && ( {categoryOf(item) === 'video' ? (
<div className="bp-reader-hero"> <div className="bp-reader-video-container">
<img <iframe
src={article?.image_url ?? item.og_image_url} className="bp-reader-video-iframe"
alt="" src={`https://www.youtube.com/embed/${getYouTubeVideoId(item.url)}?autoplay=1&rel=0`}
loading="lazy" title={item.title}
/> frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
</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"> <div className="bp-reader-content">
@@ -225,6 +252,25 @@ function ReaderPanel({
<div className="bp-reader-byline mono">{article.byline}</div> <div className="bp-reader-byline mono">{article.byline}</div>
)} )}
{categoryOf(item) === 'video' && (
<div className="bp-reader-video-desc">
{(item.og_description || item.summary) && (
<p className="bp-reader-fallback" style={{ marginBottom: '16px' }}>
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-ext-link"
style={{ fontSize: '13px', fontWeight: 600 }}
>
Open in YouTube
</a>
</div>
)}
{loading && ( {loading && (
<div className="bp-reader-loading">Loading article</div> <div className="bp-reader-loading">Loading article</div>
)} )}
@@ -286,6 +332,7 @@ function ReaderPanel({
export function BriefingPage() { export function BriefingPage() {
const [activeCategory, setActiveCategory] = useState<Category>('all') const [activeCategory, setActiveCategory] = useState<Category>('all')
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null) const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
const [page, setPage] = useState(1)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: allNews = [], isLoading, isError } = useQuery({ const { data: allNews = [], isLoading, isError } = useQuery({
@@ -297,6 +344,10 @@ export function BriefingPage() {
const filtered = allNews.filter( const filtered = allNews.filter(
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory, (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<Category, number> = { const counts: Record<Category, number> = {
all: allNews.length, all: allNews.length,
@@ -323,7 +374,22 @@ export function BriefingPage() {
const handleClose = useCallback(() => setSelectedItem(null), []) 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 unreadCount = allNews.filter((i) => !i.read_at).length
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
const showPagination = filtered.length > PAGE_SIZE
return ( return (
<div className="bp-page" data-testid="briefing-page"> <div className="bp-page" data-testid="briefing-page">
@@ -342,7 +408,7 @@ export function BriefingPage() {
<CategoryTabs <CategoryTabs
active={activeCategory} active={activeCategory}
counts={counts} counts={counts}
onChange={(c) => { setActiveCategory(c); setSelectedItem(null) }} onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
/> />
{filtered.length === 0 ? ( {filtered.length === 0 ? (
@@ -351,16 +417,67 @@ export function BriefingPage() {
Run <code>box-box --ingest-news</code> to refresh feeds. Run <code>box-box --ingest-news</code> to refresh feeds.
</div> </div>
) : ( ) : (
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}> <>
{filtered.map((item) => ( {showPagination && (
<BriefingCard <div className="bp-pagination bp-pagination-top">
key={item.url} <span className="bp-pagination-meta mono">
item={item} {pageStart + 1}-{pageEnd} of {filtered.length}
isActive={selectedItem?.url === item.url} </span>
onSelect={handleSelect} <div className="bp-pagination-actions">
/> <button
))} className="bp-page-btn"
</div> onClick={() => handlePageChange(safePage - 1)}
disabled={safePage === 1}
>
Previous
</button>
<span className="bp-page-current mono">
Page {safePage} / {pageCount}
</span>
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage + 1)}
disabled={safePage === pageCount}
>
Next
</button>
</div>
</div>
)}
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{pagedItems.map((item) => (
<BriefingCard
key={item.url}
item={item}
isActive={selectedItem?.url === item.url}
onSelect={handleSelect}
/>
))}
</div>
{showPagination && (
<div className="bp-pagination bp-pagination-bottom">
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage - 1)}
disabled={safePage === 1}
>
Previous
</button>
<span className="bp-page-current mono">
Page {safePage} / {pageCount}
</span>
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage + 1)}
disabled={safePage === pageCount}
>
Next
</button>
</div>
)}
</>
)} )}
</> </>
)} )}

View File

@@ -2455,22 +2455,76 @@ a { color: inherit; text-decoration: none; }
border-radius: 2px; 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 */ /* Individual card */
.bp-card { .bp-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 2px; border-radius: 6px;
overflow: hidden; overflow: hidden;
cursor: pointer; 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; position: relative;
user-select: none; user-select: none;
} }
.bp-card:hover { border-color: var(--border-2); background: var(--surface-h); } .bp-card:hover {
.bp-card.bp-card-active { border-color: var(--red); } border-color: var(--border-2);
.bp-card.bp-card-read { opacity: 0.6; } 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 { .bp-card-img-wrap {
width: 100%; width: 100%;
@@ -2483,9 +2537,9 @@ a { color: inherit; text-decoration: none; }
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; 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.bp-card-img-fallback,
.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-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 { .bp-reader-hero {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
overflow: hidden; overflow: hidden;
background: var(--surface-2); background: var(--surface-2);
border-bottom: 1px solid var(--border);
} }
.bp-reader-hero img { .bp-reader-hero img {
width: 100%; width: 100%;
@@ -2733,6 +2810,20 @@ a { color: inherit; text-decoration: none; }
@media (max-width: 600px) { @media (max-width: 600px) {
.bp-grid { grid-template-columns: 1fr; } .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 { width: 100vw; }
.bp-reader-content { padding: var(--s5) var(--s5) var(--s7); } .bp-reader-content { padding: var(--s5) var(--s5) var(--s7); }
} }

View File

@@ -14,7 +14,7 @@ import (
"golang.org/x/net/html" "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. // Source describes a feed that can be fetched and normalized.
type Source struct { type Source struct {
@@ -207,6 +207,12 @@ type rssItem struct {
PubDate string `xml:"pubDate"` PubDate string `xml:"pubDate"`
Description string `xml:"description"` Description string `xml:"description"`
Categories []string `xml:"category"` 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 { type atomFeed struct {
@@ -225,6 +231,12 @@ type atomEntry struct {
Term string `xml:"term,attr"` Term string `xml:"term,attr"`
Label string `xml:"label,attr"` Label string `xml:"label,attr"`
} `xml:"category"` } `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 { type atomLink struct {
@@ -243,6 +255,10 @@ func normalizeRSS(source Source, raw []rssItem, fetchedAt time.Time) []Item {
if !source.SkipSummary { if !source.SkipSummary {
summary = stripHTML(entry.Description) summary = stripHTML(entry.Description)
} }
imgURL := entry.Thumbnail.URL
if imgURL == "" {
imgURL = entry.Content.URL
}
items = append(items, Item{ items = append(items, Item{
Source: source.ID, Source: source.ID,
Title: cleanText(entry.Title), Title: cleanText(entry.Title),
@@ -251,6 +267,7 @@ func normalizeRSS(source Source, raw []rssItem, fetchedAt time.Time) []Item {
Summary: summary, Summary: summary,
Category: firstNonEmpty(entry.Categories, source.Category), Category: firstNonEmpty(entry.Categories, source.Category),
FetchedAt: fetchedAt, FetchedAt: fetchedAt,
OGImageURL: imgURL,
}) })
} }
return DeduplicateByURL(items) return DeduplicateByURL(items)
@@ -265,17 +282,19 @@ func normalizeAtom(source Source, raw []atomEntry, fetchedAt time.Time) []Item {
} }
summary := "" summary := ""
if !source.SkipSummary { if !source.SkipSummary {
raw := firstNonEmpty([]string{entry.Summary, entry.Content}, "") raw := firstNonEmpty([]string{entry.Summary, entry.Content, entry.MediaGroup.Description}, "")
summary = stripHTML(raw) summary = stripHTML(raw)
} }
items = append(items, Item{ items = append(items, Item{
Source: source.ID, Source: source.ID,
Title: cleanText(entry.Title), Title: cleanText(entry.Title),
URL: atomEntryURL(entry), URL: atomEntryURL(entry),
PublishedAt: parseFeedTime(firstNonEmpty([]string{entry.Published, entry.Updated}, "")), PublishedAt: parseFeedTime(firstNonEmpty([]string{entry.Published, entry.Updated}, "")),
Summary: summary, Summary: summary,
Category: category, Category: category,
FetchedAt: fetchedAt, FetchedAt: fetchedAt,
OGImageURL: entry.MediaGroup.Thumbnail.URL,
OGDescription: stripHTML(entry.MediaGroup.Description),
}) })
} }
return DeduplicateByURL(items) return DeduplicateByURL(items)

View File

@@ -166,6 +166,9 @@ func enrichOG(ctx context.Context, client *http.Client, items []Item, parallel i
} }
for i := range items { for i := range items {
if items[i].OGImageURL != "" {
continue // Already has preview from feed, skip OG head fetch
}
wg.Add(1) wg.Add(1)
go func(idx int) { go func(idx int) {
defer wg.Done() defer wg.Done()

View File

@@ -7,13 +7,13 @@ import (
func TestCoverageCRUD(t *testing.T) { func TestCoverageCRUD(t *testing.T) {
s := openTestStore(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() version, err := s.SchemaVersion()
if err != nil { if err != nil {
t.Fatalf("SchemaVersion() error = %v", err) t.Fatalf("SchemaVersion() error = %v", err)
} }
if version != 4 { if version != 5 {
t.Fatalf("SchemaVersion() = %d, want 4", version) t.Fatalf("SchemaVersion() = %d, want 5", version)
} }
// Verify session_coverage table exists // Verify session_coverage table exists

View File

@@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SchemaVersion() error = %v", err) t.Fatalf("SchemaVersion() error = %v", err)
} }
if version != 4 { if version != 5 {
t.Fatalf("SchemaVersion() = %d, want 4", version) t.Fatalf("SchemaVersion() = %d, want 5", version)
} }
tables := []string{ tables := []string{
@@ -99,6 +99,12 @@ func TestMigrationsAreIdempotent(t *testing.T) {
if count != 1 { if count != 1 {
t.Fatalf("schema_migrations v4 count = %d, want 1", count) 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) { func TestRawPayloadInsertAndRead(t *testing.T) {