From 63e6a2f784f57332153154a9fba3582a6379bee9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:30:45 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]=20Fi?= =?UTF-8?q?x=20missing=20rate=20limiting=20on=20/api/exif/[id]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com> --- .Jules/palette.md | 1 + .jules/sentinel.md | 5 + README.md | 16 +- app/[...path]/AlbumDetailView.tsx | 60 +++-- app/[...path]/SubpageGridView.tsx | 255 +++++++++--------- app/about/about.css | 2 +- app/api/exif/[id]/route.ts | 20 +- app/globals.css | 49 ++-- app/layout.tsx | 73 +++-- app/map/map.css | 188 ++++++------- app/themes/classic.css | 4 +- app/themes/editorial.css | 4 +- app/themes/minimal.css | 4 +- app/themes/monograph.css | 6 +- app/themes/noir.css | 4 +- app/tokens.css | 2 +- components/Lightbox.module.css | 7 +- docs/gallery-config.md | 71 ++--- docs/ideas.md | 18 +- docs/theming.md | 18 +- hooks/useExif.ts | 48 ++-- hooks/useSwipe.ts | 42 +-- lib/auth.ts | 8 +- lib/config/index.ts | 426 +++++++++++++++++------------- lib/config/parser.ts | 28 +- lib/config/schema.ts | 200 +++++++------- lib/config/theme.ts | 221 ++++++++-------- lib/env.ts | 6 +- lib/immich.ts | 2 - lib/mapService.ts | 132 ++++----- 30 files changed, 1046 insertions(+), 874 deletions(-) create mode 100644 .jules/sentinel.md diff --git a/.Jules/palette.md b/.Jules/palette.md index 1cef59a..2680a9d 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -1,3 +1,4 @@ ## 2024-03-XX - Lightbox Accessibility + **Learning:** Found that Lightbox overlay has good keyboard navigation (esc/left/right) but lacked tooltip hints for those controls and lacked a shortcut for EXIF data toggle. **Action:** Added `i` shortcut and `title` attributes on interactive elements in Lightbox. diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..c2fc2de --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,5 @@ +## 2024-03-12 - [Missing Rate Limiting on EXIF Endpoint] + +**Vulnerability:** The `/api/exif/[id]` endpoint lacked rate limiting, making it vulnerable to DoS attacks. An attacker could rapidly request EXIF metadata, exhausting server resources and the Immich API rate limits. +**Learning:** Even simple GET endpoints that fetch metadata from an upstream server (like Immich) must be protected with rate limiting to prevent downstream DoS and resource exhaustion, especially when they perform backend API requests per hit. +**Prevention:** Always implement rate limiting on endpoints that proxy requests or query a backend API, utilizing the shared `checkRateLimit` utility. diff --git a/README.md b/README.md index 7b2f5b5..63d7a13 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,14 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
-| Concern | Protection | -| --- | --- | -| **Server exposure** | Immich URL never leaves your network — all requests proxy server-side | -| **API key** | Stored only in `.env.local`, never in client code | -| **Asset IDs** | Immich UUIDs encrypted (AES-256) into opaque tokens | -| **Album scope** | Only albums in `gallery.yaml` are accessible | -| **Password protection** | Per-subpage password support | -| **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) | +| Concern | Protection | +| ----------------------- | --------------------------------------------------------------------- | +| **Server exposure** | Immich URL never leaves your network — all requests proxy server-side | +| **API key** | Stored only in `.env.local`, never in client code | +| **Asset IDs** | Immich UUIDs encrypted (AES-256) into opaque tokens | +| **Album scope** | Only albums in `gallery.yaml` are accessible | +| **Password protection** | Per-subpage password support | +| **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) | - Health check endpoint at `GET /api/health` - In-memory caching with configurable TTL diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx index b9faacb..fc34ab2 100644 --- a/app/[...path]/AlbumDetailView.tsx +++ b/app/[...path]/AlbumDetailView.tsx @@ -6,36 +6,40 @@ import type { GridConfig } from '@/lib/config'; import React from 'react'; interface AlbumDetailViewProps { - album: ImmichAlbum; - images: PhotoItem[]; - layout: GridConfig['layout']; - gridStyle: React.CSSProperties; - backLinkHref: string; - backLinkLabel: string; - subtitle?: string; + album: ImmichAlbum; + images: PhotoItem[]; + layout: GridConfig['layout']; + gridStyle: React.CSSProperties; + backLinkHref: string; + backLinkLabel: string; + subtitle?: string; } export function AlbumDetailView({ - album, - images, - layout, - gridStyle, - backLinkHref, - backLinkLabel, - subtitle, + album, + images, + layout, + gridStyle, + backLinkHref, + backLinkLabel, + subtitle, }: AlbumDetailViewProps) { - return ( - <> -
- -

{album.albumName}

- {subtitle &&

{subtitle}

} -

- {images.length} {images.length === 1 ? 'photo' : 'photos'} - {album.description && ` · ${album.description}`} -

-
- - - ); + return ( + <> +
+ +

{album.albumName}

+ {subtitle && ( +

+ {subtitle} +

+ )} +

+ {images.length} {images.length === 1 ? 'photo' : 'photos'} + {album.description && ` · ${album.description}`} +

+
+ + + ); } diff --git a/app/[...path]/SubpageGridView.tsx b/app/[...path]/SubpageGridView.tsx index 4ad4ecf..7f9b737 100644 --- a/app/[...path]/SubpageGridView.tsx +++ b/app/[...path]/SubpageGridView.tsx @@ -4,143 +4,146 @@ import { imageUrl } from '@/lib/urls'; import { SubpageSectionConfig } from '@/lib/config'; interface SubpageAlbum { - id: string; - slug: string; - albumName: string; - assetCount: number; - albumThumbnailAssetId: string | null; + id: string; + slug: string; + albumName: string; + assetCount: number; + albumThumbnailAssetId: string | null; } interface Placeholder { - blurDataURL?: string; - dominantColor?: string; + blurDataURL?: string; + dominantColor?: string; } interface SubpageGridViewProps { - slug: string; - title?: string; - subtitle?: string; - albums: SubpageAlbum[]; - coverPlaceholders: (Placeholder | null)[]; - sections?: SubpageSectionConfig[]; + slug: string; + title?: string; + subtitle?: string; + albums: SubpageAlbum[]; + coverPlaceholders: (Placeholder | null)[]; + sections?: SubpageSectionConfig[]; } function AlbumGrid({ - albums, - albumMap, - placeholderMap, - slug, + albums, + albumMap, + placeholderMap, + slug, }: { - albums: SubpageAlbum[]; - albumMap: Map; - placeholderMap: Map; - slug: string; + 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) && ( -
- {title &&

{title}

} - {subtitle &&

{subtitle}

} -
- )} - - {/* Typographic Table of Contents */} - {hasSections && ( - - )} - - {/* 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}

- )} -
- -
- ); - }) + return ( +
+ {albums.map((album) => { + const ph = placeholderMap.get(album.id) ?? null; + return ( + + {album.albumThumbnailAssetId ? ( + {album.albumName} ) : ( - /* Flat layout (no sections) */ - +
)} -
- ); + + {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) && ( +
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+ )} + + {/* Typographic Table of Contents */} + {hasSections && ( + + )} + + {/* 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/about/about.css b/app/about/about.css index dd6be6e..a33d815 100644 --- a/app/about/about.css +++ b/app/about/about.css @@ -119,4 +119,4 @@ .about__name { font-size: 1.8rem; } -} \ No newline at end of file +} diff --git a/app/api/exif/[id]/route.ts b/app/api/exif/[id]/route.ts index 87524ac..0b6d118 100644 --- a/app/api/exif/[id]/route.ts +++ b/app/api/exif/[id]/route.ts @@ -5,12 +5,13 @@ * The :token is an encoded asset ID (not a raw UUID). */ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { immich } from '@/lib/immich'; import { decodeAssetId } from '@/lib/tokens'; import { getConfig } from '@/lib/config'; +import { checkRateLimit } from '@/lib/rate-limit'; -export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { +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 @@ -18,6 +19,21 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'EXIF metadata is disabled' }, { status: 403 }); } + // ── Rate limiting ────────────────────────────────── + // Security: Prioritize x-real-ip (set by trusted reverse proxies like nginx/Traefik) + // over x-forwarded-for which can be spoofed by clients. + const ip = + request.headers.get('x-real-ip') ?? + request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? + 'unknown'; + const { success, resetAt } = checkRateLimit(`exif:${ip}`, config.rateLimitRpm); + + if (!success) { + const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); + console.warn(`[EXIF API] ⚠️ Rate limit exceeded for IP: ${ip}. Retry after ${retryAfter}s`); + return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + } + const { id: token } = await params; // Decode the opaque token back to an Immich asset ID diff --git a/app/globals.css b/app/globals.css index ee86948..e2d5b1e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -197,8 +197,8 @@ } /* HeroCarousel images fill the entire container */ -.hero--fullbleed>span, -.hero--fullbleed>img { +.hero--fullbleed > span, +.hero--fullbleed > img { position: absolute !important; inset: 0; width: 100% !important; @@ -218,14 +218,21 @@ padding: 64px 40px; /* Multi-stop for contrast on any image brightness */ background: - radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0.0) 70%), - linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.15) 40%, rgba(0, 0, 0, 0.45) 100%); + radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 70%), + linear-gradient( + to bottom, + rgba(0, 0, 0, 0.4) 0%, + rgba(0, 0, 0, 0.15) 40%, + rgba(0, 0, 0, 0.45) 100% + ); } .hero--fullbleed .hero__title { color: #fff; font-size: 4rem; - text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7), 0 8px 48px rgba(0, 0, 0, 0.6); + text-shadow: + 0 2px 8px rgba(0, 0, 0, 0.7), + 0 8px 48px rgba(0, 0, 0, 0.6); } .hero--fullbleed .hero__subtitle { @@ -299,7 +306,12 @@ right: 0; z-index: 2; padding: 48px 40px; - background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.3) 50%, transparent 100%); + background: linear-gradient( + to top, + rgba(0, 0, 0, 0.8) 0%, + rgba(0, 0, 0, 0.3) 50%, + transparent 100% + ); } .hero--stacked .hero__title { @@ -589,7 +601,7 @@ align-items: center; } -.subpage-toc__entry+.subpage-toc__entry::before { +.subpage-toc__entry + .subpage-toc__entry::before { content: '·'; padding: 0 12px; color: var(--text-muted); @@ -658,7 +670,6 @@ width: 100%; } - .subpage-grid { display: grid; grid-template-columns: repeat(2, 1fr); @@ -763,10 +774,12 @@ flex-direction: column; justify-content: flex-end; padding: 24px; - background: linear-gradient(to top, - rgba(0, 0, 0, 0.8) 0%, - rgba(0, 0, 0, 0.3) 40%, - transparent 100%); + background: linear-gradient( + to top, + rgba(0, 0, 0, 0.8) 0%, + rgba(0, 0, 0, 0.3) 40%, + transparent 100% + ); opacity: 0; transition: opacity var(--transition-normal); } @@ -981,10 +994,12 @@ /* ── Loading States ───────────────────────────────── */ .skeleton { - background: linear-gradient(90deg, - var(--bg-card) 0%, - var(--bg-card-hover) 50%, - var(--bg-card) 100%); + background: linear-gradient( + 90deg, + var(--bg-card) 0%, + var(--bg-card-hover) 50%, + var(--bg-card) 100% + ); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite; border-radius: var(--radius-sm); @@ -1513,4 +1528,4 @@ [data-preset='monograph'] .album-header__title { font-size: 2rem; } -} \ No newline at end of file +} diff --git a/app/layout.tsx b/app/layout.tsx index ebd463a..9db1f45 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -65,28 +65,71 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( -
+
-

+

Setup Required

-

- Immich Folio is running, but it looks like your configuration files or environment variables are missing. +

+ Immich Folio is running, but it looks like your configuration files or environment + variables are missing.

-
-

To get started, follow these steps in your repository or mounted content/ volume:

+
+

+ To get started, follow these steps in your repository or mounted{' '} + content/ volume: +

    -
  1. Copy .env.example to .env.local and fill in your Immich API URL and Key
  2. -
  3. Copy settings.yaml.example to settings.yaml
  4. -
  5. Copy gallery.yaml.example to gallery.yaml
  6. -
  7. Copy about.md.example to about.md (optional)
  8. +
  9. + Copy .env.example to .env.local and fill in your + Immich API URL and Key +
  10. +
  11. + Copy settings.yaml.example to settings.yaml +
  12. +
  13. + Copy gallery.yaml.example to gallery.yaml +
  14. +
  15. + Copy about.md.example to about.md (optional) +

- Open gallery.yaml and add the album IDs you want to display, then restart your server. + Open gallery.yaml and add the album IDs you want to display, then{' '} + restart your server.

diff --git a/app/map/map.css b/app/map/map.css index 5875b27..36b3801 100644 --- a/app/map/map.css +++ b/app/map/map.css @@ -1,174 +1,174 @@ /* ── Map Page ──────────────────────────────────────────────── */ .map-page { - display: flex; - flex-direction: column; - min-height: calc(100vh - var(--header-height, 64px)); + display: flex; + flex-direction: column; + min-height: calc(100vh - var(--header-height, 64px)); } .map-page__header { - padding: 2rem var(--page-padding, 2rem) 1rem; + padding: 2rem var(--page-padding, 2rem) 1rem; } .map-page__title { - font-family: var(--font-heading); - font-size: clamp(1.5rem, 4vw, 2.5rem); - font-weight: 600; - margin: 0.5rem 0 0.25rem; - color: var(--text-primary); + font-family: var(--font-heading); + font-size: clamp(1.5rem, 4vw, 2.5rem); + font-weight: 600; + margin: 0.5rem 0 0.25rem; + color: var(--text-primary); } .map-page__subtitle { - font-family: var(--font-body); - font-size: 0.875rem; - color: var(--text-secondary); - margin: 0; + font-family: var(--font-body); + font-size: 0.875rem; + color: var(--text-secondary); + margin: 0; } /* ── Map Container ─────────────────────────────────────────── */ .map-container { - flex: 1; - min-height: 500px; - margin: 0 var(--page-padding, 2rem) 2rem; - border-radius: var(--radius, 0); - overflow: hidden; - border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)); + flex: 1; + min-height: 500px; + margin: 0 var(--page-padding, 2rem) 2rem; + border-radius: var(--radius, 0); + overflow: hidden; + border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)); } .map-container__loading { - display: flex; - align-items: center; - justify-content: center; - height: 500px; - color: var(--text-secondary); - font-family: var(--font-body); - font-size: 0.875rem; + display: flex; + align-items: center; + justify-content: center; + height: 500px; + color: var(--text-secondary); + font-family: var(--font-body); + font-size: 0.875rem; } /* ── Map Popups ────────────────────────────────────────────── */ .leaflet-popup-content-wrapper { - background: var(--bg-primary, #1a1a1a) !important; - color: var(--text-primary, #fff) !important; - border-radius: var(--radius, 4px) !important; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important; - border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important; - padding: 0 !important; - overflow: hidden; + background: var(--bg-primary, #1a1a1a) !important; + color: var(--text-primary, #fff) !important; + border-radius: var(--radius, 4px) !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important; + border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important; + padding: 0 !important; + overflow: hidden; } .leaflet-popup-tip { - background: var(--bg-primary, #1a1a1a) !important; - border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important; - border-top: none !important; - border-left: none !important; + background: var(--bg-primary, #1a1a1a) !important; + border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important; + border-top: none !important; + border-left: none !important; } .leaflet-popup-content { - margin: 0 !important; - min-width: 200px; + margin: 0 !important; + min-width: 200px; } .map-popup { - font-family: var(--font-body); + font-family: var(--font-body); } .map-popup__cover { - width: 100%; - height: 120px; - object-fit: cover; - display: block; + width: 100%; + height: 120px; + object-fit: cover; + display: block; } .map-popup__body { - padding: 0.75rem 1rem; + padding: 0.75rem 1rem; } .map-popup__city { - font-family: var(--font-heading); - font-size: 0.9375rem; - font-weight: 600; - margin: 0 0 0.125rem; - color: var(--text-primary); + font-family: var(--font-heading); + font-size: 0.9375rem; + font-weight: 600; + margin: 0 0 0.125rem; + color: var(--text-primary); } .map-popup__country { - font-family: var(--font-caption); - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-secondary); - margin: 0 0 0.5rem; + font-family: var(--font-caption); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); + margin: 0 0 0.5rem; } .map-popup__count { - font-family: var(--font-caption); - font-size: 0.75rem; - color: var(--text-secondary); - margin-bottom: 0.5rem; + font-family: var(--font-caption); + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; } .map-popup__albums { - display: flex; - flex-direction: column; - gap: 0.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; } .map-popup__album-link { - font-size: 0.8125rem; - color: var(--accent, #e60012); - text-decoration: none; - transition: opacity 0.15s; + font-size: 0.8125rem; + color: var(--accent, #e60012); + text-decoration: none; + transition: opacity 0.15s; } .map-popup__album-link:hover { - opacity: 0.7; + opacity: 0.7; } /* ── Custom Marker ─────────────────────────────────────────── */ .map-marker { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 50%; - background: var(--accent, #e60012); - color: #fff; - font-family: var(--font-caption); - font-size: 0.6875rem; - font-weight: 700; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - border: 2px solid rgba(255, 255, 255, 0.9); - cursor: pointer; - transition: transform 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent, #e60012); + color: #fff; + font-family: var(--font-caption); + font-size: 0.6875rem; + font-weight: 700; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + border: 2px solid rgba(255, 255, 255, 0.9); + cursor: pointer; + transition: transform 0.15s ease; } .map-marker:hover { - transform: scale(1.15); + transform: scale(1.15); } /* ── Leaflet overrides ─────────────────────────────────────── */ .leaflet-control-attribution { - background: var(--bg-primary, #1a1a1a) !important; - color: var(--text-secondary, #888) !important; - font-size: 0.625rem !important; - opacity: 0.7; + background: var(--bg-primary, #1a1a1a) !important; + color: var(--text-secondary, #888) !important; + font-size: 0.625rem !important; + opacity: 0.7; } .leaflet-control-attribution a { - color: var(--text-secondary, #888) !important; + color: var(--text-secondary, #888) !important; } .leaflet-control-zoom a { - background: var(--bg-primary, #1a1a1a) !important; - color: var(--text-primary, #fff) !important; - border-color: var(--border-color, rgba(128, 128, 128, 0.15)) !important; + background: var(--bg-primary, #1a1a1a) !important; + color: var(--text-primary, #fff) !important; + border-color: var(--border-color, rgba(128, 128, 128, 0.15)) !important; } .leaflet-control-zoom a:hover { - background: var(--bg-secondary, #222) !important; -} \ No newline at end of file + background: var(--bg-secondary, #222) !important; +} diff --git a/app/themes/classic.css b/app/themes/classic.css index 386509c..0ffa6f0 100644 --- a/app/themes/classic.css +++ b/app/themes/classic.css @@ -198,7 +198,7 @@ justify-content: center; } -[data-preset='classic'] .subpage-toc__entry+.subpage-toc__entry::before { +[data-preset='classic'] .subpage-toc__entry + .subpage-toc__entry::before { content: '◆'; padding: 0 14px; font-size: 0.45rem; @@ -260,4 +260,4 @@ 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 0d40dac..aa8a4a2 100644 --- a/app/themes/editorial.css +++ b/app/themes/editorial.css @@ -181,7 +181,7 @@ margin-bottom: 64px; } -[data-preset='editorial'] .subpage-toc__entry+.subpage-toc__entry::before { +[data-preset='editorial'] .subpage-toc__entry + .subpage-toc__entry::before { content: '/'; padding: 0 12px; font-family: var(--font-caption); @@ -234,4 +234,4 @@ 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 16ee321..85ca08d 100644 --- a/app/themes/minimal.css +++ b/app/themes/minimal.css @@ -198,7 +198,7 @@ margin-bottom: 48px; } -[data-preset='minimal'] .subpage-toc__entry+.subpage-toc__entry::before { +[data-preset='minimal'] .subpage-toc__entry + .subpage-toc__entry::before { content: '—'; padding: 0 10px; font-size: 0.6rem; @@ -242,4 +242,4 @@ [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 b1a7eb5..3b38de9 100644 --- a/app/themes/monograph.css +++ b/app/themes/monograph.css @@ -164,7 +164,7 @@ } /* Main: extra generous margins */ -[data-preset='monograph']~body .main { +[data-preset='monograph'] ~ body .main { padding: 80px 60px 120px; } @@ -203,7 +203,7 @@ border-bottom: none; } -[data-preset='monograph'] .subpage-toc__entry+.subpage-toc__entry::before { +[data-preset='monograph'] .subpage-toc__entry + .subpage-toc__entry::before { display: none; } @@ -395,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 66ce7b7..8ebe27a 100644 --- a/app/themes/noir.css +++ b/app/themes/noir.css @@ -111,7 +111,7 @@ margin-bottom: 56px; } -[data-preset='noir'] .subpage-toc__entry+.subpage-toc__entry::before { +[data-preset='noir'] .subpage-toc__entry + .subpage-toc__entry::before { content: '|'; padding: 0 12px; font-family: var(--font-caption); @@ -161,4 +161,4 @@ background: linear-gradient(to right, var(--accent), transparent); opacity: 0.35; height: 1px; -} \ No newline at end of file +} diff --git a/app/tokens.css b/app/tokens.css index 2f889b2..b57ebb8 100644 --- a/app/tokens.css +++ b/app/tokens.css @@ -251,4 +251,4 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; -} \ No newline at end of file +} diff --git a/components/Lightbox.module.css b/components/Lightbox.module.css index 94bf362..7af929a 100644 --- a/components/Lightbox.module.css +++ b/components/Lightbox.module.css @@ -55,7 +55,9 @@ -webkit-user-drag: none; opacity: 0; transform: scale(0.96); - transition: opacity var(--transition-normal), transform 600ms cubic-bezier(0.16, 1, 0.3, 1); + transition: + opacity var(--transition-normal), + transform 600ms cubic-bezier(0.16, 1, 0.3, 1); } .imageLoaded { @@ -251,7 +253,6 @@ } @media (hover: none) { - .close, .nav, .counter { @@ -265,4 +266,4 @@ .image { border-radius: 0; -} \ No newline at end of file +} diff --git a/docs/gallery-config.md b/docs/gallery-config.md index d88be2a..260e748 100644 --- a/docs/gallery-config.md +++ b/docs/gallery-config.md @@ -7,6 +7,7 @@ cp content/gallery.yaml.example content/gallery.yaml ``` **Contents:** + - [Hero Images](#hero-images) - [Standalone Albums](#standalone-albums) - [Subpages & Categories](#subpages) @@ -33,7 +34,8 @@ hero: ## Standalone Albums -Albums shown directly on the homepage as cards. +Albums shown directly on the homepage as cards. + > [!NOTE] > The thumbnail image for each album grid card is automatically synced with the explicit **\"Cover Image\"** you select for that album inside the Immich Web UI. @@ -50,15 +52,15 @@ The overall **Subpage Cover Image** shown on the homepage is automatically inher ```yaml subpages: - - name: Japan # → /japan - title: "Trip to Japan" # Optional: overrides the page heading - subtitle: "2024 adventures" # Optional: adds a subline under the heading + - name: Japan # → /japan + title: 'Trip to Japan' # Optional: overrides the page heading + subtitle: '2024 adventures' # Optional: adds a subline under the heading albums: - - 33333333-3333-3333-3333-333333333333 # Tokyo - - 44444444-4444-4444-4444-444444444444 # Kyoto + - 33333333-3333-3333-3333-333333333333 # Tokyo + - 44444444-4444-4444-4444-444444444444 # Kyoto - - name: Wedding – Smith # → /wedding-smith - password: clientpass123 # Optional: password-protected + - name: Wedding – Smith # → /wedding-smith + password: clientpass123 # Optional: password-protected albums: - 55555555-5555-5555-5555-555555555555 ``` @@ -67,9 +69,9 @@ Alternatively, you can use the object notation (recommended): ```yaml subpages: - "Japan": - title: "Trip to Japan" - subtitle: "2024 adventures" + 'Japan': + title: 'Trip to Japan' + subtitle: '2024 adventures' albums: - 33333333-3333-3333-3333-333333333333 ``` @@ -80,18 +82,18 @@ When a subpage contains many albums you can split them into **named sections**. 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) | +| 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 + - 'album-uuid' # → uses the Immich album name + - 'album-uuid': My Title # → displays "My Title" instead ``` **Full example:** @@ -100,29 +102,28 @@ albums: subpages: - name: Japan title: Japan - subtitle: "Reisen durch ein Land voller Kontraste." + subtitle: 'Reisen durch ein Land voller Kontraste.' sections: - title: Tokyo - description: "Megacity, Neonlichter, Stille im Lärm." + description: 'Megacity, Neonlichter, Stille im Lärm.' albums: - - "33333333-3333-3333-3333-333333333333" - - "44444444-4444-4444-4444-444444444444": Shinjuku bei Nacht + - '33333333-3333-3333-3333-333333333333' + - '44444444-4444-4444-4444-444444444444': Shinjuku bei Nacht - title: Kyoto - description: "Tempel und Bambuswälder." + description: 'Tempel und Bambuswälder.' albums: - - "55555555-5555-5555-5555-555555555555": Fushimi Inari - - "66666666-6666-6666-6666-666666666666" + - '55555555-5555-5555-5555-555555555555': Fushimi Inari + - '66666666-6666-6666-6666-666666666666' - title: Osaka - albums: # description is optional - - "77777777-7777-7777-7777-777777777777" + 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: @@ -130,17 +131,17 @@ Configure the photo grid globally or per-subpage: ```yaml # Global defaults grid: - columns: 3 # number of columns (default: 3) - gap: 12 # gap in pixels (default: 12) - aspectRatio: "1" # "1", "3/2", "auto", etc. (default: "1") - layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow" + columns: 3 # number of columns (default: 3) + gap: 12 # gap in pixels (default: 12) + aspectRatio: '1' # "1", "3/2", "auto", etc. (default: "1") + layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow" subpages: - name: Japan - grid: # per-subpage override + grid: # per-subpage override columns: 4 layout: uniform - aspectRatio: "3/2" + aspectRatio: '3/2' albums: - ... ``` @@ -150,7 +151,7 @@ subpages: Show camera/lens/settings when hovering over photos (enabled by default): ```yaml -exifOnHover: false # set to false to disable +exifOnHover: false # set to false to disable ``` ## Footer diff --git a/docs/ideas.md b/docs/ideas.md index 20f367f..3070883 100644 --- a/docs/ideas.md +++ b/docs/ideas.md @@ -4,14 +4,14 @@ Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, berei ## 🏆 Quick Wins (High Impact / Low Effort) -| Idea | Effort | Impact | -|------|--------|--------| -| **Slideshow mode** | Low | High — exhibition ready (Auto-advance 3s/5s/10s) | -| **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) | -| **Download originals** | Low | High — client delivery (Button in lightbox) | -| **Photo of the Day** | Low | Medium — return visits (Auto-rotate hero image) | -| **Keyboard shortcut hints** | Low | Medium — `?` button for shortcuts (arrows, Esc, F) | -| **Photo sharing** | Low | Medium — "Copy link" button in lightbox | +| Idea | Effort | Impact | +| ------------------------------ | ------ | -------------------------------------------------- | +| **Slideshow mode** | Low | High — exhibition ready (Auto-advance 3s/5s/10s) | +| **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) | +| **Download originals** | Low | High — client delivery (Button in lightbox) | +| **Photo of the Day** | Low | Medium — return visits (Auto-rotate hero image) | +| **Keyboard shortcut hints** | Low | Medium — `?` button for shortcuts (arrows, Esc, F) | +| **Photo sharing** | Low | Medium — "Copy link" button in lightbox | --- @@ -29,7 +29,7 @@ Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, berei ## 📖 Deep Dive Feature Plan: Photo Stories / Essays -*Vertical scroll format with images + text interspersed, like a photo essay. A major portfolio differentiator.* +_Vertical scroll format with images + text interspersed, like a photo essay. A major portfolio differentiator._ ### Proposed Architecture diff --git a/docs/theming.md b/docs/theming.md index a6fcca2..89ab4d5 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -14,18 +14,19 @@ That's it. Your entire gallery switches to the minimal theme. ## Built-in Presets -| Preset | Style | Accent | Fonts | Hero | Frame | Grain | -| ------------- | ------------------------------ | ------------ | ---------------------------------- | ----------- | ------------ | ----- | -| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ | -| **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ | -| **editorial** | Cinematic magazine, warm tones | 🟤 `#8B2500` | Bodoni Moda + Newsreader | Split | Shadow | ✗ | -| **classic** | Gilded gallery, warm luxury | 🟡 `#c49a3c` | Cinzel + Crimson Pro | Minimal | Passepartout | ✗ | -| **noir** | Darkroom analog, film noir | 🟠 `#ff6b35` | Libre Baskerville + Source Sans 3 | Fullbleed | Passepartout | ✓ | -| **monograph** | Typographic, book-like | ⬛ `#333333` | Instrument Serif + Inter | Typographic | None | ✗ | +| Preset | Style | Accent | Fonts | Hero | Frame | Grain | +| ------------- | ------------------------------ | ------------ | --------------------------------- | ----------- | ------------ | ----- | +| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ | +| **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ | +| **editorial** | Cinematic magazine, warm tones | 🟤 `#8B2500` | Bodoni Moda + Newsreader | Split | Shadow | ✗ | +| **classic** | Gilded gallery, warm luxury | 🟡 `#c49a3c` | Cinzel + Crimson Pro | Minimal | Passepartout | ✗ | +| **noir** | Darkroom analog, film noir | 🟠 `#ff6b35` | Libre Baskerville + Source Sans 3 | Fullbleed | Passepartout | ✓ | +| **monograph** | Typographic, book-like | ⬛ `#333333` | Instrument Serif + Inter | Typographic | None | ✗ | Default is `studio` if no theme is specified. ### Theme Gallery + _From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_

@@ -38,6 +39,7 @@ _From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_

### Grid View Examples +

Studio grid Minimal grid diff --git a/hooks/useExif.ts b/hooks/useExif.ts index 6319a39..dca9bb3 100644 --- a/hooks/useExif.ts +++ b/hooks/useExif.ts @@ -1,34 +1,34 @@ import { useState, useCallback } from 'react'; export interface ExifData { - make?: string | null; - model?: string | null; - lensModel?: string | null; - focalLength?: number | null; - fNumber?: number | null; - exposureTime?: string | null; - iso?: number | null; - city?: string | null; - country?: string | null; + make?: string | null; + model?: string | null; + lensModel?: string | null; + focalLength?: number | null; + fNumber?: number | null; + exposureTime?: string | null; + iso?: number | null; + city?: string | null; + country?: string | null; } export function useExif() { - const [exifData, setExifData] = useState(null); - const [exifLoading, setExifLoading] = useState(false); + const [exifData, setExifData] = useState(null); + const [exifLoading, setExifLoading] = useState(false); - const fetchExif = useCallback((url: string) => { - setExifLoading(true); - setExifData(null); - fetch(url) - .then((res) => (res.ok ? res.json() : null)) - .then((data) => setExifData(data)) - .catch(() => setExifData(null)) - .finally(() => setExifLoading(false)); - }, []); + const fetchExif = useCallback((url: string) => { + setExifLoading(true); + setExifData(null); + fetch(url) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setExifData(data)) + .catch(() => setExifData(null)) + .finally(() => setExifLoading(false)); + }, []); - const clearExif = useCallback(() => { - setExifData(null); - }, []); + const clearExif = useCallback(() => { + setExifData(null); + }, []); - return { exifData, exifLoading, fetchExif, clearExif }; + return { exifData, exifLoading, fetchExif, clearExif }; } diff --git a/hooks/useSwipe.ts b/hooks/useSwipe.ts index 2f06da6..60061f4 100644 --- a/hooks/useSwipe.ts +++ b/hooks/useSwipe.ts @@ -1,32 +1,32 @@ import { useRef, useCallback } from 'react'; interface UseSwipeProps { - onSwipeLeft: () => void; - onSwipeRight: () => void; - threshold?: number; + onSwipeLeft: () => void; + onSwipeRight: () => void; + threshold?: number; } export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 60 }: UseSwipeProps) { - const touchStartX = useRef(0); - const touchStartY = useRef(0); + const touchStartX = useRef(0); + const touchStartY = useRef(0); - const handleTouchStart = useCallback((e: React.TouchEvent | TouchEvent) => { - touchStartX.current = e.touches[0].clientX; - touchStartY.current = e.touches[0].clientY; - }, []); + const handleTouchStart = useCallback((e: React.TouchEvent | TouchEvent) => { + touchStartX.current = e.touches[0].clientX; + touchStartY.current = e.touches[0].clientY; + }, []); - const handleTouchEnd = useCallback( - (e: React.TouchEvent | TouchEvent) => { - const dx = e.changedTouches[0].clientX - touchStartX.current; - const dy = e.changedTouches[0].clientY - touchStartY.current; + const handleTouchEnd = useCallback( + (e: React.TouchEvent | TouchEvent) => { + const dx = e.changedTouches[0].clientX - touchStartX.current; + const dy = e.changedTouches[0].clientY - touchStartY.current; - if (Math.abs(dx) > threshold && Math.abs(dx) > Math.abs(dy)) { - if (dx > 0) onSwipeRight(); - else onSwipeLeft(); - } - }, - [onSwipeLeft, onSwipeRight, threshold], - ); + if (Math.abs(dx) > threshold && Math.abs(dx) > Math.abs(dy)) { + if (dx > 0) onSwipeRight(); + else onSwipeLeft(); + } + }, + [onSwipeLeft, onSwipeRight, threshold], + ); - return { handleTouchStart, handleTouchEnd }; + return { handleTouchStart, handleTouchEnd }; } diff --git a/lib/auth.ts b/lib/auth.ts index c01307f..3f02903 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -85,9 +85,9 @@ export function authenticate(slug: string, password: string): string | null { if (isBcryptHash(sp.password)) { console.error( `\n❌ SECURITY ERROR: Subpage "${slug}" is using an outdated bcrypt password hash.\n` + - ` Bcrypt dependency has been removed to reduce bundle size.\n` + - ` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` + - ` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`, + ` Bcrypt dependency has been removed to reduce bundle size.\n` + + ` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` + + ` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`, ); return null; } @@ -101,7 +101,7 @@ export function authenticate(slug: string, password: string): string | null { const recommendedHash = generateScryptHash(sp.password); console.warn( `\n⚠️ SECURITY WARNING: Subpage "${slug}" is using a plaintext password in gallery.yaml.\n` + - ` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`, + ` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`, ); } } diff --git a/lib/config/index.ts b/lib/config/index.ts index d6dee36..0606645 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -1,195 +1,265 @@ import { env } from '../env'; import { loadYaml, validateUuid } from './parser'; import { resolveTheme, VALID_LAYOUTS } from './theme'; -import { slugify, AppConfig, SubpageConfig, SubpageSectionConfig, GalleryYaml, SettingsYaml, GridConfig } from './schema'; +import { + slugify, + AppConfig, + SubpageConfig, + SubpageSectionConfig, + GalleryYaml, + SettingsYaml, + GridConfig, +} from './schema'; export * from './schema'; export * from './theme'; let _config: AppConfig | null = null; export function getConfig(): AppConfig { - const isDev = process.env.NODE_ENV !== 'production'; - if (_config && !isDev) return _config; + const isDev = process.env.NODE_ENV !== 'production'; + if (_config && !isDev) return _config; - const apiUrl = env.IMMICH_API_URL; - const apiKey = env.IMMICH_API_KEY; - const authSecret = env.AUTH_SECRET || apiKey; + const apiUrl = env.IMMICH_API_URL; + const apiKey = env.IMMICH_API_KEY; + const authSecret = env.AUTH_SECRET || apiKey; - if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') { - console.warn('\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n Please set a long random string as AUTH_SECRET in your .env for better security.\n'); - } + if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') { + console.warn( + '\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n Please set a long random string as AUTH_SECRET in your .env for better security.\n', + ); + } - const gallery = loadYaml('gallery.yaml'); - const settings = loadYaml('settings.yaml') || {}; - - if (!gallery || !apiKey || !apiUrl) { - // Return dummy config if gallery.yaml is missing - _config = { - immich: { apiUrl: `${apiUrl}/api`, apiKey }, - authSecret, - albums: [], - standaloneAlbums: [], - subpages: [], - siteTitle: env.SITE_TITLE || 'Immich Folio', - siteSubtitle: env.SITE_SUBTITLE || 'Setup Required', - seo: { title: 'Setup Required', description: 'Please configure Immich Folio', noIndex: true, noFollow: true }, - heroImages: [], - exifOnHover: true, - grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' }, - theme: resolveTheme('studio'), - footer: null, - legal: { enabled: false, name: '', address: '', zipCity: '', country: '' }, - map: false, - transitions: false, - albumOverrides: {}, - cacheTtl: env.CACHE_TTL * 1000, - rateLimitRpm: env.RATE_LIMIT_RPM, - needsSetup: true - }; - return _config; - } - - const theme = resolveTheme(settings.theme); - - const albumOverrides: Record = {}; - - function processAlbumEntry(entry: string | Record, context: string): string { - if (typeof entry === 'string') { - return validateUuid(entry, context); - } - const [uuid, name] = Object.entries(entry)[0]; - const validatedUuid = validateUuid(uuid, context); - albumOverrides[validatedUuid] = name; - return validatedUuid; - } - - const standaloneAlbumIds = (gallery.albums ?? []).map((entry) => processAlbumEntry(entry, 'gallery.yaml albums')); - if (standaloneAlbumIds.length === 0 && (!gallery.subpages || (Array.isArray(gallery.subpages) ? gallery.subpages.length === 0 : Object.keys(gallery.subpages).length === 0))) { - throw new Error('gallery.yaml must define at least one album or subpage'); - } - - let subpages: SubpageConfig[] = []; - - if (Array.isArray(gallery.subpages)) { - subpages = gallery.subpages.map((sp) => { - 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, - sections, - password: sp.password, - ...(sp.grid ? { - grid: { - ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), - ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), - ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), - ...(sp.grid.layout != null ? { layout: (VALID_LAYOUTS.includes(sp.grid.layout) ? sp.grid.layout : 'masonry') as GridConfig['layout'] } : {}), - }, - } : {}), - }; - }); - } else if (gallery.subpages) { - subpages = Object.entries(gallery.subpages).map(([name, value]) => { - if (Array.isArray(value)) { - return { name, slug: slugify(name), albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)) }; - } - interface SubpageObjectValue { title?: string; subtitle?: string; albums?: Array>; password?: string; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string; }; } - const sp = value as SubpageObjectValue; - const albumEntries = sp.albums || []; - return { - name, - slug: slugify(name), - title: sp.title, - subtitle: sp.subtitle, - albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)), - password: sp.password, - ...(sp.grid ? { - grid: { - ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), - ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), - ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), - ...(sp.grid.layout != null ? { layout: (VALID_LAYOUTS.includes(sp.grid.layout) ? sp.grid.layout : 'masonry') as GridConfig['layout'] } : {}), - }, - } : {}), - }; - }); - } - - const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds)); - const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])]; + const gallery = loadYaml('gallery.yaml'); + const settings = loadYaml('settings.yaml') || {}; + if (!gallery || !apiKey || !apiUrl) { + // Return dummy config if gallery.yaml is missing _config = { - immich: { apiUrl: `${apiUrl}/api`, apiKey }, - authSecret, - albums: allAlbumIds, - standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)), - subpages, - siteTitle: settings.title ?? env.SITE_TITLE, - siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE, - seo: { - title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery', - description: settings.seo?.description || settings.subtitle || env.SITE_SUBTITLE || 'A curated photography portfolio', - noIndex: settings.seo?.noIndex === true, - noFollow: settings.seo?.noFollow === true, - }, - heroImages: gallery.hero ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => validateUuid(id, 'gallery.yaml hero')) : [], - exifOnHover: settings.exifOnHover !== false, - grid: { - columns: settings.grid?.columns ?? 3, - gap: settings.grid?.gap ?? 12, - aspectRatio: settings.grid?.aspectRatio ?? '1', - layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '') ? settings.grid!.layout : 'masonry') as GridConfig['layout'], - }, - theme, - footer: settings.footer ? { name: settings.footer.name, instagram: settings.footer.instagram, email: settings.footer.email, website: settings.footer.website } : null, - legal: { - enabled: settings.legal?.enabled === true, - name: settings.legal?.name || '', - address: settings.legal?.address || '', - zipCity: settings.legal?.zipCity || '', - country: settings.legal?.country || '', - email: settings.legal?.email, - phone: settings.legal?.phone, - taxId: settings.legal?.taxId, - vatId: settings.legal?.vatId, - extraInfo: settings.legal?.extraInfo, - }, - map: settings.map === true, - transitions: settings.transitions !== false, - albumOverrides, - cacheTtl: env.CACHE_TTL * 1000, - rateLimitRpm: env.RATE_LIMIT_RPM, - needsSetup: false + immich: { apiUrl: `${apiUrl}/api`, apiKey }, + authSecret, + albums: [], + standaloneAlbums: [], + subpages: [], + siteTitle: env.SITE_TITLE || 'Immich Folio', + siteSubtitle: env.SITE_SUBTITLE || 'Setup Required', + seo: { + title: 'Setup Required', + description: 'Please configure Immich Folio', + noIndex: true, + noFollow: true, + }, + heroImages: [], + exifOnHover: true, + grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' }, + theme: resolveTheme('studio'), + footer: null, + legal: { enabled: false, name: '', address: '', zipCity: '', country: '' }, + map: false, + transitions: false, + albumOverrides: {}, + cacheTtl: env.CACHE_TTL * 1000, + rateLimitRpm: env.RATE_LIMIT_RPM, + needsSetup: true, }; return _config; + } + + const theme = resolveTheme(settings.theme); + + const albumOverrides: Record = {}; + + function processAlbumEntry(entry: string | Record, context: string): string { + if (typeof entry === 'string') { + return validateUuid(entry, context); + } + const [uuid, name] = Object.entries(entry)[0]; + const validatedUuid = validateUuid(uuid, context); + albumOverrides[validatedUuid] = name; + return validatedUuid; + } + + const standaloneAlbumIds = (gallery.albums ?? []).map((entry) => + processAlbumEntry(entry, 'gallery.yaml albums'), + ); + if ( + standaloneAlbumIds.length === 0 && + (!gallery.subpages || + (Array.isArray(gallery.subpages) + ? gallery.subpages.length === 0 + : Object.keys(gallery.subpages).length === 0)) + ) { + throw new Error('gallery.yaml must define at least one album or subpage'); + } + + let subpages: SubpageConfig[] = []; + + if (Array.isArray(gallery.subpages)) { + subpages = gallery.subpages.map((sp) => { + 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, + sections, + password: sp.password, + ...(sp.grid + ? { + grid: { + ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), + ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), + ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), + ...(sp.grid.layout != null + ? { + layout: (VALID_LAYOUTS.includes(sp.grid.layout) + ? sp.grid.layout + : 'masonry') as GridConfig['layout'], + } + : {}), + }, + } + : {}), + }; + }); + } else if (gallery.subpages) { + subpages = Object.entries(gallery.subpages).map(([name, value]) => { + if (Array.isArray(value)) { + return { + name, + slug: slugify(name), + albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)), + }; + } + interface SubpageObjectValue { + title?: string; + subtitle?: string; + albums?: Array>; + password?: string; + grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }; + } + const sp = value as SubpageObjectValue; + const albumEntries = sp.albums || []; + return { + name, + slug: slugify(name), + title: sp.title, + subtitle: sp.subtitle, + albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)), + password: sp.password, + ...(sp.grid + ? { + grid: { + ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), + ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), + ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), + ...(sp.grid.layout != null + ? { + layout: (VALID_LAYOUTS.includes(sp.grid.layout) + ? sp.grid.layout + : 'masonry') as GridConfig['layout'], + } + : {}), + }, + } + : {}), + }; + }); + } + + const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds)); + const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])]; + + _config = { + immich: { apiUrl: `${apiUrl}/api`, apiKey }, + authSecret, + albums: allAlbumIds, + standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)), + subpages, + siteTitle: settings.title ?? env.SITE_TITLE, + siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE, + seo: { + title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery', + description: + settings.seo?.description || + settings.subtitle || + env.SITE_SUBTITLE || + 'A curated photography portfolio', + noIndex: settings.seo?.noIndex === true, + noFollow: settings.seo?.noFollow === true, + }, + heroImages: gallery.hero + ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => + validateUuid(id, 'gallery.yaml hero'), + ) + : [], + exifOnHover: settings.exifOnHover !== false, + grid: { + columns: settings.grid?.columns ?? 3, + gap: settings.grid?.gap ?? 12, + aspectRatio: settings.grid?.aspectRatio ?? '1', + layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '') + ? settings.grid!.layout + : 'masonry') as GridConfig['layout'], + }, + theme, + footer: settings.footer + ? { + name: settings.footer.name, + instagram: settings.footer.instagram, + email: settings.footer.email, + website: settings.footer.website, + } + : null, + legal: { + enabled: settings.legal?.enabled === true, + name: settings.legal?.name || '', + address: settings.legal?.address || '', + zipCity: settings.legal?.zipCity || '', + country: settings.legal?.country || '', + email: settings.legal?.email, + phone: settings.legal?.phone, + taxId: settings.legal?.taxId, + vatId: settings.legal?.vatId, + extraInfo: settings.legal?.extraInfo, + }, + map: settings.map === true, + transitions: settings.transitions !== false, + albumOverrides, + cacheTtl: env.CACHE_TTL * 1000, + rateLimitRpm: env.RATE_LIMIT_RPM, + needsSetup: false, + }; + return _config; } diff --git a/lib/config/parser.ts b/lib/config/parser.ts index 4bdec1a..2820fe6 100644 --- a/lib/config/parser.ts +++ b/lib/config/parser.ts @@ -3,25 +3,25 @@ import path from 'path'; import yaml from 'js-yaml'; export function loadYaml(filename: string): T | null { - const yamlPath = path.join(process.cwd(), 'content', filename); + const yamlPath = path.join(process.cwd(), 'content', filename); - if (!fs.existsSync(yamlPath)) { - return null; - } + if (!fs.existsSync(yamlPath)) { + return null; + } - const raw = fs.readFileSync(yamlPath, 'utf8'); - return (yaml.load(raw) || {}) as T; + const raw = fs.readFileSync(yamlPath, 'utf8'); + return (yaml.load(raw) || {}) as T; } const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export function validateUuid(id: string, context: string): string { - const trimmed = id.trim(); - if (!UUID_REGEX.test(trimmed)) { - // If we are in setup mode or using dummy data, don't crash the build. - // Return a valid dummy UUID so Next.js doesn't panic. - console.warn(`Invalid UUID in ${context}: "${trimmed}" (Ignored for build/setup)`); - return '00000000-0000-0000-0000-000000000000'; - } - return trimmed; + const trimmed = id.trim(); + if (!UUID_REGEX.test(trimmed)) { + // If we are in setup mode or using dummy data, don't crash the build. + // Return a valid dummy UUID so Next.js doesn't panic. + console.warn(`Invalid UUID in ${context}: "${trimmed}" (Ignored for build/setup)`); + return '00000000-0000-0000-0000-000000000000'; + } + return trimmed; } diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 8f06c34..0f2c867 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -1,91 +1,91 @@ export interface SubpageSectionConfig { - title: string; - slug: string; // auto-derived from title, used as anchor - description?: string; - albumIds: string[]; + 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[]; // flat list (all albums, incl. from sections) - sections?: SubpageSectionConfig[]; - password?: string; - grid?: Partial; + name: string; + slug: string; + title?: string; + subtitle?: string; + albumIds: string[]; // flat list (all albums, incl. from sections) + sections?: SubpageSectionConfig[]; + password?: string; + grid?: Partial; } export interface FooterConfig { - name?: string; - instagram?: string; - email?: string; - website?: string; + name?: string; + instagram?: string; + email?: string; + website?: string; } export interface LegalConfig { - enabled: boolean; - name: string; - address: string; - zipCity: string; - country: string; - email?: string; - phone?: string; - taxId?: string; - vatId?: string; - extraInfo?: string; + enabled: boolean; + name: string; + address: string; + zipCity: string; + country: string; + email?: string; + phone?: string; + taxId?: string; + vatId?: string; + extraInfo?: string; } export interface ThemeConfig { - preset: string; - accent: string; - fonts: { heading: string; body: string; caption: string }; - radius: number; - photoFrame: 'none' | 'passepartout' | 'shadow'; - grain: boolean; - headerDot: boolean; - heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic'; + preset: string; + accent: string; + fonts: { heading: string; body: string; caption: string }; + radius: number; + photoFrame: 'none' | 'passepartout' | 'shadow'; + grain: boolean; + headerDot: boolean; + heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic'; } export interface GridConfig { - columns: number; - gap: number; - aspectRatio: string; - layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow'; + columns: number; + gap: number; + aspectRatio: string; + layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow'; } export interface AppConfig { - immich: { apiUrl: string; apiKey: string }; - authSecret: string; - albums: string[]; - standaloneAlbums: string[]; - subpages: SubpageConfig[]; - siteTitle: string; - siteSubtitle: string; - seo: { - title: string; - description: string; - noIndex: boolean; - noFollow: boolean; - }; - heroImages: string[]; - exifOnHover: boolean; - grid: GridConfig; - theme: ThemeConfig; - footer: FooterConfig | null; - legal: LegalConfig; - map: boolean; - transitions: boolean; - albumOverrides: Record; - cacheTtl: number; - rateLimitRpm: number; - needsSetup?: boolean; + immich: { apiUrl: string; apiKey: string }; + authSecret: string; + albums: string[]; + standaloneAlbums: string[]; + subpages: SubpageConfig[]; + siteTitle: string; + siteSubtitle: string; + seo: { + title: string; + description: string; + noIndex: boolean; + noFollow: boolean; + }; + heroImages: string[]; + exifOnHover: boolean; + grid: GridConfig; + theme: ThemeConfig; + footer: FooterConfig | null; + legal: LegalConfig; + map: boolean; + transitions: boolean; + albumOverrides: Record; + cacheTtl: number; + rateLimitRpm: number; + needsSetup?: boolean; } export interface GalleryYaml { - hero?: string | string[]; - albums?: string[]; - subpages?: + hero?: string | string[]; + albums?: string[]; + subpages?: | Record>> | Array<{ name: string; @@ -93,33 +93,33 @@ export interface GalleryYaml { subtitle?: string; albums?: Array>; sections?: Array<{ - title: string; - description?: string; - albums: Array>; + title: string; + description?: string; + albums: Array>; }>; password?: string; grid?: { - columns?: number; - gap?: number; - aspectRatio?: string; - layout?: string; + columns?: number; + gap?: number; + aspectRatio?: string; + layout?: string; }; - }>; + }>; } export interface SettingsYaml { + title?: string; + subtitle?: string; + seo?: { title?: string; - subtitle?: string; - seo?: { - title?: string; - description?: string; - noIndex?: boolean; - noFollow?: boolean; - }; - exifOnHover?: boolean; - map?: boolean; - transitions?: boolean; - theme?: + description?: string; + noIndex?: boolean; + noFollow?: boolean; + }; + exifOnHover?: boolean; + map?: boolean; + transitions?: boolean; + theme?: | string | { preset?: string; @@ -130,22 +130,22 @@ export interface SettingsYaml { grain?: boolean; headerDot?: boolean; heroStyle?: string; - }; - grid?: { - columns?: number; - gap?: number; - aspectRatio?: string; - layout?: string; - }; - footer?: FooterConfig; - legal?: Partial; + }; + grid?: { + columns?: number; + gap?: number; + aspectRatio?: string; + layout?: string; + }; + footer?: FooterConfig; + legal?: Partial; } export function slugify(name: string): string { - return name - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + return name + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); } diff --git a/lib/config/theme.ts b/lib/config/theme.ts index 411beff..231ee92 100644 --- a/lib/config/theme.ts +++ b/lib/config/theme.ts @@ -1,122 +1,133 @@ import type { ThemeConfig, SettingsYaml } from './schema'; export const THEME_PRESETS: Record = { - studio: { - preset: 'studio', - accent: '#e60012', - fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' }, - radius: 0, - photoFrame: 'passepartout', - grain: true, - headerDot: true, - heroStyle: 'split', - }, - minimal: { - preset: 'minimal', - accent: '#000000', - fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' }, - radius: 0, - photoFrame: 'none', - grain: false, - headerDot: false, - heroStyle: 'fullbleed', - }, - editorial: { - preset: 'editorial', - accent: '#8B2500', - fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' }, - radius: 0, - photoFrame: 'shadow', - grain: false, - headerDot: false, - heroStyle: 'split', - }, - classic: { - preset: 'classic', - accent: '#c49a3c', - fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' }, - radius: 12, - photoFrame: 'passepartout', - grain: false, - headerDot: true, - heroStyle: 'minimal', - }, - noir: { - preset: 'noir', - accent: '#ff6b35', - fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' }, - radius: 0, - photoFrame: 'passepartout', - grain: true, - headerDot: false, - heroStyle: 'fullbleed', - }, - monograph: { - preset: 'monograph', - accent: '#333333', - fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' }, - radius: 0, - photoFrame: 'none', - grain: false, - headerDot: false, - heroStyle: 'typographic', - }, - botanica: { - preset: 'botanica', - accent: '#4a7c59', - fonts: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' }, - radius: 8, - photoFrame: 'none', - grain: false, - headerDot: true, - heroStyle: 'split', - }, + studio: { + preset: 'studio', + accent: '#e60012', + fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' }, + radius: 0, + photoFrame: 'passepartout', + grain: true, + headerDot: true, + heroStyle: 'split', + }, + minimal: { + preset: 'minimal', + accent: '#000000', + fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' }, + radius: 0, + photoFrame: 'none', + grain: false, + headerDot: false, + heroStyle: 'fullbleed', + }, + editorial: { + preset: 'editorial', + accent: '#8B2500', + fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' }, + radius: 0, + photoFrame: 'shadow', + grain: false, + headerDot: false, + heroStyle: 'split', + }, + classic: { + preset: 'classic', + accent: '#c49a3c', + fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' }, + radius: 12, + photoFrame: 'passepartout', + grain: false, + headerDot: true, + heroStyle: 'minimal', + }, + noir: { + preset: 'noir', + accent: '#ff6b35', + fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' }, + radius: 0, + photoFrame: 'passepartout', + grain: true, + headerDot: false, + heroStyle: 'fullbleed', + }, + monograph: { + preset: 'monograph', + accent: '#333333', + fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' }, + radius: 0, + photoFrame: 'none', + grain: false, + headerDot: false, + heroStyle: 'typographic', + }, + botanica: { + preset: 'botanica', + accent: '#4a7c59', + fonts: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' }, + radius: 8, + photoFrame: 'none', + grain: false, + headerDot: true, + heroStyle: 'split', + }, }; export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow']; -export const VALID_HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic']; +export const VALID_HERO_STYLES = [ + 'split', + 'fullbleed', + 'minimal', + 'stacked', + 'typographic', + 'mosaic', +]; export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow']; export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig { - if (!raw) return { ...THEME_PRESETS.studio }; + if (!raw) return { ...THEME_PRESETS.studio }; - if (typeof raw === 'string') { - const preset = THEME_PRESETS[raw]; - if (!preset) { - throw new Error(`Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`); - } - return { ...preset }; + if (typeof raw === 'string') { + const preset = THEME_PRESETS[raw]; + if (!preset) { + throw new Error( + `Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`, + ); } + return { ...preset }; + } - const baseName = raw.preset ?? 'studio'; - const base = THEME_PRESETS[baseName]; - if (!base) { - throw new Error(`Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`); - } + const baseName = raw.preset ?? 'studio'; + const base = THEME_PRESETS[baseName]; + if (!base) { + throw new Error( + `Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`, + ); + } - return { - preset: baseName, - accent: raw.accent ?? base.accent, - fonts: { - heading: raw.fonts?.heading ?? base.fonts.heading, - body: raw.fonts?.body ?? base.fonts.body, - caption: raw.fonts?.caption ?? base.fonts.caption, - }, - radius: raw.radius ?? base.radius, - photoFrame: VALID_PHOTO_FRAMES.includes(raw.photoFrame ?? '') - ? (raw.photoFrame as ThemeConfig['photoFrame']) - : base.photoFrame, - grain: raw.grain ?? base.grain, - headerDot: raw.headerDot ?? base.headerDot, - heroStyle: VALID_HERO_STYLES.includes(raw.heroStyle ?? '') - ? (raw.heroStyle as ThemeConfig['heroStyle']) - : base.heroStyle, - }; + return { + preset: baseName, + accent: raw.accent ?? base.accent, + fonts: { + heading: raw.fonts?.heading ?? base.fonts.heading, + body: raw.fonts?.body ?? base.fonts.body, + caption: raw.fonts?.caption ?? base.fonts.caption, + }, + radius: raw.radius ?? base.radius, + photoFrame: VALID_PHOTO_FRAMES.includes(raw.photoFrame ?? '') + ? (raw.photoFrame as ThemeConfig['photoFrame']) + : base.photoFrame, + grain: raw.grain ?? base.grain, + headerDot: raw.headerDot ?? base.headerDot, + heroStyle: VALID_HERO_STYLES.includes(raw.heroStyle ?? '') + ? (raw.heroStyle as ThemeConfig['heroStyle']) + : base.heroStyle, + }; } export function getGoogleFontsUrl(theme: ThemeConfig): string { - const weights = '300;400;500;600'; - const families = [...new Set([theme.fonts.heading, theme.fonts.body, theme.fonts.caption])]; - const params = families.map((f) => `family=${f.replace(/ /g, '+')}:wght@${weights}`).join('&'); - return `https://fonts.googleapis.com/css2?${params}&display=swap`; + const weights = '300;400;500;600'; + const families = [...new Set([theme.fonts.heading, theme.fonts.body, theme.fonts.caption])]; + const params = families.map((f) => `family=${f.replace(/ /g, '+')}:wght@${weights}`).join('&'); + return `https://fonts.googleapis.com/css2?${params}&display=swap`; } diff --git a/lib/env.ts b/lib/env.ts index f2dd0e6..d479e6a 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -29,10 +29,12 @@ function parseEnv(): Env { const apiKey = process.env.IMMICH_API_KEY || ''; const cacheTtlStr = process.env.CACHE_TTL; - const cacheTtl = cacheTtlStr && !isNaN(parseInt(cacheTtlStr, 10)) ? parseInt(cacheTtlStr, 10) : 300; + const cacheTtl = + cacheTtlStr && !isNaN(parseInt(cacheTtlStr, 10)) ? parseInt(cacheTtlStr, 10) : 300; const rateLimitStr = process.env.RATE_LIMIT_RPM; - const rateLimit = rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600; + const rateLimit = + rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600; return { IMMICH_API_URL: apiUrl, diff --git a/lib/immich.ts b/lib/immich.ts index b980fc7..2b0a30e 100644 --- a/lib/immich.ts +++ b/lib/immich.ts @@ -55,7 +55,6 @@ export interface ImmichExifInfo { description: string | null; } - export type ImageSize = 'thumbnail' | 'preview' | 'original'; /** Enriched subpage with album metadata (for rendering cards). */ @@ -325,7 +324,6 @@ class ImmichClient { return asset; } - /** * Check if the Immich server is reachable. */ diff --git a/lib/mapService.ts b/lib/mapService.ts index aaa4458..a225270 100644 --- a/lib/mapService.ts +++ b/lib/mapService.ts @@ -4,13 +4,13 @@ import { cache } from './cache'; /** A clustered map marker — one per unique city/country. */ export interface MapLocation { - city: string; - country: string; - lat: number; - lng: number; - photoCount: number; - coverAssetId: string; - albums: { name: string; slug: string; subpageSlug?: string }[]; + city: string; + country: string; + lat: number; + lng: number; + photoCount: number; + coverAssetId: string; + albums: { name: string; slug: string; subpageSlug?: string }[]; } /** @@ -18,68 +18,68 @@ export interface MapLocation { * Returns one entry per unique city+country with averaged lat/lng. */ export async function getMapData(): Promise { - const config = getConfig(); - const cacheKey = 'map-data'; - const cached = cache.get(cacheKey); - if (cached) return cached; + const config = getConfig(); + const cacheKey = 'map-data'; + const cached = cache.get(cacheKey); + if (cached) return cached; - const albums = await immich.getAlbums(); + const albums = await immich.getAlbums(); - // Build a lookup: album ID → { name, slug, subpageSlug? } - const albumMeta = new Map(); - for (const a of albums) { - // Check if this album belongs to a subpage - const sp = config.subpages.find((s) => s.albumIds.includes(a.id)); - albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug }); + // Build a lookup: album ID → { name, slug, subpageSlug? } + const albumMeta = new Map(); + for (const a of albums) { + // Check if this album belongs to a subpage + const sp = config.subpages.find((s) => s.albumIds.includes(a.id)); + albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug }); + } + + // Fetch full album data (with assets) for each + const fullAlbums = await Promise.all(albums.map((a) => immich.getAlbum(a.id))); + + // Bucket assets by city+country + const buckets = new Map< + string, + { lats: number[]; lngs: number[]; count: number; coverAssetId: string; albumIds: Set } + >(); + + for (const album of fullAlbums) { + if (!album) continue; + for (const asset of album.assets) { + const exif = asset.exifInfo; + if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue; + + const key = `${exif.city}|${exif.country}`; + let bucket = buckets.get(key); + if (!bucket) { + bucket = { lats: [], lngs: [], count: 0, coverAssetId: asset.id, albumIds: new Set() }; + buckets.set(key, bucket); + } + bucket.lats.push(exif.latitude); + bucket.lngs.push(exif.longitude); + bucket.count++; + bucket.albumIds.add(album.id); } + } - // Fetch full album data (with assets) for each - const fullAlbums = await Promise.all(albums.map((a) => immich.getAlbum(a.id))); + // Convert buckets to MapLocation[] + const locations: MapLocation[] = []; + for (const [key, bucket] of buckets) { + const [city, country] = key.split('|'); + const lat = bucket.lats.reduce((a, b) => a + b, 0) / bucket.lats.length; + const lng = bucket.lngs.reduce((a, b) => a + b, 0) / bucket.lngs.length; + locations.push({ + city, + country, + lat, + lng, + photoCount: bucket.count, + coverAssetId: bucket.coverAssetId, + albums: [...bucket.albumIds] + .map((id) => albumMeta.get(id)) + .filter((m): m is NonNullable => !!m), + }); + } - // Bucket assets by city+country - const buckets = new Map< - string, - { lats: number[]; lngs: number[]; count: number; coverAssetId: string; albumIds: Set } - >(); - - for (const album of fullAlbums) { - if (!album) continue; - for (const asset of album.assets) { - const exif = asset.exifInfo; - if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue; - - const key = `${exif.city}|${exif.country}`; - let bucket = buckets.get(key); - if (!bucket) { - bucket = { lats: [], lngs: [], count: 0, coverAssetId: asset.id, albumIds: new Set() }; - buckets.set(key, bucket); - } - bucket.lats.push(exif.latitude); - bucket.lngs.push(exif.longitude); - bucket.count++; - bucket.albumIds.add(album.id); - } - } - - // Convert buckets to MapLocation[] - const locations: MapLocation[] = []; - for (const [key, bucket] of buckets) { - const [city, country] = key.split('|'); - const lat = bucket.lats.reduce((a, b) => a + b, 0) / bucket.lats.length; - const lng = bucket.lngs.reduce((a, b) => a + b, 0) / bucket.lngs.length; - locations.push({ - city, - country, - lat, - lng, - photoCount: bucket.count, - coverAssetId: bucket.coverAssetId, - albums: [...bucket.albumIds] - .map((id) => albumMeta.get(id)) - .filter((m): m is NonNullable => !!m), - }); - } - - cache.set(cacheKey, locations, config.cacheTtl); - return locations; + cache.set(cacheKey, locations, config.cacheTtl); + return locations; }