From b2d1909b138d8479b52298436b44ca5956d4a104 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 15:10:06 +0200 Subject: [PATCH 1/3] feat(web): embedURL helper for privacy-friendly nocookie embeds Validates an 11-char YouTube id and returns the youtube-nocookie embed URL, or ("", false) so callers omit a broken iframe. Table-driven test. --- internal/web/view.go | 16 ++++++++++++++++ internal/web/view_internal_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 internal/web/view_internal_test.go diff --git a/internal/web/view.go b/internal/web/view.go index 5f3a450..648f55e 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. 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) + } + }) + } +} From 897a21a1d6e628d2860a796c4522059dc3ac9ccf Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 15:10:29 +0200 Subject: [PATCH 2/3] feat(store): expose provider_video_id on SummaryRow Read-only addition (column + field + scan) so the web layer can build a video embed URL. No write-path or restructuring. --- internal/adapters/store/reads.go | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/internal/adapters/store/reads.go b/internal/adapters/store/reads.go index 8233d1a..609bcd3 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, ''), @@ -135,6 +137,7 @@ func scanSummaryRow(rows pgx.Row) (SummaryRow, error) { ) if err := rows.Scan( &row.VideoID, + &row.ProviderVideoID, &row.Title, &row.Channel, &row.URL, From dc4b06baf475c44e2615e8f338d253b27ccb0048 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 15:11:13 +0200 Subject: [PATCH 3/3] feat(web): embed video on detail page via nocookie iframe Responsive 16:9 youtube-nocookie iframe rendered when the id is valid; omitted (graceful) otherwise so summary/highlights/takeaways still show. Regenerated views_templ.go. --- internal/web/view.go | 2 + internal/web/views.templ | 12 +++ internal/web/views_templ.go | 202 +++++++++++++++++++++--------------- 3 files changed, 131 insertions(+), 85 deletions(-) diff --git a/internal/web/view.go b/internal/web/view.go index 648f55e..f63b19c 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -254,6 +254,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/views.templ b/internal/web/views.templ index a7134cf..a4764fe 100644 --- a/internal/web/views.templ +++ b/internal/web/views.templ @@ -106,6 +106,18 @@ templ DetailPage(r store.SummaryRow) { fallback }

+ if url, ok := embedURL(r.ProviderVideoID); ok { +
+ +
+ } if r.URL != "" {

watch on source ↗

} diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go index 4bac615..aa40ecc 100644 --- a/internal/web/views_templ.go +++ b/internal/web/views_templ.go @@ -432,21 +432,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, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } if r.URL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "

watch on source ↗

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" rel=\"noopener noreferrer\">watch on source ↗

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -455,82 +487,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, 34, "

Summary

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "

Summary

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.Summary) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, 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: 115, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 127, Col: 31} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(r.Highlights) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

Highlights

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "

    Highlights

      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, h := range r.Highlights { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(h) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(h) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 122, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 134, Col: 14} } - _, 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, 38, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(r.Takeaways) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "

Takeaways

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "

    Takeaways

      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, t := range r.Takeaways { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(t) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(t) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 132, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 144, Col: 14} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -564,117 +596,117 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var23 := templ.GetChildren(ctx) - if templ_7745c5c3_Var23 == nil { - templ_7745c5c3_Var23 = templ.NopComponent + templ_7745c5c3_Var25 := templ.GetChildren(ctx) + if templ_7745c5c3_Var25 == nil { + templ_7745c5c3_Var25 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, v := range actionVerbs { - var templ_7745c5c3_Var26 = []any{"action", templ.KV("active", active[v])} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...) + var templ_7745c5c3_Var28 = []any{"action", templ.KV("active", active[v])} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }