diff --git a/internal/adapters/store/reads.go b/internal/adapters/store/reads.go index 78c294c..4ca1336 100644 --- a/internal/adapters/store/reads.go +++ b/internal/adapters/store/reads.go @@ -25,19 +25,20 @@ var ErrNotFound = errors.New("store: summary not found") // not part of the Stage-0 store slice yet. When that table is migrated, swap the // JOIN source — callers already fall back gracefully on an empty Channel. type SummaryRow struct { - VideoID string - Title string // videos.title; empty when no videos row - Channel string // videos.provider for now; empty when no videos row - URL string // videos.url; empty when no videos row - PublishedAt time.Time // videos.published_at; zero when absent - Summary string - Highlights []string - Takeaways []string - AIProvider string - AIModel string - FallbackUsed bool - CreatedAt time.Time - Actions []string // current active actions for this video; nil when none + VideoID string + ProviderVideoID string // videos.provider_video_id; empty when no videos row + Title string // videos.title; empty when no videos row + Channel string // videos.provider for now; empty when no videos row + URL string // videos.url; empty when no videos row + PublishedAt time.Time // videos.published_at; zero when absent + Summary string + Highlights []string + Takeaways []string + AIProvider string + AIModel string + FallbackUsed bool + CreatedAt time.Time + Actions []string // current active actions for this video; nil when none } // selectSummary is the shared projection for both reads. videos is LEFT JOINed @@ -45,6 +46,7 @@ type SummaryRow struct { // crosses users and a missing videos row yields nulls, not a dropped summary. const selectSummary = ` SELECT s.video_id, + COALESCE(v.provider_video_id, ''), COALESCE(v.title, ''), COALESCE(v.provider, ''), COALESCE(v.url, ''), @@ -153,6 +155,7 @@ func scanSummaryRow(rows pgx.Row) (SummaryRow, error) { ) if err := rows.Scan( &row.VideoID, + &row.ProviderVideoID, &row.Title, &row.Channel, &row.URL, diff --git a/internal/web/view.go b/internal/web/view.go index c2bfd51..b62bb82 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -1,6 +1,7 @@ package web import ( + "regexp" "strings" "time" @@ -9,6 +10,21 @@ import ( "gitea.d-ma.be/mathias/tapir/internal/adapters/store" ) +// youtubeIDRe matches a canonical 11-char YouTube video id (the provider's +// base64url alphabet). Anything else is rejected so we never emit a broken +// embed src. +var youtubeIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`) + +// embedURL builds a privacy-friendly nocookie embed URL for a YouTube video id. +// It returns ("", false) for any id that isn't a valid 11-char YouTube id, so +// the caller can omit the embed instead of rendering a broken iframe. +func embedURL(providerVideoID string) (string, bool) { + if !youtubeIDRe.MatchString(providerVideoID) { + return "", false + } + return "https://www.youtube-nocookie.com/embed/" + providerVideoID, true +} + // actionVerbs is the fixed, ordered set of action toggles rendered in the button // group. It mirrors the store's allowed actions (store/actions.go); order here is // the display order, not the store's. @@ -284,6 +300,8 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); } .detail h1 { font-size: 1.7rem; line-height: 1.25; margin: 0 0 var(--s2); } .detail .meta { color: var(--muted); font-size: .9rem; margin: 0 0 var(--s2); display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; } .detail .source { margin: 0 0 var(--s4); font-size: .9rem; } +.detail .embed { margin: 0 0 var(--s4); aspect-ratio: 16 / 9; border-radius: var(--radius); overflow: hidden; background: #000; border: 1px solid var(--line); } +.detail .embed iframe { display: block; width: 100%; height: 100%; border: 0; } .detail section { margin-top: var(--s4); } .detail section h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-top: 1px solid var(--line); padding-top: var(--s3); margin: 0 0 var(--s2); } .detail .body { white-space: pre-wrap; line-height: 1.7; margin: 0; } diff --git a/internal/web/view_internal_test.go b/internal/web/view_internal_test.go new file mode 100644 index 0000000..1e2d1b6 --- /dev/null +++ b/internal/web/view_internal_test.go @@ -0,0 +1,29 @@ +package web + +import "testing" + +func TestEmbedURL(t *testing.T) { + tests := []struct { + name string + id string + wantURL string + wantOK bool + }{ + {"valid 11-char id", "dQw4w9WgXcQ", "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ", true}, + {"valid with dash and underscore", "a_b-cD12345", "https://www.youtube-nocookie.com/embed/a_b-cD12345", true}, + {"empty", "", "", false}, + {"too short", "abc", "", false}, + {"too long", "dQw4w9WgXcQX", "", false}, + {"invalid char", "dQw4w9WgXc!", "", false}, + {"space", "dQw4w9WgX Q", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotURL, gotOK := embedURL(tt.id) + if gotURL != tt.wantURL || gotOK != tt.wantOK { + t.Errorf("embedURL(%q) = (%q, %v), want (%q, %v)", + tt.id, gotURL, gotOK, tt.wantURL, tt.wantOK) + } + }) + } +} diff --git a/internal/web/views.templ b/internal/web/views.templ index c0bb1a2..15e421a 100644 --- a/internal/web/views.templ +++ b/internal/web/views.templ @@ -109,6 +109,18 @@ templ DetailPage(r store.SummaryRow) { fallback }
+ if url, ok := embedURL(r.ProviderVideoID); ok { + + } if r.URL != "" { } diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go index 3cdba4a..cb5b4f1 100644 --- a/internal/web/views_templ.go +++ b/internal/web/views_templ.go @@ -451,21 +451,53 @@ func DetailPage(r store.SummaryRow) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if url, ok := embedURL(r.ProviderVideoID); ok { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } if r.URL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" rel=\"noopener noreferrer\">watch on source ↗") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -474,82 +506,82 @@ func DetailPage(r store.SummaryRow) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var21 string
- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(r.Summary)
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(r.Summary)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 118, Col: 31}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 130, Col: 31}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Summary