diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx index a11b33e..381fc93 100644 --- a/app/[...path]/PhotoGrid.tsx +++ b/app/[...path]/PhotoGrid.tsx @@ -34,7 +34,14 @@ export interface PhotoItem { interface PhotoGridProps { assets: PhotoItem[]; - layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay'; + layout?: + | 'masonry' + | 'uniform' + | 'showcase' + | 'filmstrip' + | 'editorial-flow' + | 'essay' + | 'justified'; gridStyle?: React.CSSProperties; watermark?: LightboxWatermark; } @@ -122,8 +129,21 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph return displayedAssets.map((asset, index) => { const isFav = proofing ? proofing.isFavorite(asset.id) : false; + // Justified rows: the flex sizing must sit on the outermost grid child, + // which is the FadeIn wrapper — not the .photo-grid__item inside it. + // Aspect ratio drives both grow factor and basis; ~3:2 fallback for + // assets without dimensions (e.g. videos) keeps the row math sane. + const justifiedAr = asset.aspectRatio || 1.5; + const justifiedStyle: React.CSSProperties | undefined = + layout === 'justified' + ? { + flexGrow: justifiedAr, + flexBasis: `calc(var(--grid-row-height, 300px) * ${justifiedAr})`, + } + : undefined; + return ( - +
) : ( diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx index 4cfc241..ff03079 100644 --- a/app/[...path]/page.tsx +++ b/app/[...path]/page.tsx @@ -182,6 +182,17 @@ export default async function PathPage({ params, searchParams }: PathPageProps) const resolveLayout = (overrides?: Partial) => overrides?.layout ?? config.grid.layout; + // EXPERIMENTAL: per-album grid override — merged over the subpage grid so + // the precedence is global < subpage < album. + const mergeAlbumGrid = ( + albumId: string, + spGrid?: Partial, + ): Partial | undefined => { + const albumGrid = config.albumGrids[albumId]; + if (!albumGrid) return spGrid; + return { ...spGrid, ...albumGrid }; + }; + if (!path || path.length === 0) { notFound(); } @@ -225,8 +236,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps) { const heroId = config.albumHeroImages[album.id]; - return heroId ? { ...album, albumThumbnailAssetId: heroId } : album; + // EXPERIMENTAL: coverPosition rides along so the card crop can honour + // the configured focal point. + const coverPosition = config.albumCoverPositions[album.id]; + return { + ...album, + ...(heroId ? { albumThumbnailAssetId: heroId } : {}), + ...(coverPosition ? { coverPosition } : {}), + }; }); // Batch-fetch ThumbHash for album cover placeholders @@ -412,8 +430,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps) + + + + + + ); +} + export function IconBan({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { return ( diff --git a/app/admin/components/PageBuilder.tsx b/app/admin/components/PageBuilder.tsx index 130d096..3f170e1 100644 --- a/app/admin/components/PageBuilder.tsx +++ b/app/admin/components/PageBuilder.tsx @@ -43,6 +43,9 @@ import { IconGripVertical, IconHome, IconImage, + IconBan, + IconEyeOff, + IconGlobe, IconLock, IconPencil, IconPlus, @@ -92,6 +95,10 @@ interface AlbumEntry { sort?: AlbumSortMode; /** Pinned asset UUIDs for `sort: manual`; everything else follows automatically. */ assetOrder?: string[]; + /** EXPERIMENTAL: per-album grid override (layout only in the UI for now) */ + grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }; + /** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */ + coverPosition?: string; } interface Section { @@ -106,6 +113,8 @@ interface Subpage { subtitle?: string; password?: string; enabled?: boolean; + /** EXPERIMENTAL: reachable by direct link, but not shown in navigation */ + hidden?: boolean; essayText?: string; essayFile?: string; sections?: Section[]; @@ -153,7 +162,9 @@ function hasAlbumOptions(entry: AlbumEntry): boolean { entry.password || entry.heroImage || (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) || - entry.assetOrder?.length, + entry.assetOrder?.length || + entry.grid || + entry.coverPosition, ); } @@ -171,6 +182,8 @@ function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] { heroImage: value.heroImage, sort: isAlbumSortMode(value.sort) ? value.sort : undefined, assetOrder: value.assetOrder, + grid: value.grid, + coverPosition: value.coverPosition, }; }); } @@ -192,6 +205,8 @@ function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] { // Persisted regardless of the mode, so manual → newest → manual does not // throw away a hand-curated order. if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder; + if (entry.grid) val.grid = entry.grid; + if (entry.coverPosition) val.coverPosition = entry.coverPosition; return { [entry.id]: val }; }); } @@ -326,6 +341,12 @@ function SortableSubpageTile({ )} + {sp.hidden === true && sp.enabled !== false && ( + + Unlisted + + )} + {sp.password && ( Password @@ -420,6 +441,22 @@ export default function PageBuilder() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [dirty, saving, gallery]); + // ── Escape closes the subpage sheet — only when it is topmost ─ + useEffect(() => { + if (expandedSubpage === null) return; + function handleEscape(e: KeyboardEvent) { + if (e.key !== 'Escape') return; + // Listbox preventDefaults its own Escape but does not stopPropagation — + // respect that so closing a popup never also closes the sheet. + if (e.defaultPrevented) return; + // A higher layer (album editor, pickers, order editor) owns the key. + if (editingAlbumAddress || pickerTarget || heroPickerTarget || orderEditorTarget) return; + setExpandedSubpage(null); + } + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [expandedSubpage, editingAlbumAddress, pickerTarget, heroPickerTarget, orderEditorTarget]); + // ── Unsaved changes guard ──────────────────────────────────── useEffect(() => { function handleBeforeUnload(e: BeforeUnloadEvent) { @@ -470,6 +507,7 @@ export default function PageBuilder() { subtitle: sp.subtitle as string | undefined, password: sp.password as string | undefined, enabled: sp.enabled !== false, + hidden: sp.hidden === true, essayText: sp.essayText as string | undefined, essayFile: sp.essayFile as string | undefined, albums: parseAlbumEntries(sp.albums as Array> | undefined), @@ -494,6 +532,7 @@ export default function PageBuilder() { subtitle: sp.subtitle as string | undefined, password: sp.password as string | undefined, enabled: sp.enabled !== false, + hidden: sp.hidden === true, essayText: sp.essayText as string | undefined, essayFile: sp.essayFile as string | undefined, albums: parseAlbumEntries( @@ -533,6 +572,7 @@ export default function PageBuilder() { if (sp.subtitle) entry.subtitle = sp.subtitle; if (sp.password) entry.password = sp.password; if (sp.enabled === false) entry.enabled = false; + if (sp.hidden === true) entry.hidden = true; if (sp.essayText) entry.essayText = sp.essayText; if (sp.essayFile) entry.essayFile = sp.essayFile; if (sp.grid) entry.grid = sp.grid; @@ -1175,7 +1215,8 @@ export default function PageBuilder() { )}
) : ( - <> +
+
@@ -1215,9 +1256,74 @@ export default function PageBuilder() { />
-
- -
+
+ + {/* + One field, three states — enabled/hidden are two YAML flags, + but for the owner it is a single question: how visible is + this page? Splitting it across two toggles produced + contradictory copy ("Disabled (Hidden)" vs "Unlisted"). + */} + {( + [ + { + key: 'published', + active: sp.enabled !== false && sp.hidden !== true, + icon: , + iconColor: '#4ade80', + title: 'Published', + desc: 'Shown in the header menu and homepage lists, reachable via URL', + patch: { enabled: true, hidden: false }, + }, + { + key: 'unlisted', + active: sp.enabled !== false && sp.hidden === true, + icon: , + iconColor: '#fbbf24', + title: 'Unlisted (Experimental)', + desc: 'Not shown in menus or on the homepage, but reachable via direct link', + patch: { enabled: true, hidden: true }, + }, + { + key: 'disabled', + active: sp.enabled === false, + icon: , + iconColor: '#f87171', + title: 'Disabled', + desc: 'Completely offline — returns 404 even when accessed directly', + patch: { enabled: false, hidden: false }, + }, + ] as const + ).map((opt) => ( + + ))} + + {/* Password lives in the same group: it is the access half + of "who gets to see this page". Applies to Published and + Unlisted; a Disabled page 404s before the gate. */} +
updateSubpage(spIndex, { password: e.target.value || undefined }) } - placeholder="Leave empty for public access" + placeholder="Password protection (optional) — leave empty for public access" + disabled={sp.enabled === false} />
- -
- - -
{ + const layout = e.target.value || undefined; + // Only the layout is exposed here; drop the grid object + // entirely when it goes back to "inherit" so the YAML + // stays clean. + onUpdate({ grid: layout ? { ...(album.grid || {}), layout } : undefined }); + }} + > + + + + + + + + +
+ +
+ + onUpdate({ coverPosition: e.target.value || undefined })} + placeholder={'e.g. "50% 25%" or "top" — where the cover crop should anchor'} + /> +
+ {/* Hero Image Selection */}
@@ -1627,6 +1742,8 @@ export default function PageBuilder() {
)}
+
+
+
+ ))} + )} diff --git a/app/api/exif/[id]/route.ts b/app/api/exif/[id]/route.ts index 3c48435..57a2cc1 100644 --- a/app/api/exif/[id]/route.ts +++ b/app/api/exif/[id]/route.ts @@ -14,10 +14,10 @@ import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const config = getConfig(); - // Security: If EXIF is globally disabled, do not serve it via API either - if (!config.exifOnHover) { - return NextResponse.json({ error: 'EXIF metadata is disabled' }, { status: 403 }); - } + // exifOnHover only gates the *technical* fields below. The asset description + // (EXPERIMENTAL caption) is editorial content, so the route stays reachable + // and serves captions even when EXIF display is disabled. + const exifEnabled = config.exifOnHover; // ── Rate limiting ────────────────────────────────── const ip = getClientIp(request); @@ -64,17 +64,23 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } const exif = asset.exifInfo; + const caption = exif.description?.trim() || undefined; return NextResponse.json( { - make: exif.make, - model: exif.model, - lensModel: exif.lensModel, - focalLength: exif.focalLength, - fNumber: exif.fNumber, - exposureTime: exif.exposureTime, - iso: exif.iso, - city: exif.city, - country: exif.country, + ...(exifEnabled + ? { + make: exif.make, + model: exif.model, + lensModel: exif.lensModel, + focalLength: exif.focalLength, + fNumber: exif.fNumber, + exposureTime: exif.exposureTime, + iso: exif.iso, + city: exif.city, + country: exif.country, + } + : {}), + ...(caption ? { description: caption } : {}), }, { headers: { diff --git a/app/api/exif/__tests__/exif-route.test.ts b/app/api/exif/__tests__/exif-route.test.ts new file mode 100644 index 0000000..5ecf73e --- /dev/null +++ b/app/api/exif/__tests__/exif-route.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * EXPERIMENTAL captions (L10): the route serves the Immich asset description + * as a caption. Captions are editorial, not technical — so exifOnHover: false + * must strip the technical fields but still serve the caption, instead of the + * old blanket 403. + */ + +vi.mock('@/lib/config', () => ({ + getConfig: vi.fn(), +})); + +vi.mock('@/lib/immich', () => ({ + immich: { getAssetInfo: vi.fn() }, + ImmichUnavailableError: class ImmichUnavailableError extends Error {}, +})); + +vi.mock('@/lib/tokens', () => ({ + decodeAssetId: vi.fn(() => 'asset-uuid'), +})); + +vi.mock('@/lib/rate-limit', () => ({ + checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })), + getClientIp: vi.fn(() => '127.0.0.1'), + retryAfterSeconds: vi.fn(() => 60), +})); + +import { GET } from '../[id]/route'; +import { getConfig } from '@/lib/config'; +import { immich } from '@/lib/immich'; +import { NextRequest } from 'next/server'; + +const mockConfig = getConfig as unknown as ReturnType; +const mockAssetInfo = immich.getAssetInfo as unknown as ReturnType; + +const EXIF_ASSET = { + exifInfo: { + make: 'Fujifilm', + model: 'X-T5', + lensModel: 'XF 35mm', + focalLength: 35, + fNumber: 2, + exposureTime: '1/250', + iso: 200, + city: 'Vienna', + country: 'Austria', + description: ' A quiet morning at the Danube. ', + }, +}; + +function makeRequest() { + return new NextRequest('http://localhost/api/exif/token123'); +} + +const params = { params: Promise.resolve({ id: 'token123' }) }; + +beforeEach(() => { + vi.clearAllMocks(); + mockAssetInfo.mockResolvedValue(EXIF_ASSET); +}); + +describe('GET /api/exif/[id] captions', () => { + it('includes the trimmed description alongside technical fields', async () => { + mockConfig.mockReturnValue({ exifOnHover: true, rateLimitRpm: 120 }); + + const res = await GET(makeRequest(), params); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.model).toBe('X-T5'); + expect(body.description).toBe('A quiet morning at the Danube.'); + }); + + it('serves only the caption when exifOnHover is disabled', async () => { + mockConfig.mockReturnValue({ exifOnHover: false, rateLimitRpm: 120 }); + + const res = await GET(makeRequest(), params); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.description).toBe('A quiet morning at the Danube.'); + expect(body).not.toHaveProperty('model'); + expect(body).not.toHaveProperty('iso'); + }); + + it('omits the description entirely when the asset has none', async () => { + mockConfig.mockReturnValue({ exifOnHover: true, rateLimitRpm: 120 }); + mockAssetInfo.mockResolvedValue({ + exifInfo: { ...EXIF_ASSET.exifInfo, description: null }, + }); + + const res = await GET(makeRequest(), params); + const body = await res.json(); + expect(body).not.toHaveProperty('description'); + }); +}); diff --git a/app/globals.css b/app/globals.css index 3839440..e8604ad 100644 --- a/app/globals.css +++ b/app/globals.css @@ -244,6 +244,76 @@ overflow: hidden; } +/* ── Cover / Splash hero (EXPERIMENTAL) ───────────── */ +/* Fullscreen welcome page: one image, the title, a single Enter link. */ +.hero--cover { + position: relative; + min-height: calc(100vh - 56px); + min-height: calc(100dvh - 56px); + overflow: hidden; +} + +.hero--cover > span, +.hero--cover > img { + position: absolute !important; + inset: 0; + width: 100% !important; + height: 100% !important; + object-fit: cover; +} + +.hero__cover-overlay { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 64px 40px; + background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.1) 75%); + color: #fff; +} + +.hero--cover .hero__title, +.hero--cover .hero__subtitle { + color: #fff; +} + +.hero__cover-enter { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 36px; + padding: 12px 32px; + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: var(--radius-lg, 4px); + color: #fff; + font-family: var(--font-caption); + font-size: 0.85rem; + letter-spacing: 0.18em; + text-transform: uppercase; + text-decoration: none; + transition: + background var(--transition-normal), + color var(--transition-normal); +} + +.hero__cover-enter:hover, +.hero__cover-enter:focus-visible { + background: #fff; + color: #000; +} + +.hero__cover-enter-arrow { + transition: transform var(--transition-normal); +} + +.hero__cover-enter:hover .hero__cover-enter-arrow { + transform: translateX(4px); +} + /* HeroCarousel images fill the entire container */ .hero--fullbleed > span, .hero--fullbleed > img { @@ -1084,6 +1154,55 @@ } } +/* ── Justified Rows Layout (EXPERIMENTAL) ───────── */ +/* Row-based justified layout: every row fills the container width, all + images in a row share the same height, aspect ratios stay intact. + Flex sizing (grow = aspect ratio) lives inline on the .fade-in wrapper. */ +.photo-grid--justified { + display: flex; + flex-wrap: wrap; + gap: var(--grid-gap, 12px); + column-count: unset; + /* More columns → shorter rows, mirroring the density of the other layouts */ + --grid-row-height: calc(900px / var(--grid-columns, 3)); +} + +/* Filler that soaks up leftover space so the last row keeps natural sizes + instead of stretching a lone image to full width */ +.photo-grid--justified::after { + content: ''; + flex-grow: 999999; + min-width: 0; +} + +.photo-grid--justified > .fade-in { + height: var(--grid-row-height); + min-width: 0; +} + +.photo-grid--justified .photo-grid__item { + height: 100%; + width: 100%; + margin-bottom: 0; + /* The base .photo-grid__item rule forces aspect-ratio from the grid + setting. Combined with height: 100% that pins the item width to + height × ratio, so it never fills the flex-grown wrapper and rows end + ragged. The wrapper's flex sizing is the only geometry source here. */ + aspect-ratio: unset; +} + +@media (max-width: 1024px) { + .photo-grid--justified { + --grid-row-height: 240px; + } +} + +@media (max-width: 640px) { + .photo-grid--justified { + --grid-row-height: 160px; + } +} + .photo-grid__item img { width: 100%; height: 100%; diff --git a/app/page.tsx b/app/page.tsx index 5488c2f..58c86c6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -137,6 +137,38 @@ export default async function HomePage() { const heroStyle = config.theme.heroStyle; + // ── Cover (EXPERIMENTAL): fullscreen splash + single "Enter" link ─ + // Welcome-page splash: one fullbleed image, the site + // title, and one link into the portfolio (the first nav entry). + if (heroStyle === 'cover') { + const entries = heroNavEntries(subpages, albums); + const enterHref = entries[0]?.href ?? '/about'; + + return ( +
+ +
+ +

{config.siteTitle}

+
+ {config.siteSubtitle && ( + +

{config.siteSubtitle}

+
+ )} + + + Enter + + + +
+
+ ); + } + // ── Typographic: no image, pure text ─────────────────────────── if (heroStyle === 'typographic') { return ( diff --git a/components/FadeIn.tsx b/components/FadeIn.tsx index 0b61b24..bf1fca3 100644 --- a/components/FadeIn.tsx +++ b/components/FadeIn.tsx @@ -18,9 +18,11 @@ interface FadeInProps { direction?: 'up' | 'none'; /** CSS class name for the wrapper */ className?: string; + /** Extra inline styles for the wrapper (e.g. flex sizing in justified grids) */ + style?: React.CSSProperties; } -export function FadeIn({ children, delay = 0, direction = 'up', className }: FadeInProps) { +export function FadeIn({ children, delay = 0, direction = 'up', className, style }: FadeInProps) { const ref = useRef(null); const reveal = useCallback(() => { @@ -55,7 +57,7 @@ export function FadeIn({ children, delay = 0, direction = 'up', className }: Fad
{children}
diff --git a/components/Lightbox.module.css b/components/Lightbox.module.css index 71dd96c..3f2f2c5 100644 --- a/components/Lightbox.module.css +++ b/components/Lightbox.module.css @@ -184,6 +184,17 @@ animation: exif-slide-up var(--transition-normal) ease forwards; } +/* EXPERIMENTAL: editorial caption (Immich asset description) above the tech rows */ +.exifCaption { + margin: 0 0 12px; + max-width: 280px; + font-family: var(--font-caption); + font-size: 0.85rem; + line-height: 1.5; + font-style: italic; + color: rgba(255, 255, 255, 0.85); +} + @keyframes exif-slide-up { from { opacity: 0; diff --git a/components/Lightbox.tsx b/components/Lightbox.tsx index 70b65bf..86db386 100644 --- a/components/Lightbox.tsx +++ b/components/Lightbox.tsx @@ -326,6 +326,9 @@ export function Lightbox({ ) : exifData ? ( <> + {exifData.description && ( +

{exifData.description}

+ )} {exifData.model && (
Camera diff --git a/components/SubpageNav.tsx b/components/SubpageNav.tsx index ceca5e3..8a72a7a 100644 --- a/components/SubpageNav.tsx +++ b/components/SubpageNav.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { immich } from '@/lib/immich'; +import { getConfig } from '@/lib/config'; import { listJournalEntries } from '@/lib/admin/journal-service'; export async function SubpageNav() { @@ -13,6 +14,9 @@ export async function SubpageNav() { immich.getStandaloneAlbums(), listJournalEntries().catch(() => []), ]); + // EXPERIMENTAL: external nav links from settings.yaml, appended after the + // internal entries. Sanitised to http(s) in getConfig(). + const navLinks = getConfig().navLinks; const hasPublicJournal = journalEntries.some((e) => !e.frontmatter.draft); @@ -33,6 +37,17 @@ export async function SubpageNav() { {album.albumName} ))} + {navLinks.map((link) => ( + + {link.label} + + ))} ); } diff --git a/content/gallery.yaml.example b/content/gallery.yaml.example index cf97359..83c1747 100644 --- a/content/gallery.yaml.example +++ b/content/gallery.yaml.example @@ -51,12 +51,17 @@ subpages: title: "My Creative Projects" # Optional: Header title, defaults to Navigation key ("Projects") subtitle: "A collection of 2026 highlights in 35mm film." # Optional: Sub-text for headers password: "your-password" # Optional protection + # hidden: true # EXPERIMENTAL: reachable by direct link, but not shown in navigation albums: - "album-uuid-5" - "album-uuid-6": title: "Private Project #7" description: "Shot on 35mm film in Vienna, spring 2025." # Optional: shown below album title password: "scrypt:..." # Recommended: secure hash (generated via logs) + # grid: # EXPERIMENTAL: per-album grid override (wins over page/global grid) + # layout: "justified" + # columns: 2 + # coverPosition: "50% 25%" # EXPERIMENTAL: focal point for the cover crop ("top", "center bottom", "50% 25%") # ── Subpage with Sections ────────────────────────────────────────── # Use sections to group albums by location, theme, or any other logic. diff --git a/content/settings.yaml.example b/content/settings.yaml.example index c764c3a..308d5d8 100644 --- a/content/settings.yaml.example +++ b/content/settings.yaml.example @@ -39,7 +39,7 @@ theme: # accent: "#e60012" # photoFrame: "passepartout" # none, passepartout, shadow # grain: true - # heroStyle: "split" # split, fullbleed, minimal, stacked, typographic, mosaic + # heroStyle: "split" # split, fullbleed, minimal, stacked, typographic, mosaic, cover (EXPERIMENTAL) # Note: "mosaic" needs at least 3 hero images in gallery.yaml (optimal: 6) # ── Grid configuration ─────────────────────────────────────────── @@ -47,7 +47,16 @@ grid: columns: 3 gap: 12 aspectRatio: "1" # "1" (square), "3/2", "2/3", "16/9", "auto" - layout: "masonry" # masonry, uniform, showcase, filmstrip, editorial-flow + layout: "masonry" # masonry, uniform, showcase, filmstrip, editorial-flow, justified (EXPERIMENTAL) + +# ── Navigation ──────────────────────────────────────────────────── +# EXPERIMENTAL: external links appended to the header navigation. +# Only http(s) URLs are allowed; they open in a new tab. +# navLinks: +# - label: "Shop" +# url: "https://shop.example.com" +# - label: "Instagram" +# url: "https://instagram.com/your-handle" # ── Footer & Social ────────────────────────────────────────────── footer: diff --git a/hooks/useExif.ts b/hooks/useExif.ts index dca9bb3..37b15df 100644 --- a/hooks/useExif.ts +++ b/hooks/useExif.ts @@ -10,6 +10,8 @@ export interface ExifData { iso?: number | null; city?: string | null; country?: string | null; + /** EXPERIMENTAL: editorial caption from the Immich asset description */ + description?: string | null; } export function useExif() { diff --git a/lib/__tests__/config.test.ts b/lib/__tests__/config.test.ts index 791015b..4455a0c 100644 --- a/lib/__tests__/config.test.ts +++ b/lib/__tests__/config.test.ts @@ -12,7 +12,38 @@ vi.mock('@/lib/env', () => ({ }, })); -import { slugify, buildSubpageGrid } from '@/lib/config'; +import { slugify, buildSubpageGrid, sanitizeNavLinks } from '@/lib/config'; + +// EXPERIMENTAL: external navigation links — rendered as in the +// header, so only http(s) URLs may survive sanitisation. +describe('sanitizeNavLinks', () => { + it('keeps well-formed http(s) links', () => { + expect( + sanitizeNavLinks([ + { label: 'Shop', url: 'https://shop.example.com' }, + { label: 'Blog', url: 'http://blog.example.com/a?b=c' }, + ]), + ).toEqual([ + { label: 'Shop', url: 'https://shop.example.com' }, + { label: 'Blog', url: 'http://blog.example.com/a?b=c' }, + ]); + }); + + it('drops non-http schemes and entries without label or url', () => { + expect( + sanitizeNavLinks([ + { label: 'Bad', url: 'javascript:alert(1)' }, + { label: 'AlsoBad', url: 'data:text/html,x' }, + { label: '', url: 'https://x.example' }, + { label: 'NoUrl', url: '' }, + ]), + ).toEqual([]); + }); + + it('returns empty for undefined input', () => { + expect(sanitizeNavLinks(undefined)).toEqual([]); + }); +}); describe('slugify', () => { it('converts a simple name to lowercase with hyphens', () => { @@ -79,6 +110,12 @@ describe('buildSubpageGrid', () => { }); }); + it('preserves the experimental justified layout', () => { + expect(buildSubpageGrid({ layout: 'justified' })).toEqual({ + grid: { layout: 'justified' }, + }); + }); + it('falls back to masonry for an unknown layout', () => { expect(buildSubpageGrid({ layout: 'unknown-layout' })).toEqual({ grid: { layout: 'masonry' }, diff --git a/lib/__tests__/derive-gallery.test.ts b/lib/__tests__/derive-gallery.test.ts index 0ccd206..16e0439 100644 --- a/lib/__tests__/derive-gallery.test.ts +++ b/lib/__tests__/derive-gallery.test.ts @@ -156,6 +156,66 @@ describe('deriveGallery accepts valid structures', () => { expect(result.albumManualOrders[A]).toEqual([B]); }); + // EXPERIMENTAL: hidden keeps a subpage reachable by direct link while + // removing it from navigation — distinct from enabled:false, which 404s. + it('derives the hidden flag on array-style subpages', () => { + const result = deriveGallery({ + subpages: [{ name: 'Preview', hidden: true, albums: [A] }], + } as unknown as GalleryYaml); + + expect(result.subpages[0].hidden).toBe(true); + expect(result.subpages[0].enabled).toBe(true); + }); + + it('derives the hidden flag on map-style subpages, defaulting to falsy', () => { + const result = deriveGallery({ + subpages: { Preview: { hidden: true, albums: [A] }, Open: [B] }, + } as unknown as GalleryYaml); + + const preview = result.subpages.find((sp) => sp.name === 'Preview'); + const open = result.subpages.find((sp) => sp.name === 'Open'); + expect(preview?.hidden).toBe(true); + expect(open?.hidden).toBeFalsy(); + }); + + // EXPERIMENTAL: per-album grid overrides — merged over subpage/global grid. + it('collects per-album grid overrides, dropping unknown layouts to masonry', () => { + const result = deriveGallery({ + albums: [ + { [A]: { title: 'Panoramas', grid: { layout: 'filmstrip', columns: 2 } } }, + { [B]: { grid: { layout: 'not-a-layout' } } }, + ], + } as unknown as GalleryYaml); + + expect(result.albumGrids[A]).toEqual({ layout: 'filmstrip', columns: 2 }); + expect(result.albumGrids[B]).toEqual({ layout: 'masonry' }); + }); + + it('leaves albumGrids empty for entries without a grid', () => { + const result = deriveGallery({ albums: [A] } as unknown as GalleryYaml); + expect(result.albumGrids).toEqual({}); + }); + + // EXPERIMENTAL: coverPosition feeds object-position on cover images, so it + // must be a strict CSS position value — anything else is a typo or an + // injection attempt and both should fail loudly. + it('collects a valid coverPosition', () => { + const result = deriveGallery({ + albums: [{ [A]: { coverPosition: '50% 25%' } }, { [B]: { coverPosition: 'top' } }], + } as unknown as GalleryYaml); + + expect(result.albumCoverPositions[A]).toBe('50% 25%'); + expect(result.albumCoverPositions[B]).toBe('top'); + }); + + it('rejects a malformed coverPosition, naming the album', () => { + expect(() => + deriveGallery({ + albums: [{ [A]: { coverPosition: 'javascript:alert(1)' } }], + } as unknown as GalleryYaml), + ).toThrow(new RegExp(A)); + }); + it('ignores an empty asset order rather than storing one', () => { const result = deriveGallery({ albums: [{ [A]: { sort: 'manual', assetOrder: [] } }], diff --git a/lib/config/index.ts b/lib/config/index.ts index d2afef4..54f28e1 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -76,6 +76,32 @@ export function buildSubpageGrid(raw?: { }; } +/** + * EXPERIMENTAL: keep only nav links that are safe to render as header + * tags. Non-http(s) schemes (javascript:, data:) and incomplete entries are + * dropped with a warning rather than throwing — a bad external link should + * not take the site down. + */ +export function sanitizeNavLinks( + raw?: Array<{ label?: string; url?: string }>, +): Array<{ label: string; url: string }> { + if (!raw) return []; + const links: Array<{ label: string; url: string }> = []; + for (const entry of raw) { + const label = entry.label?.trim(); + const url = entry.url?.trim(); + if (!label || !url || !/^https?:\/\//i.test(url)) { + console.warn( + `[Folio] settings.yaml navLinks: dropping entry ${JSON.stringify(entry)} — ` + + 'label and an http(s) url are required.', + ); + continue; + } + links.push({ label, url }); + } + return links; +} + /** The parts of AppConfig that come from gallery.yaml. */ export interface GalleryDerivation { albums: string[]; @@ -87,6 +113,8 @@ export interface GalleryDerivation { albumHeroImages: Record; albumSortModes: Record; albumManualOrders: Record; + albumGrids: Record>; + albumCoverPositions: Record; } /** @@ -108,6 +136,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { const albumHeroImages: Record = {}; const albumSortModes: Record = {}; const albumManualOrders: Record = {}; + const albumGrids: Record> = {}; + const albumCoverPositions: Record = {}; function processAlbumEntry( entry: string | Record, @@ -162,6 +192,29 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { validateUuid(assetId, `${context} assetOrder[${i}] for album ${validatedUuid}`), ); } + + // EXPERIMENTAL: per-album grid override — reuses the subpage-grid + // normalisation so unknown layouts degrade identically. + if (value.grid) { + const built = buildSubpageGrid(value.grid); + if ('grid' in built) albumGrids[validatedUuid] = built.grid; + } + + // EXPERIMENTAL: coverPosition lands in a style attribute, so only a + // strict CSS position grammar is allowed. Throw like `sort` does — a + // silent fallback would leave the owner wondering why nothing moves. + if (value.coverPosition != null) { + const pos = value.coverPosition.trim(); + const keyword = /^(center|top|bottom|left|right)( (center|top|bottom|left|right))?$/; + const percent = /^\d{1,3}% \d{1,3}%$/; + if (!keyword.test(pos) && !percent.test(pos)) { + throw new Error( + `${context}: album ${validatedUuid} has an invalid coverPosition "${value.coverPosition}". ` + + `Use two percentages ("50% 25%") or CSS keywords ("top", "center bottom").`, + ); + } + albumCoverPositions[validatedUuid] = pos; + } } return validatedUuid; } @@ -218,6 +271,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { essayFile: sp.essayFile, essayText: sp.essayText, enabled: sp.enabled !== false, + hidden: sp.hidden === true, ...buildSubpageGrid(sp.grid), }; }); @@ -244,6 +298,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { essayFile: sp.essayFile, essayText: sp.essayText, enabled: sp.enabled !== false, + hidden: sp.hidden === true, ...buildSubpageGrid(sp.grid), }; }); @@ -262,6 +317,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { albumHeroImages, albumSortModes, albumManualOrders, + albumGrids, + albumCoverPositions, }; } @@ -316,6 +373,9 @@ export function getConfig(): AppConfig { albumHeroImages: {}, albumSortModes: {}, albumManualOrders: {}, + albumGrids: {}, + albumCoverPositions: {}, + navLinks: [], cacheTtl: env.CACHE_TTL * 1000, staleMaxAge: env.STALE_MAX_AGE * 1000, immichTimeoutMs: env.IMMICH_TIMEOUT_MS, @@ -337,6 +397,8 @@ export function getConfig(): AppConfig { albumHeroImages, albumSortModes, albumManualOrders, + albumGrids, + albumCoverPositions, } = deriveGallery(gallery); const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery'; @@ -412,6 +474,9 @@ export function getConfig(): AppConfig { albumHeroImages, albumSortModes, albumManualOrders, + albumGrids, + albumCoverPositions, + navLinks: sanitizeNavLinks(settings.navLinks), cacheTtl: env.CACHE_TTL * 1000, staleMaxAge: env.STALE_MAX_AGE * 1000, immichTimeoutMs: env.IMMICH_TIMEOUT_MS, diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 237764e..f533f72 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -20,6 +20,8 @@ export interface SubpageConfig { essayFile?: string; essayText?: string; enabled?: boolean; + /** EXPERIMENTAL: reachable by direct link, but not shown in navigation */ + hidden?: boolean; } export interface SubpageObjectValue { @@ -31,6 +33,8 @@ export interface SubpageObjectValue { essayFile?: string; essayText?: string; enabled?: boolean; + /** EXPERIMENTAL: reachable by direct link, but not shown in navigation */ + hidden?: boolean; // Both album lists reuse AlbumEntryObject rather than inlining the shape: // the inline copies had already drifted (they never gained `heroImage`), and // this path is live for map-style subpages. @@ -42,6 +46,12 @@ export interface SubpageObjectValue { }>; } +/** EXPERIMENTAL: external link shown in the header navigation. */ +export interface NavLinkConfig { + label: string; + url: string; +} + export interface FooterConfig { name?: string; instagram?: string; @@ -70,14 +80,21 @@ export interface ThemeConfig { photoFrame: 'none' | 'passepartout' | 'shadow'; grain: boolean; headerDot: boolean; - heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic'; + heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic' | 'cover'; } export interface GridConfig { columns: number; gap: number; aspectRatio: string; - layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay'; + layout: + | 'masonry' + | 'uniform' + | 'showcase' + | 'filmstrip' + | 'editorial-flow' + | 'essay' + | 'justified'; } export interface AppConfig { @@ -119,6 +136,12 @@ export interface AppConfig { albumSortModes: Record; /** Pinned asset UUIDs per album for `sort: manual`, in display order. */ albumManualOrders: Record; + /** EXPERIMENTAL: per-album grid overrides, merged over the subpage/global grid. */ + albumGrids: Record>; + /** EXPERIMENTAL: per-album focal point for cover crops (CSS object-position). */ + albumCoverPositions: Record; + /** EXPERIMENTAL: external links appended to the header navigation. */ + navLinks: NavLinkConfig[]; cacheTtl: number; /** How long (ms) an expired cache entry may still be served while Immich is unreachable. */ staleMaxAge: number; @@ -152,6 +175,10 @@ export interface AlbumEntryObject { sort?: string; /** Pinned asset UUIDs for `sort: manual`. Everything else follows automatically. */ assetOrder?: string[]; + /** EXPERIMENTAL: per-album grid override, merged over the subpage/global grid */ + grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }; + /** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */ + coverPosition?: string; } export interface GalleryYaml { @@ -174,6 +201,7 @@ export interface GalleryYaml { essayFile?: string; essayText?: string; enabled?: boolean; + hidden?: boolean; grid?: { columns?: number; gap?: number; @@ -223,6 +251,8 @@ export interface SettingsYaml { }; footer?: FooterConfig; legal?: Partial; + /** EXPERIMENTAL: external links appended to the header navigation. */ + navLinks?: Array<{ label?: string; url?: string }>; protection?: { disableRightClick?: boolean; disableImageDrag?: boolean; diff --git a/lib/config/theme.ts b/lib/config/theme.ts index d7e7f58..fb406e4 100644 --- a/lib/config/theme.ts +++ b/lib/config/theme.ts @@ -81,8 +81,17 @@ export const VALID_HERO_STYLES = [ 'stacked', 'typographic', 'mosaic', + 'cover', // EXPERIMENTAL: fullscreen splash with a single "Enter" link +]; +export const VALID_LAYOUTS = [ + 'masonry', + 'uniform', + 'showcase', + 'filmstrip', + 'editorial-flow', + 'essay', + 'justified', // EXPERIMENTAL: row-based layout with equal heights ]; -export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'essay']; export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig { if (!raw) return { ...THEME_PRESETS.studio }; diff --git a/lib/immich.ts b/lib/immich.ts index ede8717..b5a8c54 100644 --- a/lib/immich.ts +++ b/lib/immich.ts @@ -461,7 +461,12 @@ class ImmichClient { * Get enriched subpage summaries for the homepage. */ async getSubpages(forceFresh = false): Promise { - const activeSubpages = this.config.subpages.filter((sp) => sp.enabled !== false); + // hidden (EXPERIMENTAL) drops a subpage from every list this feeds (nav, + // homepage, hero) while getSubpageAlbums()/isValidSubpage() still resolve + // it — unlike enabled:false, which 404s the page entirely. + const activeSubpages = this.config.subpages.filter( + (sp) => sp.enabled !== false && sp.hidden !== true, + ); if (activeSubpages.length === 0) return []; const albums = await this.getAlbums(forceFresh);