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 <details> "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 <noreply@anthropic.com>
This commit is contained in:
1 parent
b09533cc00
commit
ddf2157e63
15 files changed
+286
-65
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
+29
-3
@@ -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
|
||||
}
|
||||
+115
-5
@@ -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 <details>, 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); }
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
<symbol id="settings" viewBox="0 0 24 24"><path d="M20 7h-9M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></symbol>
|
||||
<symbol id="list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></symbol>
|
||||
<symbol id="rows" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/></symbol>
|
||||
<symbol id="podium" viewBox="0 0 24 24"><path d="M2 21h20"/><rect x="9" y="5" width="6" height="16" rx="1"/><rect x="3" y="10" width="6" height="11" rx="1"/><rect x="15" y="13" width="6" height="8" rx="1"/></symbol>
|
||||
<symbol id="spotlight" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="11" rx="2"/><path d="M3 18h18M3 21h12"/></symbol>
|
||||
<symbol id="grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1.5"/><rect width="7" height="7" x="14" y="3" rx="1.5"/><rect width="7" height="7" x="14" y="14" rx="1.5"/><rect width="7" height="7" x="3" y="14" rx="1.5"/></symbol>
|
||||
<symbol id="logout" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
|
||||
<symbol id="download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.8 KiB |
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
-25
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
<a class="see-all" href="/l/{{.List.Slug}}">{{if .More}}All {{num .List.ItemCount}}{{else}}Open{{end}}{{icon "arrow-right"}}</a>
|
||||
</header>
|
||||
{{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}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{else}}
|
||||
|
||||
@@ -18,10 +18,11 @@
|
||||
<div class="list-actions">
|
||||
{{if $.LoggedIn}}<a class="btn btn--ghost" href="/admin/lists/{{.List.ID}}">{{icon "pencil"}}Edit</a>{{if .Items}}{{template "export" .List.Slug}}{{end}}{{end}}
|
||||
{{if .Items}}
|
||||
<div class="segmented" role="group" aria-label="Layout" data-layout-toggle data-default="{{.List.Layout}}" data-key="{{.List.Slug}}">
|
||||
<button type="button" data-layout="grid" title="Grid">{{icon "grid"}}<span class="sr-only">Grid</span></button>
|
||||
<button type="button" data-layout="rows" title="List">{{icon "rows"}}<span class="sr-only">List</span></button>
|
||||
</div>
|
||||
<nav class="segmented" aria-label="Style">
|
||||
{{- range .Styles}}
|
||||
<a href="?style={{.Key}}" title="{{.Label}}"{{if eq .Key $.Data.Style}} aria-current="true"{{end}}>{{icon .Icon}}<span class="sr-only">{{.Label}}</span></a>
|
||||
{{- end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,16 +31,16 @@
|
||||
|
||||
<div class="container list-body">
|
||||
{{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}}
|
||||
<nav class="pager" aria-label="Pages">
|
||||
{{if gt .Page 1}}<a class="pager-btn" href="?page={{add .Page -1}}" rel="prev">{{icon "chevron-left"}}<span class="sr-only">Previous</span></a>{{end}}
|
||||
{{if gt .Page 1}}<a class="pager-btn" href="?page={{add .Page -1}}{{.PageQS}}" rel="prev">{{icon "chevron-left"}}<span class="sr-only">Previous</span></a>{{end}}
|
||||
{{range .PageNums}}
|
||||
{{if eq . 0}}<span class="pager-gap">…</span>
|
||||
{{else if eq . $.Data.Page}}<span class="pager-btn is-current" aria-current="page">{{.}}</span>
|
||||
{{else}}<a class="pager-btn" href="?page={{.}}">{{.}}</a>{{end}}
|
||||
{{else}}<a class="pager-btn" href="?page={{.}}{{$.Data.PageQS}}">{{.}}</a>{{end}}
|
||||
{{end}}
|
||||
{{if lt .Page .Pages}}<a class="pager-btn" href="?page={{add .Page 1}}" rel="next">{{icon "chevron-right"}}<span class="sr-only">Next</span></a>{{end}}
|
||||
{{if lt .Page .Pages}}<a class="pager-btn" href="?page={{add .Page 1}}{{.PageQS}}" rel="next">{{icon "chevron-right"}}<span class="sr-only">Next</span></a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{else}}
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
{{define "items"}}
|
||||
<ol class="items items--{{.Layout}} aspect-{{.Category.Aspect}}" data-items data-list-title="{{.Title}}" start="{{with index .Items 0}}{{.Rank}}{{end}}">
|
||||
<ol class="items items--{{.Layout}}" start="{{with index .Items 0}}{{.Rank}}{{end}}">
|
||||
{{- range .Items}}
|
||||
<li class="item{{if le .Rank 3}} item--top item--top{{.Rank}}{{end}}">
|
||||
<button type="button" class="item-btn" data-item="{{.ID}}">
|
||||
<span class="item-art">
|
||||
{{- if .Image}}<img src="{{.Image}}" alt="" loading="lazy" decoding="async">{{else}}<span class="art-fallback">{{icon $.Category.Icon}}</span>{{end -}}
|
||||
{{- template "art" (dict "Item" . "Icon" $.Category.Icon)}}
|
||||
<span class="rank-badge" aria-hidden="true">{{.Rank}}</span>
|
||||
</span>
|
||||
<span class="item-rank" aria-hidden="true">{{.Rank}}</span>
|
||||
<span class="item-body">
|
||||
<span class="item-title"><span class="sr-only">#{{.Rank}}: </span>{{.Title}}</span>
|
||||
{{- template "itemsub" .}}
|
||||
{{- with .Note}}<span class="item-note">{{.}}</span>{{end}}
|
||||
</span>
|
||||
</button>
|
||||
{{- template "playbtn" .}}
|
||||
</li>
|
||||
{{- end}}
|
||||
</ol>
|
||||
{{end}}
|
||||
|
||||
{{/* Shared bits of an entry. */}}
|
||||
{{define "art"}}{{if .Item.Image}}<img src="{{.Item.Image}}" alt="" loading="lazy" decoding="async">{{else}}<span class="art-fallback">{{icon .Icon}}</span>{{end}}{{end}}
|
||||
|
||||
{{define "itemsub"}}
|
||||
{{- if .Badge}}
|
||||
{{- with .Subtitle}}<span class="item-sub">{{.}}</span>{{end}}
|
||||
<span class="item-tags"><span class="badge">{{.Badge}}</span>{{with .Year}}<span class="item-year">{{.}}</span>{{end}}</span>
|
||||
{{- else if or .Subtitle .Year}}
|
||||
<span class="item-sub">{{.Subtitle}}{{if and .Subtitle .Year}}<span class="dot" aria-hidden="true">·</span>{{end}}{{.Year}}</span>
|
||||
{{- end}}
|
||||
{{- with .Note}}<span class="item-note">{{.}}</span>{{end}}
|
||||
</span>
|
||||
</button>
|
||||
{{- if .Preview}}
|
||||
<button type="button" class="play-btn" data-preview-src="/preview/{{.ID}}" aria-pressed="false" aria-label="Play a preview of {{.Title}}" title="Play 30-second preview">{{icon "play"}}{{icon "pause"}}</button>
|
||||
{{- end}}
|
||||
</li>
|
||||
{{- end}}
|
||||
</ol>
|
||||
<script type="application/json" class="items-data">{{.Items}}</script>
|
||||
{{end}}
|
||||
|
||||
{{define "playbtn"}}{{if .Preview}}<button type="button" class="play-btn" data-preview-src="/preview/{{.ID}}" aria-pressed="false" aria-label="Play a preview of {{.Title}}" title="Play 30-second preview">{{icon "play"}}{{icon "pause"}}</button>{{end}}{{end}}
|
||||
@@ -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)}}
|
||||
<div class="ranked ranked--{{if $featured}}{{.Style}}{{else}}{{flatLayout .Style}}{{end}} aspect-{{.Category.Aspect}}" data-items data-list-title="{{.Title}}">
|
||||
{{- 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}}
|
||||
<script type="application/json" class="items-data">{{.Items}}</script>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "podium"}}
|
||||
{{- $c := .Category}}
|
||||
{{- $top := first 3 .Items}}
|
||||
<ol class="podium podium--{{len $top}}">
|
||||
{{- range $top}}
|
||||
<li class="podium-spot podium-spot--{{.Rank}}">
|
||||
<button type="button" class="item-btn podium-card" data-item="{{.ID}}">
|
||||
<span class="item-art">{{template "art" (dict "Item" . "Icon" $c.Icon)}}</span>
|
||||
<span class="item-body">
|
||||
<span class="item-title"><span class="sr-only">#{{.Rank}}: </span>{{.Title}}</span>
|
||||
{{- template "itemsub" .}}
|
||||
</span>
|
||||
</button>
|
||||
{{- template "playbtn" .}}
|
||||
<span class="podium-step" aria-hidden="true"><span>{{.Rank}}</span></span>
|
||||
</li>
|
||||
{{- end}}
|
||||
</ol>
|
||||
{{- $rest := after 3 .Items}}
|
||||
{{- if $rest}}
|
||||
<details class="more">
|
||||
<summary class="more-btn"><span class="more-closed">Show {{len $rest}} more</span><span class="more-open">Show less</span>{{icon "chevron-down"}}</summary>
|
||||
{{template "items" (dict "Items" $rest "Category" $c "Layout" "rows")}}
|
||||
{{- if .AllLink}}<a class="more-all" href="{{.AllLink}}">See all {{num .Total}}{{icon "arrow-right"}}</a>{{end}}
|
||||
</details>
|
||||
{{- else if .AllLink}}
|
||||
<a class="more-all" href="{{.AllLink}}">See all {{num .Total}}{{icon "arrow-right"}}</a>
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
{{define "spotlight"}}
|
||||
{{- $c := .Category}}
|
||||
{{- with index .Items 0}}
|
||||
<article class="spotlight">
|
||||
{{- if .Image}}<div class="spotlight-bg" aria-hidden="true"><img src="{{.Image}}" alt=""></div>{{end}}
|
||||
<div class="spotlight-art">
|
||||
<button type="button" class="item-btn" data-item="{{.ID}}" aria-label="#1: {{.Title}}">
|
||||
<span class="item-art">{{template "art" (dict "Item" . "Icon" $c.Icon)}}</span>
|
||||
</button>
|
||||
{{- template "playbtn" .}}
|
||||
</div>
|
||||
<div class="spotlight-body">
|
||||
<p class="spotlight-rank">#1</p>
|
||||
<h3 class="spotlight-title">{{.Title}}</h3>
|
||||
{{- template "itemsub" .}}
|
||||
{{- with .Note}}<blockquote class="spotlight-note">{{.}}</blockquote>
|
||||
{{- else}}{{with .Description}}<p class="spotlight-desc">{{.}}</p>{{end}}{{end}}
|
||||
<button type="button" class="btn btn--sm spotlight-more" data-item="{{.ID}}">More about it{{icon "arrow-right"}}</button>
|
||||
</div>
|
||||
</article>
|
||||
{{- end}}
|
||||
{{- $rest := after 1 .Items}}
|
||||
{{- if $rest}}{{template "items" (dict "Items" $rest "Category" $c "Layout" "rows")}}{{end}}
|
||||
{{- if .AllLink}}<a class="more-all" href="{{.AllLink}}">See all {{num .Total}}{{icon "arrow-right"}}</a>{{end}}
|
||||
{{end}}
|
||||
Reference in new issue
Block a user