package web import ( "strings" "time" "github.com/a-h/templ" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" ) // 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. var actionVerbs = []string{"watched", "skipped", "saved"} // actionLabels maps each verb to its button caption. var actionLabels = map[string]string{ "watched": "Watched", "skipped": "Skipped", "saved": "Saved", } func actionLabel(v string) string { if l, ok := actionLabels[v]; ok { return l } return v } // isActionVerb reports whether v is a known action verb. The handler rejects // anything else before touching the store (defence in depth — the store also // validates). func isActionVerb(v string) bool { for _, a := range actionVerbs { if a == v { return true } } return false } // actionSet turns the store's active-action slice into a set for the template's // O(1) "is this verb active?" lookups. func actionSet(xs []string) map[string]bool { m := make(map[string]bool, len(xs)) for _, x := range xs { m[x] = true } return m } func ariaPressed(b bool) string { if b { return "true" } return "false" } // displayTitle falls back to the video id when no videos row supplied a title // (the LEFT JOIN can yield an empty Title — see store.SummaryRow). func displayTitle(r store.SummaryRow) string { if r.Title != "" { return r.Title } return r.VideoID } // cardMeta is the muted "channel · date" line on a list card. Empty parts are // omitted so a row with no joined videos row renders no stray separators. func cardMeta(r store.SummaryRow) string { var parts []string if r.Channel != "" { parts = append(parts, r.Channel) } if !r.PublishedAt.IsZero() { parts = append(parts, r.PublishedAt.Format("2006-01-02")) } return strings.Join(parts, " · ") } // detailMeta is the single-line meta join under the detail title. It builds a // slice of the present parts (channel, date, provider[+model]) and joins with // " · ", so an absent videos row never produces a dangling "· — ·" (review #5). // The fallback badge is rendered separately, not part of this string. func detailMeta(r store.SummaryRow) string { var parts []string if r.Channel != "" { parts = append(parts, r.Channel) } if !r.PublishedAt.IsZero() { parts = append(parts, r.PublishedAt.Format("2006-01-02")) } if prov := r.AIProvider; prov != "" { if r.AIModel != "" { prov += " (" + r.AIModel + ")" } parts = append(parts, prov) } return strings.Join(parts, " · ") } // videoURL builds the internal detail-page path for a video id. func videoURL(videoID string) templ.SafeURL { return templ.SafeURL("/v/" + videoID) } // actionURL builds the action POST path for a video id. func actionURL(videoID string) templ.SafeURL { return templ.SafeURL("/v/" + videoID + "/action") } // externalURL passes a stored source URL through templ's URL sanitiser. func externalURL(u string) templ.SafeURL { return templ.URL(u) } // Filter holds the list-view query parameters. Empty fields mean "no constraint". // Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's // input verbatim; parsing happens in matchFilter. type Filter struct { Channel string From string To string } // matches reports whether a row satisfies the filter. Channel is an exact match; // From/To bound PublishedAt inclusively. Unparseable or empty bounds are ignored // (no constraint) — Stage-0 filtering is in-memory over the listed rows, not a // store query. func (f Filter) matches(r store.SummaryRow) bool { if f.Channel != "" && r.Channel != f.Channel { return false } if from, ok := parseDate(f.From); ok { if r.PublishedAt.IsZero() || r.PublishedAt.Before(from) { return false } } if to, ok := parseDate(f.To); ok { // inclusive of the whole "to" day if r.PublishedAt.IsZero() || r.PublishedAt.After(to.Add(24*time.Hour-time.Nanosecond)) { return false } } return true } func parseDate(s string) (time.Time, bool) { if s == "" { return time.Time{}, false } t, err := time.Parse("2006-01-02", s) if err != nil { return time.Time{}, false } return t, true } // apply returns the subset of rows matching the filter, preserving order. func (f Filter) apply(rows []store.SummaryRow) []store.SummaryRow { if f == (Filter{}) { return rows } out := rows[:0:0] for _, r := range rows { if f.matches(r) { out = append(out, r) } } return out } // styleTag wraps the stylesheet in a " // The design system is a small set of CSS custom properties: one accent, a // 4/8px-derived spacing scale, a single radius, and full light+dark palettes so // color-scheme: light dark is actually honoured (review #1). Muted is #595959 // (~7:1 on white) / #9aa0a8 on dark to clear WCAG AA (review #4). const stylesheet = ` :root { color-scheme: light dark; --bg:#fbfbfa; --card:#ffffff; --fg:#1a1a1a; --muted:#595959; --line:#e4e4e1; --accent:#2b6cb0; --accent-fg:#ffffff; --accent-weak:#eaf1f8; --badge-bg:#e7a13a; --badge-fg:#2a1d00; --s1:.25rem; --s2:.5rem; --s3:1rem; --s4:1.5rem; --s5:2.5rem; --radius:.5rem; } @media (prefers-color-scheme: dark) { :root { --bg:#15171b; --card:#1e2128; --fg:#e7e7e4; --muted:#9aa0a8; --line:#2d313a; --accent:#76aae6; --accent-fg:#0c0f13; --accent-weak:#222b38; --badge-bg:#caa24a; --badge-fg:#1a1300; } } * { box-sizing: border-box; } body { font: 15px/1.6 system-ui, -apple-system, sans-serif; margin: 0; color: var(--fg); background: var(--bg); } a { color: var(--accent); text-decoration: none; } a:hover, a:focus-visible { text-decoration: underline; } a:visited { color: var(--accent); } header { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); background: var(--card); } .brand { font-weight: 700; font-size: 1.05rem; color: var(--accent); } main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); } .muted { color: var(--muted); } /* filter bar */ .filters { display: flex; gap: var(--s3); align-items: end; flex-wrap: wrap; margin-bottom: var(--s4); } .filters label { display: flex; flex-direction: column; font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); gap: var(--s1); } .filters input { font: inherit; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); min-width: 9rem; } .filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); } .btn { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent); color: var(--accent-fg); cursor: pointer; } .btn:hover { filter: brightness(1.05); } .btn:active { transform: translateY(1px); } /* card list */ .cards { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s3); } .card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3) var(--s4); display: flex; flex-direction: column; gap: var(--s2); } .card-title { font-size: 1.1rem; font-weight: 600; line-height: 1.3; } .card-meta { color: var(--muted); font-size: .85rem; } .card-foot { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-top: var(--s1); } .chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; } .card-state { color: var(--muted); font-size: .8rem; } .badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; } /* empty state */ .empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); } .empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); } .empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; } /* htmx loading feedback */ .htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; } .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; } /* detail reader */ .detail { max-width: 38rem; } .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 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; } .detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; } .detail li { margin-bottom: var(--s1); } /* action toggles */ .actions { display: flex; gap: var(--s2); margin: var(--s4) 0; flex-wrap: wrap; } .actions .action { font: inherit; padding: .4rem .9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); cursor: pointer; transition: border-color .15s, background .15s; } .actions .action:hover { border-color: var(--accent); } .actions .action:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } .actions .action:active { transform: translateY(1px); } .actions .action.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); } @media (max-width: 640px) { main { padding: var(--s3) var(--s2); } .filters { gap: var(--s2); } .filters input { min-width: 0; width: 100%; } .filters label { flex: 1 1 8rem; } .card { padding: var(--s3); } } `