package web import ( "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 } // displayDate renders a published date, or an em dash when absent (zero time). func displayDate(t time.Time) string { if t.IsZero() { return "—" } return t.Format("2006-01-02") } // 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 " const stylesheet = ` :root { color-scheme: light dark; --fg:#1a1a1a; --muted:#777; --line:#ddd; --accent:#2b6cb0; } * { box-sizing: border-box; } body { font: 15px/1.5 system-ui, sans-serif; margin: 0; color: var(--fg); } header { padding: .75rem 1.25rem; border-bottom: 1px solid var(--line); } .brand { font-weight: 700; text-decoration: none; color: var(--accent); } main { max-width: 60rem; margin: 0 auto; padding: 1.25rem; } table.summaries { width: 100%; border-collapse: collapse; } table.summaries th, table.summaries td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } table.summaries th { font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); } .empty { color: var(--muted); font-style: italic; } .muted { color: var(--muted); } .badge { display: inline-block; padding: 0 .4rem; border-radius: .4rem; background: #f0ad4e; color: #000; font-size: .75rem; } .filters { display: flex; gap: 1rem; align-items: end; flex-wrap: wrap; margin-bottom: 1rem; } .filters label { display: flex; flex-direction: column; font-size: .8rem; color: var(--muted); gap: .2rem; } .filters input { font: inherit; padding: .25rem; } .detail .meta { color: var(--muted); } .actions { display: flex; gap: .5rem; margin: 1rem 0; } .actions .action { font: inherit; padding: .4rem .8rem; border: 1px solid var(--line); border-radius: .4rem; background: transparent; cursor: pointer; } .actions .action.active { background: var(--accent); color: #fff; border-color: var(--accent); } .body { white-space: pre-wrap; } `