From 134eae94e40452a8451875c585c8172c944f2104 Mon Sep 17 00:00:00 2001 From: ralksta Date: Sun, 8 Mar 2026 23:52:23 +0100 Subject: [PATCH] feat(subpages): add optional sections with typographic TOC - Add SubpageSectionConfig type to schema.ts - Update config parser to handle sections with album overrides - Refactor SubpageGridView to render TOC + section headers - Add base CSS for subpage-toc and subpage-section in globals.css - Add theme-specific overrides for all 5 themes (minimal, editorial, classic, noir, monograph) - Update gallery.yaml.example and docs/gallery-config.md --- app/[...path]/SubpageGridView.tsx | 142 +++++++++++++++++++++++------- app/[...path]/page.tsx | 1 + app/globals.css | 87 +++++++++++++++++- app/themes/classic.css | 70 +++++++++++++++ app/themes/editorial.css | 60 +++++++++++++ app/themes/minimal.css | 51 +++++++++++ app/themes/monograph.css | 70 ++++++++++++++- app/themes/noir.css | 57 ++++++++++++ content/gallery.yaml.example | 28 ++++++ docs/gallery-config.md | 49 +++++++++++ lib/config/index.ts | 34 ++++++- lib/config/schema.ts | 17 +++- 12 files changed, 623 insertions(+), 43 deletions(-) diff --git a/app/[...path]/SubpageGridView.tsx b/app/[...path]/SubpageGridView.tsx index 3de1d4d..4ad4ecf 100644 --- a/app/[...path]/SubpageGridView.tsx +++ b/app/[...path]/SubpageGridView.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { imageUrl } from '@/lib/urls'; +import { SubpageSectionConfig } from '@/lib/config'; interface SubpageAlbum { id: string; @@ -21,9 +22,66 @@ interface SubpageGridViewProps { subtitle?: string; albums: SubpageAlbum[]; coverPlaceholders: (Placeholder | null)[]; + sections?: SubpageSectionConfig[]; } -export function SubpageGridView({ slug, title, subtitle, albums, coverPlaceholders }: SubpageGridViewProps) { +function AlbumGrid({ + albums, + albumMap, + placeholderMap, + slug, +}: { + albums: SubpageAlbum[]; + albumMap: Map; + placeholderMap: Map; + slug: string; +}) { + return ( +
+ {albums.map((album) => { + const ph = placeholderMap.get(album.id) ?? null; + return ( + + {album.albumThumbnailAssetId ? ( + {album.albumName} + ) : ( +
+ )} + + {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'} + +
+ {album.albumName} + + {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'} + +
+ + ); + })} +
+ ); +} + +export function SubpageGridView({ slug, title, subtitle, albums, coverPlaceholders, sections }: SubpageGridViewProps) { + // Build lookup maps once + const albumMap = new Map(albums.map((a) => [a.id, a])); + const placeholderMap = new Map(albums.map((a, i) => [a.id, coverPlaceholders[i] ?? null])); + + const hasSections = sections && sections.length > 0; + return (
{(title || subtitle) && ( @@ -32,41 +90,57 @@ export function SubpageGridView({ slug, title, subtitle, albums, coverPlaceholde {subtitle &&

{subtitle}

} )} -
- {albums.map((album, i) => { - const ph = coverPlaceholders[i]; - return ( - - {album.albumThumbnailAssetId ? ( - {album.albumName} - ) : ( -
- )} - - {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'} - -
- {album.albumName} - - {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'} + + {/* Typographic Table of Contents */} + {hasSections && ( +
- + {sec.title} + + + ))} + + )} + + {/* Sectioned layout */} + {hasSections ? ( + sections.map((sec) => { + const sectionAlbums = sec.albumIds + .map((id) => albumMap.get(id)) + .filter(Boolean) as SubpageAlbum[]; + + return ( +
+
+

{sec.title}

+ {sec.description && ( +

{sec.description}

+ )} +
+ +
); - })} -
+ }) + ) : ( + /* Flat layout (no sections) */ + + )}
); } diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx index 61af81b..e79491e 100644 --- a/app/[...path]/page.tsx +++ b/app/[...path]/page.tsx @@ -227,6 +227,7 @@ export default async function PathPage({ params }: PathPageProps) { subtitle={result.subpage.subtitle} albums={albums} coverPlaceholders={coverPlaceholders} + sections={result.subpage.sections} /> ); } diff --git a/app/globals.css b/app/globals.css index 7e5f5f2..ee86948 100644 --- a/app/globals.css +++ b/app/globals.css @@ -575,7 +575,90 @@ letter-spacing: 0.02em; } -/* ── Subpage Album Grid (2-column, edge-to-edge) ─── */ +/* ── Subpage TOC (Typographic Navigation) ────────── */ +.subpage-toc { + display: flex; + flex-wrap: wrap; + gap: 0; + margin-bottom: 56px; + margin-top: 8px; +} + +.subpage-toc__entry { + display: flex; + align-items: center; +} + +.subpage-toc__entry+.subpage-toc__entry::before { + content: '·'; + padding: 0 12px; + color: var(--text-muted); + font-size: 1rem; + line-height: 1; +} + +.subpage-toc__link { + display: inline-flex; + align-items: baseline; + gap: 6px; + text-decoration: none; + color: var(--text-secondary); + transition: color var(--transition-fast); +} + +.subpage-toc__link:hover { + color: var(--text-primary); +} + +.subpage-toc__num { + font-family: var(--font-caption, monospace); + font-size: 0.6rem; + font-weight: 400; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +.subpage-toc__label { + font-family: var(--font-sans); + font-size: 0.85rem; + font-weight: 400; + letter-spacing: 0.05em; +} + +/* ── Subpage Section ─────────────────────────────── */ +.subpage-section { + scroll-margin-top: 80px; + margin-bottom: 72px; +} + +.subpage-section__header { + margin-bottom: 28px; +} + +.subpage-section__title { + font-family: var(--font-serif); + font-size: 1.6rem; + font-weight: 400; + letter-spacing: 0.02em; + margin-bottom: 6px; +} + +.subpage-section__desc { + font-family: var(--font-sans); + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 300; + letter-spacing: 0.02em; + margin-bottom: 16px; +} + +.subpage-section__rule { + height: 1px; + background: var(--border-subtle); + width: 100%; +} + + .subpage-grid { display: grid; grid-template-columns: repeat(2, 1fr); @@ -1430,4 +1513,4 @@ [data-preset='monograph'] .album-header__title { font-size: 2rem; } -} +} \ No newline at end of file diff --git a/app/themes/classic.css b/app/themes/classic.css index 486b154..386509c 100644 --- a/app/themes/classic.css +++ b/app/themes/classic.css @@ -191,3 +191,73 @@ font-weight: 400; letter-spacing: 0.06em; } + +/* Subpage TOC: Cinzel uppercase with gold ornament separators */ +[data-preset='classic'] .subpage-toc { + margin-bottom: 56px; + justify-content: center; +} + +[data-preset='classic'] .subpage-toc__entry+.subpage-toc__entry::before { + content: '◆'; + padding: 0 14px; + font-size: 0.45rem; + color: var(--accent); +} + +[data-preset='classic'] .subpage-toc__num { + display: none; +} + +[data-preset='classic'] .subpage-toc__label { + font-family: var(--font-serif); + font-size: 0.75rem; + letter-spacing: 0.2em; + text-transform: uppercase; + font-weight: 400; +} + +[data-preset='classic'] .subpage-toc__link { + color: var(--text-muted); +} + +[data-preset='classic'] .subpage-toc__link:hover { + color: var(--accent); +} + +/* Section: ornamental centered divider rule */ +[data-preset='classic'] .subpage-section__header { + text-align: center; + margin-bottom: 36px; +} + +[data-preset='classic'] .subpage-section__title { + font-family: var(--font-serif); + font-size: 1.5rem; + letter-spacing: 0.15em; + text-transform: uppercase; + font-weight: 400; + margin-bottom: 4px; +} + +[data-preset='classic'] .subpage-section__desc { + font-family: var(--font-caption); + font-style: italic; + font-size: 0.8rem; + color: var(--text-muted); +} + +[data-preset='classic'] .subpage-section__rule { + background: none; + height: auto; + border: none; + text-align: center; + margin-top: 16px; +} + +[data-preset='classic'] .subpage-section__rule::after { + content: '—◆—'; + font-size: 0.55rem; + letter-spacing: 0.2em; + color: var(--accent); +} \ No newline at end of file diff --git a/app/themes/editorial.css b/app/themes/editorial.css index 0a13307..0d40dac 100644 --- a/app/themes/editorial.css +++ b/app/themes/editorial.css @@ -175,3 +175,63 @@ font-weight: 300; text-transform: none; } + +/* Subpage TOC: italic, cinematic – like a film chapter list */ +[data-preset='editorial'] .subpage-toc { + margin-bottom: 64px; +} + +[data-preset='editorial'] .subpage-toc__entry+.subpage-toc__entry::before { + content: '/'; + padding: 0 12px; + font-family: var(--font-caption); + font-style: italic; + color: var(--text-muted); +} + +[data-preset='editorial'] .subpage-toc__num { + font-family: var(--font-caption); + font-style: italic; + font-size: 0.65rem; + color: var(--text-muted); +} + +[data-preset='editorial'] .subpage-toc__label { + font-family: var(--font-caption); + font-size: 0.9rem; + font-style: italic; + font-weight: 400; + letter-spacing: 0.04em; +} + +[data-preset='editorial'] .subpage-toc__link { + color: var(--text-secondary); +} + +[data-preset='editorial'] .subpage-toc__link:hover { + color: var(--accent); +} + +/* Section: pull-quote accent border, large cinematic title */ +[data-preset='editorial'] .subpage-section__title { + font-family: var(--font-serif); + font-size: 2rem; + font-weight: 300; + letter-spacing: 0.01em; + text-transform: none; +} + +[data-preset='editorial'] .subpage-section__desc { + font-family: var(--font-caption); + font-style: italic; + font-size: 0.85rem; + opacity: 0.7; +} + +[data-preset='editorial'] .subpage-section__rule { + background: none; + height: 0; + border-left: 3px solid var(--accent); + margin-bottom: -16px; + width: 40px; +} \ No newline at end of file diff --git a/app/themes/minimal.css b/app/themes/minimal.css index 0d6b6d1..16ee321 100644 --- a/app/themes/minimal.css +++ b/app/themes/minimal.css @@ -192,3 +192,54 @@ letter-spacing: 0.12em; text-transform: uppercase; } + +/* Subpage TOC: ultra-minimal, uppercase, no separators */ +[data-preset='minimal'] .subpage-toc { + margin-bottom: 48px; +} + +[data-preset='minimal'] .subpage-toc__entry+.subpage-toc__entry::before { + content: '—'; + padding: 0 10px; + font-size: 0.6rem; + color: var(--text-muted); +} + +[data-preset='minimal'] .subpage-toc__num { + display: none; +} + +[data-preset='minimal'] .subpage-toc__label { + font-family: var(--font-sans); + font-size: 0.6rem; + letter-spacing: 0.2em; + text-transform: uppercase; + font-weight: 400; +} + +[data-preset='minimal'] .subpage-toc__link { + color: var(--text-muted); +} + +[data-preset='minimal'] .subpage-toc__link:hover { + color: var(--text-primary); +} + +/* Section: hard-edged rule, sans title */ +[data-preset='minimal'] .subpage-section__title { + font-family: var(--font-sans); + font-size: 0.75rem; + letter-spacing: 0.2em; + text-transform: uppercase; + font-weight: 400; +} + +[data-preset='minimal'] .subpage-section__desc { + font-size: 0.7rem; + letter-spacing: 0.08em; +} + +[data-preset='minimal'] .subpage-section__rule { + background: var(--text-primary); + height: 2px; +} \ No newline at end of file diff --git a/app/themes/monograph.css b/app/themes/monograph.css index 92e882f..b1a7eb5 100644 --- a/app/themes/monograph.css +++ b/app/themes/monograph.css @@ -184,6 +184,74 @@ text-transform: uppercase; } +/* Subpage TOC: book-indexed, hairline */ +[data-preset='monograph'] .subpage-toc { + margin-bottom: 64px; + flex-direction: column; + gap: 0; + border-top: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--border-subtle); + padding: 16px 0; +} + +[data-preset='monograph'] .subpage-toc__entry { + padding: 6px 0; + border-bottom: 1px solid var(--border-subtle); +} + +[data-preset='monograph'] .subpage-toc__entry:last-child { + border-bottom: none; +} + +[data-preset='monograph'] .subpage-toc__entry+.subpage-toc__entry::before { + display: none; +} + +[data-preset='monograph'] .subpage-toc__link { + display: flex; + gap: 20px; + align-items: baseline; + color: var(--text-secondary); +} + +[data-preset='monograph'] .subpage-toc__link:hover { + color: var(--text-primary); +} + +[data-preset='monograph'] .subpage-toc__num { + font-family: var(--font-caption); + font-size: 0.65rem; + color: var(--text-muted); + min-width: 24px; +} + +[data-preset='monograph'] .subpage-toc__label { + font-family: var(--font-serif); + font-size: 1rem; + font-weight: 400; + letter-spacing: 0.02em; +} + +/* Section: hairline rule, generous serif title */ +[data-preset='monograph'] .subpage-section__title { + font-family: var(--font-serif); + font-size: 2rem; + font-weight: 400; + letter-spacing: 0.02em; + text-transform: none; +} + +[data-preset='monograph'] .subpage-section__desc { + font-family: var(--font-caption); + font-size: 0.75rem; + letter-spacing: 0.04em; +} + +[data-preset='monograph'] .subpage-section__rule { + background: var(--border-subtle); + height: 1px; +} + @media (max-width: 1024px) { .photo-grid { --grid-columns: 2; @@ -327,4 +395,4 @@ [data-preset='monograph'] .album-header__title { font-size: 2rem; } -} +} \ No newline at end of file diff --git a/app/themes/noir.css b/app/themes/noir.css index 37fef20..66ce7b7 100644 --- a/app/themes/noir.css +++ b/app/themes/noir.css @@ -105,3 +105,60 @@ font-weight: 400; letter-spacing: 0.05em; } + +/* Subpage TOC: monospace film-edge style, amber accent */ +[data-preset='noir'] .subpage-toc { + margin-bottom: 56px; +} + +[data-preset='noir'] .subpage-toc__entry+.subpage-toc__entry::before { + content: '|'; + padding: 0 12px; + font-family: var(--font-caption); + font-size: 0.65rem; + color: rgba(255, 107, 53, 0.3); +} + +[data-preset='noir'] .subpage-toc__num { + font-family: var(--font-caption); + font-size: 0.55rem; + letter-spacing: 0.12em; + color: var(--accent); + text-transform: uppercase; +} + +[data-preset='noir'] .subpage-toc__label { + font-family: var(--font-caption); + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + font-weight: 400; +} + +[data-preset='noir'] .subpage-toc__link { + color: var(--text-secondary); +} + +[data-preset='noir'] .subpage-toc__link:hover { + color: var(--accent); +} + +/* Section: amber hairline rule */ +[data-preset='noir'] .subpage-section__title { + font-weight: 300; + letter-spacing: 0.06em; +} + +[data-preset='noir'] .subpage-section__desc { + font-family: var(--font-caption); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 200, 150, 0.5); +} + +[data-preset='noir'] .subpage-section__rule { + background: linear-gradient(to right, var(--accent), transparent); + opacity: 0.35; + height: 1px; +} \ No newline at end of file diff --git a/content/gallery.yaml.example b/content/gallery.yaml.example index fca957a..55865d8 100644 --- a/content/gallery.yaml.example +++ b/content/gallery.yaml.example @@ -24,3 +24,31 @@ subpages: albums: - "album-uuid-5" - "album-uuid-6" + +# ── Subpage with Sections ────────────────────────────────────────── +# Use sections to group albums by location, theme, or any other logic. +# A typographic table of contents is rendered above the albums. +# Sections are optional: leave them out and you get a flat album grid (as above). +subpages: + - name: Japan + title: Japan + subtitle: "Reisen durch ein Land voller Kontraste." + + sections: + - title: Tokyo + description: "Megacity, Neonlichter, Stille im Lärm." + albums: + - "album-uuid-tokyo-1" # Uses Immich album name + - "album-uuid-tokyo-2": Shinjuku bei Nacht # Custom title override + - "album-uuid-tokyo-3" + + - title: Kyoto + description: "Tempel, Bambuswälder, goldener Herbst." + albums: + - "album-uuid-kyoto-1": Fushimi Inari + - "album-uuid-kyoto-2" + + - title: Osaka + albums: # description is optional + - "album-uuid-osaka-1" + - "album-uuid-osaka-2" diff --git a/docs/gallery-config.md b/docs/gallery-config.md index 7357e17..d88be2a 100644 --- a/docs/gallery-config.md +++ b/docs/gallery-config.md @@ -74,6 +74,55 @@ subpages: - 33333333-3333-3333-3333-333333333333 ``` +### Sections + +When a subpage contains many albums you can split them into **named sections**. A typographic table of contents with anchor links is automatically rendered above the albums. Sections are fully optional — omit them and you get the standard flat grid. + +Each section can have: + +| Field | Type | Required | Description | +|---|---|---|---| +| `title` | string | ✅ | Section heading + anchor name | +| `description` | string | ➖ | Optional subline under the heading | +| `albums` | list | ✅ | Album UUIDs (same format as regular albums) | + +Within the `albums` list you can use a plain UUID or override the display title: + +```yaml +albums: + - "album-uuid" # → uses the Immich album name + - "album-uuid": My Title # → displays "My Title" instead +``` + +**Full example:** + +```yaml +subpages: + - name: Japan + title: Japan + subtitle: "Reisen durch ein Land voller Kontraste." + sections: + - title: Tokyo + description: "Megacity, Neonlichter, Stille im Lärm." + albums: + - "33333333-3333-3333-3333-333333333333" + - "44444444-4444-4444-4444-444444444444": Shinjuku bei Nacht + + - title: Kyoto + description: "Tempel und Bambuswälder." + albums: + - "55555555-5555-5555-5555-555555555555": Fushimi Inari + - "66666666-6666-6666-6666-666666666666" + + - title: Osaka + albums: # description is optional + - "77777777-7777-7777-7777-777777777777" +``` + +> [!NOTE] +> Each section title is automatically converted to a URL-safe anchor (`#tokyo`, `#kyoto`, …). The TOC appearance (separator character, numbering style, section rule) is fully controlled by the active theme. + + ## Grid Layout Configure the photo grid globally or per-subpage: diff --git a/lib/config/index.ts b/lib/config/index.ts index 4a7d5d3..d6dee36 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -1,7 +1,7 @@ import { env } from '../env'; import { loadYaml, validateUuid } from './parser'; import { resolveTheme, VALID_LAYOUTS } from './theme'; -import { slugify, AppConfig, SubpageConfig, GalleryYaml, SettingsYaml, GridConfig } from './schema'; +import { slugify, AppConfig, SubpageConfig, SubpageSectionConfig, GalleryYaml, SettingsYaml, GridConfig } from './schema'; export * from './schema'; export * from './theme'; @@ -72,15 +72,41 @@ export function getConfig(): AppConfig { if (Array.isArray(gallery.subpages)) { subpages = gallery.subpages.map((sp) => { - if (!sp.name || !sp.albums || sp.albums.length === 0) { - throw new Error(`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`); + if (!sp.name) { + throw new Error(`Subpage "(unnamed)" must have a name`); } + + // Parse sections if present + let sections: SubpageSectionConfig[] | undefined; + let albumIds: string[]; + + if (sp.sections && sp.sections.length > 0) { + sections = sp.sections.map((sec) => ({ + title: sec.title, + slug: slugify(sec.title), + description: sec.description, + albumIds: sec.albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`)), + })); + // flat union of all section albums + albumIds = sections.flatMap((s) => s.albumIds); + // Also include any top-level albums (outside sections) + const topLevel = (sp.albums ?? []).map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`)); + albumIds = [...topLevel, ...albumIds]; + } else { + const albums = sp.albums ?? []; + if (albums.length === 0) { + throw new Error(`Subpage "${sp.name}" must have albums or sections`); + } + albumIds = albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`)); + } + return { name: sp.name, slug: slugify(sp.name), title: sp.title, subtitle: sp.subtitle, - albumIds: sp.albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`)), + albumIds, + sections, password: sp.password, ...(sp.grid ? { grid: { diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 0888ea3..8f06c34 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -1,9 +1,17 @@ +export interface SubpageSectionConfig { + title: string; + slug: string; // auto-derived from title, used as anchor + description?: string; + albumIds: string[]; +} + export interface SubpageConfig { name: string; slug: string; title?: string; subtitle?: string; - albumIds: string[]; + albumIds: string[]; // flat list (all albums, incl. from sections) + sections?: SubpageSectionConfig[]; password?: string; grid?: Partial; } @@ -83,7 +91,12 @@ export interface GalleryYaml { name: string; title?: string; subtitle?: string; - albums: Array>; + albums?: Array>; + sections?: Array<{ + title: string; + description?: string; + albums: Array>; + }>; password?: string; grid?: { columns?: number;