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, 56, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}