From ddf2157e631b0a98c42d9a6a5e6108a52581c11c Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 25 Sep 2026 11:18:44 -0700 Subject: [PATCH] Add list styles: Podium, Spotlight, Grid, List - Each list picks a style in List settings (new lists default to Podium); existing lists keep Grid or List - Podium: top 3 on medal-colored steps (#1 center and raised), the rest behind a native
"Show N more"; stacks #1 over #2/#3 on phones - Spotlight: #1 as a feature card (art, why-note or description, play button), the rest as a numbered list - Visitors can switch styles on a list page (?style=), preserved across pages; later pages fall back to a plain list since they don't start at #1 - Entries' detail data now lives on a wrapper per list, so featured and collapsed entries all open the detail view Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- internal/server/admin.go | 2 + internal/server/public.go | 7 ++ internal/server/render.go | 11 +++ internal/store/lists.go | 32 +++++++- web/static/css/app.css | 120 +++++++++++++++++++++++++++-- web/static/img/icons.svg | 2 + web/static/js/admin-list.js | 2 +- web/static/js/admin-lists.js | 4 +- web/static/js/list-form.js | 16 ++-- web/static/js/public.js | 31 ++------ web/templates/pages/home.html | 2 +- web/templates/pages/list.html | 17 ++-- web/templates/partials/items.html | 30 +++++--- web/templates/partials/ranked.html | 72 +++++++++++++++++ 15 files changed, 286 insertions(+), 65 deletions(-) create mode 100644 web/templates/partials/ranked.html diff --git a/README.md b/README.md index 591c794..41032cb 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ Your page is titled **"*Your name*'s Canon"** by default. Set your name under Se Visitors just see your lists. There's no sign-up, no feed and no login button. You manage everything from `/login`. -- **Ranked lists per category**: Movies, TV Shows, Albums, Songs, Video Games and Theater. Each list sets its own size, title, description and layout (artwork grid or ranked list). +- **Ranked lists per category**: Movies, TV Shows, Albums, Songs, Video Games and Theater. Each list sets its own size, title, description and style. - **Search-to-add** from TMDB, TheTVDB, IGDB, Deezer/MusicBrainz and Wikipedia, with add-manually as a fallback for anything they miss. - **Custom lists** for anything else, like sports teams, drinks or restaurants. You name the list and add entries by hand (with your own photo or logo), or pull a name and picture from Wikipedia. - **Theater by production**: record *which* staging you mean (Broadway, West End, North American Tour, a revival…) plus venue and cast, and use that production's own poster. +- **Four styles per list**: **Podium** (top 3 on a medal podium, the rest behind "Show more"), **Spotlight** (#1 as a big feature), **Grid** and **List**. You pick each list's style in List settings, and visitors can switch styles on a list's own page. - **Drag to rank**, or use the arrow buttons or type a new number. Paginated editing keeps huge lists usable. - **Art is cached on your server**, so pages never hotlink and keep working if a source changes. You can also paste an image URL or upload your own. - **Preview clips** for songs and albums: a play button plays 30 seconds from Deezer (an album plays its most popular track). Clips stream through your server, so visitors never connect to Deezer. You can turn this off in Settings → Profile. diff --git a/internal/server/admin.go b/internal/server/admin.go index 588550b..9f65834 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -44,6 +44,7 @@ func (s *Server) handleAdminLists(w http.ResponseWriter, r *http.Request) { s.render(w, r, http.StatusOK, "admin_lists", "Your lists", adminPage{Boot: bootJSON(map[string]any{ "lists": lists, "covers": covers, + "styles": store.ListStyles, "categories": store.Categories, "limits": limits, })}) @@ -72,6 +73,7 @@ func (s *Server) handleAdminList(w http.ResponseWriter, r *http.Request) { "limits": limits, "provider": provider, "productions": store.TheaterProductions, + "styles": store.ListStyles, "pageSize": listPageSize, })}) } diff --git a/internal/server/public.go b/internal/server/public.go index a16c73f..b6eeee4 100644 --- a/internal/server/public.go +++ b/internal/server/public.go @@ -143,6 +143,9 @@ const listPageSize = 100 type listPage struct { List store.List Category store.Category + Style string // style being shown: the list's, or the visitor's pick + Styles []store.ListStyle // for the style switcher + PageQS string // extra query for pager links, e.g. "&style=grid" Items []ItemView Hero *ItemView Page int @@ -180,6 +183,10 @@ func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { site := s.site(ctx) p := listPage{List: l, Page: 1} p.Category = listCategory(l) + p.Style, p.Styles = l.Layout, store.ListStyles + if st, ok := store.StyleByKey(r.URL.Query().Get("style")); ok && st.Key != l.Layout { + p.Style, p.PageQS = st.Key, "&style="+st.Key + } p.Pages = max(1, (l.ItemCount+listPageSize-1)/listPageSize) if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n > 1 { p.Page = min(n, p.Pages) diff --git a/internal/server/render.go b/internal/server/render.go index 41b158f..09dcbfd 100644 --- a/internal/server/render.go +++ b/internal/server/render.go @@ -103,6 +103,17 @@ var funcs = template.FuncMap{ }, "add": func(a, b int) int { return a + b }, "hasPrefix": strings.HasPrefix, + // first and after split a ranked list: the podium/spotlight entries and + // everything below them. + "first": func(n int, items []ItemView) []ItemView { return items[:min(n, len(items))] }, + "after": func(n int, items []ItemView) []ItemView { return items[min(n, len(items)):] }, + // flatLayout is how a style shows entries that aren't featured. + "flatLayout": func(style string) string { + if style == "grid" { + return "grid" + } + return "rows" + }, // dict builds a map for passing several values to a partial. "dict": func(kv ...any) map[string]any { m := map[string]any{} diff --git a/internal/store/lists.go b/internal/store/lists.go index 1e1699a..84cfeb9 100644 --- a/internal/store/lists.go +++ b/internal/store/lists.go @@ -24,7 +24,7 @@ type List struct { MaxItems int `json:"maxItems"` Position int `json:"position"` Visible bool `json:"visible"` - Layout string `json:"layout"` // "grid" or "rows" + Layout string `json:"layout"` // a ListStyles key, e.g. "podium" Art string `json:"art"` // "", "poster" or "square"; "" = category default ItemCount int `json:"itemCount"` UpdatedAt int64 `json:"updatedAt"` @@ -97,8 +97,8 @@ func (l *List) normalize() error { if l.MaxItems < 1 || l.MaxItems > MaxListSize { return invalid("List size must be between 1 and %d", MaxListSize) } - if l.Layout != "rows" { - l.Layout = "grid" + if _, ok := StyleByKey(l.Layout); !ok { + l.Layout = DefaultStyle } if l.Art != "poster" && l.Art != "square" { l.Art = "" @@ -245,3 +245,29 @@ func (l List) Aspect() string { c, _ := CategoryByKey(l.Category) return c.Aspect } + +// ListStyle is a way of presenting a ranked list. +type ListStyle struct { + Key string `json:"key"` + Label string `json:"label"` + Icon string `json:"icon"` + Description string `json:"description"` +} + +var ListStyles = []ListStyle{ + {"podium", "Podium", "podium", "Top 3 on a medal podium, the rest behind “Show more”."}, + {"spotlight", "Spotlight", "spotlight", "#1 as a big feature, the rest as a ranked list."}, + {"grid", "Grid", "grid", "Every entry as artwork in a grid."}, + {"rows", "List", "rows", "A compact numbered list with notes."}, +} + +const DefaultStyle = "podium" + +func StyleByKey(key string) (ListStyle, bool) { + for _, s := range ListStyles { + if s.Key == key { + return s, true + } + } + return ListStyle{}, false +} diff --git a/web/static/css/app.css b/web/static/css/app.css index dfc2e22..b42fa48 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -165,10 +165,10 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac /* ---------- ranked items: grid ---------- */ .items { list-style: none; margin: 0; padding: 0; } .items--grid { display: grid; gap: 22px 18px; grid-template-columns: repeat(auto-fill, minmax(156px, 1fr)); } -.items--grid.aspect-square { grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); } +.aspect-square .items--grid { grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); } @media (max-width: 640px) { .items--grid { grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 16px 12px; } - .items--grid.aspect-square { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); } + .aspect-square .items--grid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); } } .item-btn { all: unset; box-sizing: border-box; display: flex; flex-direction: column; gap: 10px; @@ -281,6 +281,101 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac .items--rows .item-rank { font-size: 18px; } } +/* ---------- style: podium ---------- */ +/* #2, #1, #3 left to right, #1 raised and a little larger, each standing on + a medal-colored step. */ +.podium { + list-style: none; margin: 8px auto 0; padding: 0; max-width: 860px; + display: grid; grid-template-columns: 1fr 1.22fr 1fr; gap: 0 22px; align-items: end; +} +.podium--2 { grid-template-columns: 1fr 1.22fr; max-width: 580px; } +.podium--1 { grid-template-columns: minmax(0, 300px); justify-content: center; } +.podium-spot { position: relative; display: flex; flex-direction: column; min-width: 0; --medal: var(--gold); } +.podium-spot--1 { order: 2; } +.podium-spot--2 { order: 1; --medal: var(--silver); } +.podium-spot--3 { order: 3; --medal: var(--bronze); } +.podium--1 .podium-spot--1 { order: 0; } +.podium-card { align-items: center; text-align: center; gap: 12px; padding: 0 4px 14px; } +.podium-card .item-art { width: 100%; box-shadow: var(--shadow); } +.podium-spot--1 .item-art { box-shadow: 0 0 0 3px var(--medal), var(--shadow); } +.podium-card .item-body { align-items: center; width: 100%; } +.podium-card .item-title { font-size: 16px; } +.podium-spot--1 .item-title { font-size: 18px; font-weight: 700; } +.podium-card .item-tags { justify-content: center; } +.podium-step { + display: grid; place-items: center; border-radius: 10px 10px 0 0; + border-top: 3px solid var(--medal); + background: linear-gradient(to bottom, color-mix(in srgb, var(--medal) 26%, var(--surface)), color-mix(in srgb, var(--medal) 4%, var(--bg))); + font: 800 30px/1 var(--font-display); color: var(--medal); font-variant-numeric: tabular-nums; +} +.podium-spot--1 .podium-step { height: 86px; font-size: 40px; } +.podium-spot--2 .podium-step { height: 60px; } +.podium-spot--3 .podium-step { height: 42px; font-size: 26px; } +.podium + .more, .podium + .more-all { margin-top: 0; border-top: 1px solid var(--border); } +@media (max-width: 640px) { + /* #1 on its own row, #2 and #3 side by side beneath it. */ + .podium, .podium--2 { grid-template-columns: 1fr 1fr; gap: 18px 14px; } + .podium-spot--1 { order: 0; grid-column: 1 / -1; justify-self: center; width: min(100%, 230px); } + .podium-spot--2 { order: 1; } + .podium-spot--3 { order: 2; } + /* Short, equal steps so every spot still shows its rank. */ + .podium-spot .podium-step { height: 34px; font-size: 20px; border-radius: 8px; } + .podium-card { padding-bottom: 10px; } +} + +/* "Show N more" under a podium: the browser's own
, so it works + without JavaScript. */ +.more { padding-top: 18px; } +.more > summary { + list-style: none; display: flex; align-items: center; gap: 8px; width: fit-content; margin: 0 auto; + padding: 8px 18px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface); + color: var(--muted); font-size: 14px; font-weight: 600; cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.more > summary::-webkit-details-marker { display: none; } +.more > summary:hover { color: var(--text); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } +.more > summary .icon { transition: transform 0.2s; } +.more-open, .more[open] .more-closed { display: none; } +.more[open] .more-open { display: inline; } +.more[open] > summary { margin-bottom: 16px; } +.more[open] > summary .icon { transform: rotate(180deg); } +.more-all { + display: flex; align-items: center; justify-content: center; gap: 6px; width: fit-content; margin: 16px auto 0; + color: var(--muted); font-size: 14px; font-weight: 600; text-decoration: none; +} +.more-all:hover { color: var(--accent); } +.podium + .more-all { padding-top: 16px; width: 100%; } +/* On a list page, pages past the first only make sense once the rest is open. */ +.list-body:has(.ranked--podium details:not([open])) .pager { display: none; } + +/* ---------- style: spotlight ---------- */ +.spotlight { + position: relative; overflow: hidden; isolation: isolate; margin-bottom: 14px; + display: grid; grid-template-columns: minmax(150px, 250px) 1fr; gap: 30px; align-items: center; + padding: 26px; border-radius: calc(var(--radius) + 8px); border: 1px solid var(--border); background: var(--surface); +} +.aspect-square .spotlight { grid-template-columns: minmax(150px, 270px) 1fr; } +.spotlight-bg { position: absolute; inset: 0; z-index: -1; } +.spotlight-bg img { width: 100%; height: 100%; object-fit: cover; transform: scale(1.4); filter: blur(50px) saturate(1.4); opacity: 0.32; } +.spotlight-art { position: relative; } +.spotlight-art .item-art { box-shadow: 0 0 0 3px var(--gold), var(--shadow); } +.spotlight-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; align-items: flex-start; } +.spotlight-rank { font: 800 46px/1 var(--font-display); color: var(--gold); } +.spotlight-title { font-size: clamp(24px, 3.4vw, 36px); font-weight: 800; } +.spotlight-body .item-sub { font-size: 15px; white-space: normal; } +.spotlight-desc { color: var(--muted); font-size: 15px; line-height: 1.6; margin-top: 6px; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } +.spotlight-note { + margin: 8px 0 0; padding: 10px 14px; border-left: 3px solid var(--accent); border-radius: 0 var(--radius) var(--radius) 0; + background: var(--accent-soft); font-size: 15px; white-space: pre-line; + display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; +} +.spotlight-more { margin-top: 10px; } +@media (max-width: 640px) { + .spotlight, .aspect-square .spotlight { grid-template-columns: 1fr; gap: 18px; padding: 18px; } + .spotlight-art { width: min(100%, 220px); justify-self: center; } + .spotlight-rank { font-size: 36px; } +} + /* ---------- list page ---------- */ .list-hero { position: relative; overflow: hidden; padding: 28px 0 34px; border-bottom: 1px solid var(--border); } .list-hero-backdrop { position: absolute; inset: 0; z-index: 0; } @@ -304,9 +399,9 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac .list-body { padding-top: 30px; } .segmented { display: inline-flex; padding: 3px; gap: 2px; border-radius: calc(var(--radius) + 2px); background: var(--surface); border: 1px solid var(--border); } -.segmented button { display: grid; place-items: center; width: 34px; height: 30px; border: 0; border-radius: calc(var(--radius) - 2px); background: none; color: var(--muted); cursor: pointer; } -.segmented button:hover { color: var(--text); } -.segmented button[aria-pressed="true"] { background: var(--surface-2); color: var(--text); } +.segmented a, .segmented button { display: grid; place-items: center; width: 34px; height: 30px; border: 0; border-radius: calc(var(--radius) - 2px); background: none; color: var(--muted); cursor: pointer; } +.segmented a:hover, .segmented button:hover { color: var(--text); } +.segmented [aria-current="true"], .segmented [aria-pressed="true"] { background: var(--surface-2); color: var(--text); } .pager { display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 40px; flex-wrap: wrap; } .pager:empty { display: none; } @@ -506,6 +601,21 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px; .cat-option input:checked + span { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent) inset; } .cat-option input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } .cat-option input:disabled + span { opacity: 0.45; cursor: not-allowed; } +.style-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.style-option { position: relative; } +.style-option input { position: absolute; opacity: 0; pointer-events: none; } +.style-option span { + display: grid; grid-template-columns: auto 1fr; column-gap: 10px; row-gap: 2px; align-items: center; height: 100%; + padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.style-option .icon { grid-row: span 2; width: 22px; height: 22px; color: var(--accent); } +.style-option b { font-size: 14px; } +.style-option small { color: var(--muted); font-size: 12px; line-height: 1.35; } +.style-option span:hover { border-color: var(--accent); } +.style-option input:checked + span { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent) inset; } +.style-option input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } +@media (max-width: 480px) { .style-picker { grid-template-columns: 1fr; } } .size-chips { display: flex; flex-wrap: wrap; gap: 6px; } .size-chips button { height: 30px; padding: 0 12px; border-radius: 99px; border: 1px solid var(--border); background: var(--bg); cursor: pointer; font-size: 13px; font-weight: 600; } .size-chips button:hover { border-color: var(--accent); } diff --git a/web/static/img/icons.svg b/web/static/img/icons.svg index c09d9c8..009eaec 100644 --- a/web/static/img/icons.svg +++ b/web/static/img/icons.svg @@ -22,6 +22,8 @@ + + diff --git a/web/static/js/admin-list.js b/web/static/js/admin-list.js index 113c2ae..265c21a 100644 --- a/web/static/js/admin-list.js +++ b/web/static/js/admin-list.js @@ -54,7 +54,7 @@ const isFull = () => list.itemCount >= list.maxItems; document.querySelector("[data-action=edit-list]").addEventListener("click", () => { openListForm({ - categories, limits, list, + categories, limits, styles: boot.styles, list, onSaved: (saved) => { // The artwork shape affects the whole editor, so redraw from scratch. if (saved.art !== list.art) { location.reload(); return; } diff --git a/web/static/js/admin-lists.js b/web/static/js/admin-lists.js index b42f19a..2b17fe4 100644 --- a/web/static/js/admin-lists.js +++ b/web/static/js/admin-lists.js @@ -13,14 +13,14 @@ const root = document.getElementById("lists"); function newList(category) { openListForm({ - categories, limits, category, + categories, limits, styles: boot.styles, category, onSaved: (l) => { location.href = `/admin/lists/${l.id}`; }, }); } function editList(list) { openListForm({ - categories, limits, list, + categories, limits, styles: boot.styles, list, onSaved: (saved) => { lists = lists.map((l) => (l.id === saved.id ? saved : l)); render(); diff --git a/web/static/js/list-form.js b/web/static/js/list-form.js index cdf974f..f8af0ea 100644 --- a/web/static/js/list-form.js +++ b/web/static/js/list-form.js @@ -22,7 +22,7 @@ const autoTitle = (n, cat) => (cat.key === "custom" ? "" : `Top ${n} ${cat.label * @param {string} [o.category] preselected category for a new list * @param {(list: object) => void} o.onSaved */ -export function openListForm({ categories, limits, list, category, onSaved }) { +export function openListForm({ categories, limits, styles, list, category, onSaved }) { const editing = !!list; const catByKey = Object.fromEntries(categories.map((c) => [c.key, c])); const state = { @@ -77,11 +77,12 @@ export function openListForm({ categories, limits, list, category, onSaved }) { const desc = h("textarea", { name: "description", rows: 2, maxlength: 1000, placeholder: "Optional: a line about what makes this list", value: list?.description || "" }); const slug = editing && h("input", { name: "slug", value: list.slug, maxlength: 60, pattern: "[a-z0-9-]+", spellcheck: false }); - const layout = list?.layout || "grid"; - const layoutGroup = h("div", { class: "radio-cards" }, - h("label", {}, h("input", { type: "radio", name: "layout", value: "grid", checked: layout === "grid" }), icon("grid"), "Artwork grid"), - h("label", {}, h("input", { type: "radio", name: "layout", value: "rows", checked: layout === "rows" }), icon("rows"), "Ranked list"), - ); + const layout = list?.layout || styles[0].key; + const layoutGroup = h("div", { class: "style-picker", role: "radiogroup", "aria-label": "Style" }, + styles.map((st) => h("label", { class: "style-option" }, + h("input", { type: "radio", name: "layout", value: st.key, checked: layout === st.key }), + h("span", {}, icon(st.icon), h("b", {}, st.label), h("small", {}, st.description)), + ))); // Custom lists choose their artwork shape; built-in categories have one. const art = list?.art || "square"; const artField = h("div", { class: "field", hidden: !isCustom() }, @@ -106,7 +107,8 @@ export function openListForm({ categories, limits, list, category, onSaved }) { h("label", { class: "field" }, h("span", { class: "field-label" }, "Description"), desc), editing && h("label", { class: "field" }, h("span", { class: "field-label" }, "Address"), h("div", { class: "input-group", style: { alignItems: "center" } }, h("span", { class: "muted" }, "/l/"), slug)), - h("div", { class: "field" }, h("span", { class: "field-label" }, "Layout"), layoutGroup), + h("div", { class: "field" }, h("span", { class: "field-label" }, "Style"), layoutGroup, + h("span", { class: "field-hint" }, "How the list looks on your page. Visitors can switch styles on the list's own page.")), artField, h("label", { class: "check" }, visible, "Show this list on my public page"), error, diff --git a/web/static/js/public.js b/web/static/js/public.js index 774304b..6860796 100644 --- a/web/static/js/public.js +++ b/web/static/js/public.js @@ -1,12 +1,13 @@ // Public pages: item detail dialog and grid/list toggle. -import { h, icon, openDialog, storageGet, storageSet } from "./common.js"; +import { h, icon, openDialog } from "./common.js"; import { previewButton } from "./player.js"; // Each ranked list on the page ships its items as JSON next to the markup. -const lists = [...document.querySelectorAll("[data-items]")].map((ol) => { - const data = JSON.parse(ol.nextElementSibling?.textContent || "[]"); - return { ol, items: data, title: ol.dataset.listTitle, square: ol.classList.contains("aspect-square") }; -}); +const lists = [...document.querySelectorAll("[data-items]")].map((el) => ({ + items: JSON.parse(el.querySelector(":scope > .items-data")?.textContent || "[]"), + title: el.dataset.listTitle, + square: el.classList.contains("aspect-square"), +})); function findItem(id) { for (const list of lists) { @@ -78,23 +79,3 @@ document.addEventListener("click", (e) => { const found = findItem(Number(btn.dataset.item)); if (found) show(found.list, found.idx); }); - -// Grid / list toggle on list pages, remembered per list in this browser. -const toggle = document.querySelector("[data-layout-toggle]"); -if (toggle) { - const key = "canon:layout:" + toggle.dataset.key; - const apply = (layout) => { - for (const { ol } of lists) { - ol.classList.toggle("items--grid", layout === "grid"); - ol.classList.toggle("items--rows", layout === "rows"); - } - for (const b of toggle.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.layout === layout)); - }; - apply(storageGet(key) || toggle.dataset.default); - toggle.addEventListener("click", (e) => { - const b = e.target.closest("button[data-layout]"); - if (!b) return; - apply(b.dataset.layout); - storageSet(key, b.dataset.layout); - }); -} diff --git a/web/templates/pages/home.html b/web/templates/pages/home.html index a9ff6f3..ac86359 100644 --- a/web/templates/pages/home.html +++ b/web/templates/pages/home.html @@ -30,7 +30,7 @@ {{if .More}}All {{num .List.ItemCount}}{{else}}Open{{end}}{{icon "arrow-right"}} - {{if .Items}}{{template "items" (dict "Items" .Items "Category" .Category "Layout" .List.Layout "Title" .List.Title)}}{{end}} + {{if .Items}}{{template "ranked" (dict "Items" .Items "Category" .Category "Style" .List.Layout "Title" .List.Title "AllLink" (and (gt .More 0) (print "/l/" .List.Slug)) "Total" .List.ItemCount)}}{{end}} {{end}} {{else}} diff --git a/web/templates/pages/list.html b/web/templates/pages/list.html index 9e29e4b..06eb5f6 100644 --- a/web/templates/pages/list.html +++ b/web/templates/pages/list.html @@ -18,10 +18,11 @@
{{if $.LoggedIn}}{{icon "pencil"}}Edit{{if .Items}}{{template "export" .List.Slug}}{{end}}{{end}} {{if .Items}} -
- - -
+ {{end}}
@@ -30,16 +31,16 @@
{{if .Items}} - {{template "items" (dict "Items" .Items "Category" .Category "Layout" .List.Layout "Title" .List.Title)}} + {{template "ranked" (dict "Items" .Items "Category" .Category "Style" .Style "Title" .List.Title)}} {{if .PageNums}} {{end}} {{else}} diff --git a/web/templates/partials/items.html b/web/templates/partials/items.html index fc22adb..4bf0b3b 100644 --- a/web/templates/partials/items.html +++ b/web/templates/partials/items.html @@ -1,29 +1,35 @@ {{define "items"}} -
    +
      {{- range .Items}}
    1. - {{- if .Preview}} - - {{- end}} + {{- template "playbtn" .}}
    2. {{- end}}
    - {{end}} + +{{/* Shared bits of an entry. */}} +{{define "art"}}{{if .Item.Image}}{{else}}{{icon .Icon}}{{end}}{{end}} + +{{define "itemsub"}} +{{- if .Badge}} +{{- with .Subtitle}}{{.}}{{end}} +{{.Badge}}{{with .Year}}{{.}}{{end}} +{{- else if or .Subtitle .Year}} +{{.Subtitle}}{{if and .Subtitle .Year}}{{end}}{{.Year}} +{{- end}} +{{- end}} + +{{define "playbtn"}}{{if .Preview}}{{end}}{{end}} diff --git a/web/templates/partials/ranked.html b/web/templates/partials/ranked.html new file mode 100644 index 0000000..ab2b602 --- /dev/null +++ b/web/templates/partials/ranked.html @@ -0,0 +1,72 @@ +{{/* + ranked draws a list in its style. Takes: Items, Category, Style, Title, + and optionally AllLink/Total (home page: link to the full list). + Podium and Spotlight feature #1, so a page that doesn't start at #1 falls + back to a plain list. +*/}} +{{define "ranked"}} +{{- $featured := and (or (eq .Style "podium") (eq .Style "spotlight")) (eq (index .Items 0).Rank 1)}} +
    + {{- if and $featured (eq .Style "podium")}}{{template "podium" .}} + {{- else if $featured}}{{template "spotlight" .}} + {{- else}}{{template "items" (dict "Items" .Items "Category" .Category "Layout" (flatLayout .Style))}} + {{- end}} + +
    +{{end}} + +{{define "podium"}} +{{- $c := .Category}} +{{- $top := first 3 .Items}} +
      + {{- range $top}} +
    1. + + {{- template "playbtn" .}} + +
    2. + {{- end}} +
    +{{- $rest := after 3 .Items}} +{{- if $rest}} +
    + Show {{len $rest}} moreShow less{{icon "chevron-down"}} + {{template "items" (dict "Items" $rest "Category" $c "Layout" "rows")}} + {{- if .AllLink}}See all {{num .Total}}{{icon "arrow-right"}}{{end}} +
    +{{- else if .AllLink}} +See all {{num .Total}}{{icon "arrow-right"}} +{{- end}} +{{end}} + +{{define "spotlight"}} +{{- $c := .Category}} +{{- with index .Items 0}} +
    + {{- if .Image}}{{end}} +
    + + {{- template "playbtn" .}} +
    +
    +

    #1

    +

    {{.Title}}

    + {{- template "itemsub" .}} + {{- with .Note}}
    {{.}}
    + {{- else}}{{with .Description}}

    {{.}}

    {{end}}{{end}} + +
    +
    +{{- end}} +{{- $rest := after 1 .Items}} +{{- if $rest}}{{template "items" (dict "Items" $rest "Category" $c "Layout" "rows")}}{{end}} +{{- if .AllLink}}See all {{num .Total}}{{icon "arrow-right"}}{{end}} +{{end}}