diff --git a/.env.local.example b/.env.local.example index ccf9dfe..4ab5993 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,17 +1,27 @@ -# Immich Server Connection +# ── Immich Server Connection (required) ────────────────────────── +# Validated by Zod at startup — missing or invalid values produce clear errors. + +# Base URL of your Immich instance (must be a valid URL) IMMICH_API_URL=http://your-immich-server:2283 + +# Immich API key (from Admin → API Keys) IMMICH_API_KEY=your-api-key-here -# Optional: Site title and subtitle (default: "Gallery") +# ── Optional Settings ──────────────────────────────────────────── +# All have sensible defaults and are type-checked by Zod. + +# Site title shown in the header (string, default: "Gallery") SITE_TITLE=My Photography + +# Subtitle displayed below the title (string, default: "") SITE_SUBTITLE=A visual journal -# Optional: Cache TTL in seconds (default: 300 = 5 minutes) +# Cache TTL in seconds (integer >= 0, default: 300 = 5 minutes) CACHE_TTL=300 -# Optional: Rate limit for image proxy (requests per minute per IP, default: 120) +# Rate limit for image proxy (integer >= 1, requests per minute per IP, default: 120) # RATE_LIMIT_RPM=120 -# ── Album configuration ── +# ── Album configuration ────────────────────────────────────────── # Albums, subpages, hero image, and passwords are defined in content/gallery.yaml # See content/gallery.yaml.example for the full reference. diff --git a/.gitignore b/.gitignore index ff318e1..02666ad 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ # testing /coverage +/test-results/ +/playwright-report/ # next.js /.next/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..41d2b1a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules +.next +build +out +*.yaml +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8784f69 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false +} diff --git a/README.md b/README.md index 9f55003..4c94f40 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,15 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p ### Security -| Concern | Protection | -|---------|-----------| -| **Server exposure** | Immich URL and port are never sent to the browser — all requests are proxied server-side | -| **API key** | Stored only in `.env.local`, never included in client-side code or responses | -| **Asset IDs** | Immich UUIDs are encrypted (AES-256) into opaque tokens — visitors cannot guess or enumerate asset IDs | -| **Album scope** | Only albums explicitly listed in `gallery.yaml` are accessible — everything else is blocked | -| **Password protection** | Individual subpages can require a password before content is revealed | -| **Rate limiting** | Per-IP sliding-window rate limiter on the image proxy (configurable RPM) | -| **No direct access** | Visitors interact only with the Next.js app; they have zero knowledge of your Immich server's existence | +| Concern | Protection | +| ----------------------- | ------------------------------------------------------------------------------------------------------- | +| **Server exposure** | Immich URL and port are never sent to the browser — all requests are proxied server-side | +| **API key** | Stored only in `.env.local`, never included in client-side code or responses | +| **Asset IDs** | Immich UUIDs are encrypted (AES-256) into opaque tokens — visitors cannot guess or enumerate asset IDs | +| **Album scope** | Only albums explicitly listed in `gallery.yaml` are accessible — everything else is blocked | +| **Password protection** | Individual subpages can require a password before content is revealed | +| **Rate limiting** | Per-IP sliding-window rate limiter on the image proxy (configurable RPM) | +| **No direct access** | Visitors interact only with the Next.js app; they have zero knowledge of your Immich server's existence | ### Infrastructure @@ -108,7 +108,7 @@ subpages: grid: columns: 4 layout: uniform - aspectRatio: "3/2" + aspectRatio: '3/2' albums: - uuid-tokyo - uuid-kyoto @@ -147,7 +147,7 @@ services: container_name: immich-folio restart: unless-stopped ports: - - "7211:7211" + - '7211:7211' env_file: - .env.local volumes: diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx index 5011636..a2ea7da 100644 --- a/app/[...path]/PhotoGrid.tsx +++ b/app/[...path]/PhotoGrid.tsx @@ -8,125 +8,122 @@ import { useState, useCallback, useEffect } from 'react'; import Image from 'next/image'; import { Lightbox } from '@/components/Lightbox'; +import { FadeIn } from '@/components/FadeIn'; export interface PhotoItem { - id: string; - thumbUrl: string; - previewUrl: string; - originalUrl: string; - exifUrl: string; - blurDataURL?: string; - dominantColor?: string; - /** Compact EXIF summary for hover overlay */ - camera?: string; - lens?: string; - focalLength?: string; - /** Natural image aspect ratio (width/height) for masonry layout */ - aspectRatio?: number; + id: string; + thumbUrl: string; + previewUrl: string; + originalUrl: string; + exifUrl: string; + blurDataURL?: string; + dominantColor?: string; + /** Compact EXIF summary for hover overlay */ + camera?: string; + lens?: string; + focalLength?: string; + /** Natural image aspect ratio (width/height) for masonry layout */ + aspectRatio?: number; } interface PhotoGridProps { - assets: PhotoItem[]; - albumId: string; - layout?: 'masonry' | 'uniform'; - gridStyle?: React.CSSProperties; + assets: PhotoItem[]; + albumId: string; + layout?: 'masonry' | 'uniform'; + gridStyle?: React.CSSProperties; } export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) { - const [lightboxIndex, setLightboxIndex] = useState(null); + const [lightboxIndex, setLightboxIndex] = useState(null); - const openLightbox = useCallback((index: number) => { - setLightboxIndex(index); - }, []); + const openLightbox = useCallback((index: number) => { + setLightboxIndex(index); + }, []); - const closeLightbox = useCallback(() => { - setLightboxIndex(null); - }, []); + const closeLightbox = useCallback(() => { + setLightboxIndex(null); + }, []); - const goNext = useCallback(() => { - setLightboxIndex((prev) => - prev !== null ? (prev + 1) % assets.length : null, - ); - }, [assets.length]); + const goNext = useCallback(() => { + setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null)); + }, [assets.length]); - const goPrev = useCallback(() => { - setLightboxIndex((prev) => - prev !== null ? (prev - 1 + assets.length) % assets.length : null, - ); - }, [assets.length]); + const goPrev = useCallback(() => { + setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null)); + }, [assets.length]); - // Keyboard navigation - useEffect(() => { - if (lightboxIndex === null) return; + // Keyboard navigation + useEffect(() => { + if (lightboxIndex === null) return; - const handleKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'Escape': - closeLightbox(); - break; - case 'ArrowRight': - goNext(); - break; - case 'ArrowLeft': - goPrev(); - break; - } - }; + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Escape': + closeLightbox(); + break; + case 'ArrowRight': + goNext(); + break; + case 'ArrowLeft': + goPrev(); + break; + } + }; - document.addEventListener('keydown', handleKeyDown); - // Prevent body scroll when lightbox is open - document.body.style.overflow = 'hidden'; + document.addEventListener('keydown', handleKeyDown); + // Prevent body scroll when lightbox is open + document.body.style.overflow = 'hidden'; - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.body.style.overflow = ''; - }; - }, [lightboxIndex, closeLightbox, goNext, goPrev]); + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [lightboxIndex, closeLightbox, goNext, goPrev]); - return ( - <> -
- {assets.map((asset, index) => ( -
openLightbox(index)} - style={{ - ...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}), - ...(layout === 'masonry' && asset.aspectRatio - ? { aspectRatio: `${asset.aspectRatio}` } - : {}), - }} - > - - {(asset.camera || asset.lens) && ( -
- {[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')} -
- )} -
- ))} + return ( + <> +
+ {assets.map((asset, index) => ( + +
openLightbox(index)} + style={{ + ...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}), + ...(layout === 'masonry' && asset.aspectRatio + ? { aspectRatio: `${asset.aspectRatio}` } + : {}), + }} + > + + {(asset.camera || asset.lens) && ( +
+ {[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')} +
+ )}
+
+ ))} +
- {lightboxIndex !== null && ( - - )} - - ); + {lightboxIndex !== null && ( + + )} + + ); } - diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx index a89b846..dbab16e 100644 --- a/app/[...path]/page.tsx +++ b/app/[...path]/page.tsx @@ -17,7 +17,13 @@ import type { Metadata } from 'next'; import { immich } from '@/lib/immich'; import { notFound } from 'next/navigation'; import { PhotoGrid } from './PhotoGrid'; -import { imageUrl, exifUrl, assetPlaceholder, assetExifSummary, assetAspectRatio } from '@/lib/urls'; +import { + imageUrl, + exifUrl, + assetPlaceholder, + assetExifSummary, + assetAspectRatio, +} from '@/lib/urls'; import { getConfig, type GridConfig } from '@/lib/config'; import { isProtected, isAuthenticated } from '@/lib/auth'; import PasswordGate from '@/components/PasswordGate'; @@ -26,303 +32,324 @@ import PasswordGate from '@/components/PasswordGate'; export const dynamic = 'force-dynamic'; interface PathPageProps { - params: Promise<{ path: string[] }>; + params: Promise<{ path: string[] }>; } export async function generateMetadata({ params }: PathPageProps): Promise { - const { path } = await params; - if (!path || path.length === 0) return {}; + const { path } = await params; + if (!path || path.length === 0) return {}; - const slug = path[0]; - let title = slug; - let subtitle = ''; + const slug = path[0]; + let title = slug; + let subtitle = ''; - if (path.length === 2) { - const album = await immich.getAlbumBySlug(path[1], slug); - if (album) { - title = album.albumName; - const count = album.assets.filter((a) => a.type === 'IMAGE').length; - subtitle = `${count} photo${count === 1 ? '' : 's'}`; - } - } else if (immich.isSubpageSlug(slug)) { - const result = await immich.getSubpageAlbums(slug); - if (result) { - // Single album → use album name; multiple → use subpage name - if (result.albums.length === 1) { - const album = await immich.getAlbumBySlug(result.albums[0].slug, slug); - if (album) { - title = album.albumName; - const count = album.assets.filter((a) => a.type === 'IMAGE').length; - subtitle = `${count} photo${count === 1 ? '' : 's'}`; - } - } else { - title = result.subpage.name; - } - } - } else { - const album = await immich.getAlbumBySlug(slug); - if (album) { - title = album.albumName; - const count = album.assets.filter((a) => a.type === 'IMAGE').length; - subtitle = `${count} photo${count === 1 ? '' : 's'}`; - } + if (path.length === 2) { + const album = await immich.getAlbumBySlug(path[1], slug); + if (album) { + title = album.albumName; + const count = album.assets.filter((a) => a.type === 'IMAGE').length; + subtitle = `${count} photo${count === 1 ? '' : 's'}`; } + } else if (immich.isSubpageSlug(slug)) { + const result = await immich.getSubpageAlbums(slug); + if (result) { + // Single album → use album name; multiple → use subpage name + if (result.albums.length === 1) { + const album = await immich.getAlbumBySlug(result.albums[0].slug, slug); + if (album) { + title = album.albumName; + const count = album.assets.filter((a) => a.type === 'IMAGE').length; + subtitle = `${count} photo${count === 1 ? '' : 's'}`; + } + } else { + title = result.subpage.name; + } + } + } else { + const album = await immich.getAlbumBySlug(slug); + if (album) { + title = album.albumName; + const count = album.assets.filter((a) => a.type === 'IMAGE').length; + subtitle = `${count} photo${count === 1 ? '' : 's'}`; + } + } - const ogUrl = `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`; + const ogUrl = `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`; - return { - title, - openGraph: { title, images: [ogUrl] }, - twitter: { card: 'summary_large_image', title, images: [ogUrl] }, - }; + return { + title, + openGraph: { title, images: [ogUrl] }, + twitter: { card: 'summary_large_image', title, images: [ogUrl] }, + }; } export default async function PathPage({ params }: PathPageProps) { - const { path } = await params; - const config = getConfig(); + const { path } = await params; + const config = getConfig(); - // Build grid CSS custom properties, optionally merging subpage overrides - const buildGridStyle = (overrides?: Partial): React.CSSProperties => { - const g = { ...config.grid, ...overrides }; - return { - '--grid-columns': g.columns, - '--grid-gap': `${g.gap}px`, - '--grid-aspect-ratio': g.aspectRatio, - } as React.CSSProperties; - }; - const resolveLayout = (overrides?: Partial) => - overrides?.layout ?? config.grid.layout; + // Build grid CSS custom properties, optionally merging subpage overrides + const buildGridStyle = (overrides?: Partial): React.CSSProperties => { + const g = { ...config.grid, ...overrides }; + return { + '--grid-columns': g.columns, + '--grid-gap': `${g.gap}px`, + '--grid-aspect-ratio': g.aspectRatio, + } as React.CSSProperties; + }; + const resolveLayout = (overrides?: Partial) => + overrides?.layout ?? config.grid.layout; - if (!path || path.length === 0) { - notFound(); - } + if (!path || path.length === 0) { + notFound(); + } - // ── Two segments: subpage/album ────────────────────────────── - if (path.length === 2) { - const [subpageSlug, albumSlug] = path; + // ── Two segments: subpage/album ────────────────────────────── + if (path.length === 2) { + const [subpageSlug, albumSlug] = path; - // Password gate for protected subpages - if (isProtected(subpageSlug)) { - const cookieStore = await cookies(); - const getCookie = (name: string) => cookieStore.get(name)?.value; - if (!isAuthenticated(subpageSlug, getCookie)) { - const subpageData = await immich.getSubpageAlbums(subpageSlug); - const subpageName = subpageData?.subpage.name ?? subpageSlug; - return ; - } - } - - const album = await immich.getAlbumBySlug(albumSlug, subpageSlug); - - if (!album) { - notFound(); - } - - // Look up subpage config for grid overrides and back link + // Password gate for protected subpages + if (isProtected(subpageSlug)) { + const cookieStore = await cookies(); + const getCookie = (name: string) => cookieStore.get(name)?.value; + if (!isAuthenticated(subpageSlug, getCookie)) { const subpageData = await immich.getSubpageAlbums(subpageSlug); - const spGrid = subpageData?.subpage.grid; const subpageName = subpageData?.subpage.name ?? subpageSlug; - - const images = album.assets.filter((asset) => asset.type === 'IMAGE'); - - return ( - <> -
- - - - - Back to {subpageName} - -

{album.albumName}

-

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

-
- { - const ph = assetPlaceholder(a); - const exif = config.exifOnHover ? assetExifSummary(a) : undefined; - return { - id: a.id, - thumbUrl: imageUrl(a.id, 'preview'), - previewUrl: imageUrl(a.id, 'preview'), - originalUrl: imageUrl(a.id, 'original'), - exifUrl: exifUrl(a.id), - ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), - ...(exif ?? {}), - aspectRatio: assetAspectRatio(a), - }; - })} - albumId={album.id} - layout={resolveLayout(spGrid)} - gridStyle={buildGridStyle(spGrid)} - /> - - ); + return ; + } } - // ── Single segment: subpage or standalone album ────────────── - const slug = path[0]; + const album = await immich.getAlbumBySlug(albumSlug, subpageSlug); - // Check if it's a subpage - if (immich.isSubpageSlug(slug)) { - // Password gate for protected subpages - if (isProtected(slug)) { - const cookieStore = await cookies(); - const getCookie = (name: string) => cookieStore.get(name)?.value; - if (!isAuthenticated(slug, getCookie)) { - const subpageData = await immich.getSubpageAlbums(slug); - const subpageName = subpageData?.subpage.name ?? slug; - return ; - } - } - - const result = await immich.getSubpageAlbums(slug); - if (!result || result.albums.length === 0) { - notFound(); - } - - const { albums } = result; - - // ── Single album → full-bleed (skip album grid) ────────── - if (albums.length === 1) { - const album = await immich.getAlbumBySlug(albums[0].slug, slug); - if (!album) notFound(); - - const images = album.assets.filter((asset) => asset.type === 'IMAGE'); - - return ( - <> -
- - - - - Back to Gallery - -

{album.albumName}

-

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

-
- { - const ph = assetPlaceholder(a); - const exif = config.exifOnHover ? assetExifSummary(a) : undefined; - return { - id: a.id, - thumbUrl: imageUrl(a.id, 'preview'), - previewUrl: imageUrl(a.id, 'preview'), - originalUrl: imageUrl(a.id, 'original'), - exifUrl: exifUrl(a.id), - ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), - ...(exif ?? {}), - aspectRatio: assetAspectRatio(a), - }; - })} - albumId={album.id} - layout={resolveLayout(result.subpage.grid)} - gridStyle={buildGridStyle(result.subpage.grid)} - /> - - ); - } - - // ── Multiple albums → show album grid ───────────────────── - - // Batch-fetch ThumbHash for album cover placeholders - const coverPlaceholders = await Promise.all( - albums.map(async (album) => { - if (!album.albumThumbnailAssetId) return null; - const asset = await immich.getAssetInfo(album.albumThumbnailAssetId); - return asset ? assetPlaceholder(asset) : null; - }), - ); - - return ( -
- {albums.map((album, i) => { - const ph = coverPlaceholders[i]; - return ( - - {album.albumThumbnailAssetId ? ( - {album.albumName} - ) : ( -
- )} -
- {album.albumName} - - {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'} - -
- - ); - })} -
- ); - } - - // Otherwise treat as a standalone album slug - const album = await immich.getAlbumBySlug(slug); if (!album) { - notFound(); + notFound(); } + // Look up subpage config for grid overrides and back link + const subpageData = await immich.getSubpageAlbums(subpageSlug); + const spGrid = subpageData?.subpage.grid; + const subpageName = subpageData?.subpage.name ?? subpageSlug; + const images = album.assets.filter((asset) => asset.type === 'IMAGE'); return ( - <> -
- - - - - Back to Gallery - -

{album.albumName}

-

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

-
- { - const ph = assetPlaceholder(a); - const exif = config.exifOnHover ? assetExifSummary(a) : undefined; - return { - id: a.id, - thumbUrl: imageUrl(a.id, 'preview'), - previewUrl: imageUrl(a.id, 'preview'), - originalUrl: imageUrl(a.id, 'original'), - exifUrl: exifUrl(a.id), - ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), - ...(exif ?? {}), - aspectRatio: assetAspectRatio(a), - }; - })} - albumId={album.id} - layout={resolveLayout()} - gridStyle={buildGridStyle()} - /> - + <> +
+ + + + + Back to {subpageName} + +

{album.albumName}

+

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

+
+ { + const ph = assetPlaceholder(a); + const exif = config.exifOnHover ? assetExifSummary(a) : undefined; + return { + id: a.id, + thumbUrl: imageUrl(a.id, 'preview'), + previewUrl: imageUrl(a.id, 'preview'), + originalUrl: imageUrl(a.id, 'original'), + exifUrl: exifUrl(a.id), + ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), + ...(exif ?? {}), + aspectRatio: assetAspectRatio(a), + }; + })} + albumId={album.id} + layout={resolveLayout(spGrid)} + gridStyle={buildGridStyle(spGrid)} + /> + ); + } + + // ── Single segment: subpage or standalone album ────────────── + const slug = path[0]; + + // Check if it's a subpage + if (immich.isSubpageSlug(slug)) { + // Password gate for protected subpages + if (isProtected(slug)) { + const cookieStore = await cookies(); + const getCookie = (name: string) => cookieStore.get(name)?.value; + if (!isAuthenticated(slug, getCookie)) { + const subpageData = await immich.getSubpageAlbums(slug); + const subpageName = subpageData?.subpage.name ?? slug; + return ; + } + } + + const result = await immich.getSubpageAlbums(slug); + if (!result || result.albums.length === 0) { + notFound(); + } + + const { albums } = result; + + // ── Single album → full-bleed (skip album grid) ────────── + if (albums.length === 1) { + const album = await immich.getAlbumBySlug(albums[0].slug, slug); + if (!album) notFound(); + + const images = album.assets.filter((asset) => asset.type === 'IMAGE'); + + return ( + <> +
+ + + + + Back to Gallery + +

{album.albumName}

+

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

+
+ { + const ph = assetPlaceholder(a); + const exif = config.exifOnHover ? assetExifSummary(a) : undefined; + return { + id: a.id, + thumbUrl: imageUrl(a.id, 'preview'), + previewUrl: imageUrl(a.id, 'preview'), + originalUrl: imageUrl(a.id, 'original'), + exifUrl: exifUrl(a.id), + ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), + ...(exif ?? {}), + aspectRatio: assetAspectRatio(a), + }; + })} + albumId={album.id} + layout={resolveLayout(result.subpage.grid)} + gridStyle={buildGridStyle(result.subpage.grid)} + /> + + ); + } + + // ── Multiple albums → show album grid ───────────────────── + + // Batch-fetch ThumbHash for album cover placeholders + const coverPlaceholders = await Promise.all( + albums.map(async (album) => { + if (!album.albumThumbnailAssetId) return null; + const asset = await immich.getAssetInfo(album.albumThumbnailAssetId); + return asset ? assetPlaceholder(asset) : null; + }), + ); + + return ( +
+ {albums.map((album, i) => { + const ph = coverPlaceholders[i]; + return ( + + {album.albumThumbnailAssetId ? ( + {album.albumName} + ) : ( +
+ )} + + {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'} + +
+ {album.albumName} + + {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'} + +
+ + ); + })} +
+ ); + } + + // Otherwise treat as a standalone album slug + const album = await immich.getAlbumBySlug(slug); + if (!album) { + notFound(); + } + + const images = album.assets.filter((asset) => asset.type === 'IMAGE'); + + return ( + <> +
+ + + + + Back to Gallery + +

{album.albumName}

+

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

+
+ { + const ph = assetPlaceholder(a); + const exif = config.exifOnHover ? assetExifSummary(a) : undefined; + return { + id: a.id, + thumbUrl: imageUrl(a.id, 'preview'), + previewUrl: imageUrl(a.id, 'preview'), + originalUrl: imageUrl(a.id, 'original'), + exifUrl: exifUrl(a.id), + ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), + ...(exif ?? {}), + aspectRatio: assetAspectRatio(a), + }; + })} + albumId={album.id} + layout={resolveLayout()} + gridStyle={buildGridStyle()} + /> + + ); } diff --git a/app/about/about.css b/app/about/about.css index 6055939..b2d60e2 100644 --- a/app/about/about.css +++ b/app/about/about.css @@ -1,122 +1,122 @@ /* ── About Page — Bauhaus / Leica Minimalist ──────── */ .about { - display: grid; - grid-template-columns: 1fr 1fr; - min-height: calc(100vh - var(--header-height)); + display: grid; + grid-template-columns: 1fr 1fr; + min-height: calc(100vh - var(--header-height)); } /* ── Portrait Column ─────────────────────────────── */ .about__portrait-col { - position: relative; - overflow: hidden; - background: var(--bg-secondary); + position: relative; + overflow: hidden; + background: var(--bg-secondary); } .about__portrait { - width: 100%; - height: 100%; - object-fit: cover; + width: 100%; + height: 100%; + object-fit: cover; } .about__portrait--placeholder { - width: 100%; - height: 100%; - background: var(--bg-secondary); + width: 100%; + height: 100%; + background: var(--bg-secondary); } /* ── Text Column ─────────────────────────────────── */ .about__text-col { - display: flex; - flex-direction: column; - justify-content: center; - padding: 80px 64px; - max-width: 560px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 80px 64px; + max-width: 560px; } .about__name { - font-family: var(--font-sans); - font-size: 2.4rem; - font-weight: 300; - letter-spacing: 0.08em; - text-transform: uppercase; - margin-bottom: 8px; + font-family: var(--font-sans); + font-size: 2.4rem; + font-weight: 300; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 8px; } .about__location { - font-family: var(--font-sans); - font-size: 0.75rem; - font-weight: 400; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--text-muted); + font-family: var(--font-sans); + font-size: 0.75rem; + font-weight: 400; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--text-muted); } /* ── Thin Rule Dividers ──────────────────────────── */ .about__rule { - border: none; - border-top: 1px solid var(--border-subtle); - margin: 40px 0; - width: 60px; + border: none; + border-top: 1px solid var(--border-subtle); + margin: 40px 0; + width: 60px; } /* ── Bio ─────────────────────────────────────────── */ .about__bio p { - font-family: var(--font-sans); - font-size: 0.95rem; - font-weight: 300; - line-height: 1.8; - color: var(--text-secondary); - margin-bottom: 8px; + font-family: var(--font-sans); + font-size: 0.95rem; + font-weight: 300; + line-height: 1.8; + color: var(--text-secondary); + margin-bottom: 8px; } .about__bio p:last-child { - margin-bottom: 0; + margin-bottom: 0; } /* ── Gear Section ────────────────────────────────── */ .about__section-label { - font-family: var(--font-sans); - font-size: 0.65rem; - font-weight: 500; - letter-spacing: 0.25em; - text-transform: uppercase; - color: var(--text-muted); - margin-bottom: 20px; + font-family: var(--font-sans); + font-size: 0.65rem; + font-weight: 500; + letter-spacing: 0.25em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 20px; } .about__gear { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 10px; + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 10px; } .about__gear li { - font-family: var(--font-sans); - font-size: 0.85rem; - font-weight: 300; - color: var(--text-secondary); - letter-spacing: 0.04em; + font-family: var(--font-sans); + font-size: 0.85rem; + font-weight: 300; + color: var(--text-secondary); + letter-spacing: 0.04em; } /* ── Responsive ──────────────────────────────────── */ @media (max-width: 768px) { - .about { - grid-template-columns: 1fr; - } + .about { + grid-template-columns: 1fr; + } - .about__portrait-col { - max-height: 50vh; - } + .about__portrait-col { + max-height: 50vh; + } - .about__text-col { - padding: 48px 24px 64px; - } + .about__text-col { + padding: 48px 24px 64px; + } - .about__name { - font-size: 1.8rem; - } -} \ No newline at end of file + .about__name { + font-size: 1.8rem; + } +} diff --git a/app/about/page.tsx b/app/about/page.tsx index d1a9c04..5e1f7a6 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -12,77 +12,75 @@ import { immich } from '@/lib/immich'; import './about.css'; interface AboutFrontmatter { - portrait?: string; - name?: string; - location?: string; - gear?: string[]; + portrait?: string; + name?: string; + location?: string; + gear?: string[]; } function getAboutContent() { - const raw = readFileSync(join(process.cwd(), 'content', 'about.md'), 'utf-8'); - const { data, content } = matter(raw); - return { meta: data as AboutFrontmatter, body: content.trim() }; + const raw = readFileSync(join(process.cwd(), 'content', 'about.md'), 'utf-8'); + const { data, content } = matter(raw); + return { meta: data as AboutFrontmatter, body: content.trim() }; } export default async function AboutPage() { - const { meta, body } = getAboutContent(); + const { meta, body } = getAboutContent(); - // Fetch ThumbHash for portrait placeholder - const portraitAsset = meta.portrait - ? await immich.getAssetInfo(meta.portrait) - : null; - const portraitPlaceholder = portraitAsset ? assetPlaceholder(portraitAsset) : null; + // Fetch ThumbHash for portrait placeholder + const portraitAsset = meta.portrait ? await immich.getAssetInfo(meta.portrait) : null; + const portraitPlaceholder = portraitAsset ? assetPlaceholder(portraitAsset) : null; - return ( -
- {/* ── Portrait ─────────────────────────────────── */} -
- {meta.portrait ? ( - {meta.name - ) : ( -
- )} -
+ return ( +
+ {/* ── Portrait ─────────────────────────────────── */} +
+ {meta.portrait ? ( + {meta.name + ) : ( +
+ )} +
- {/* ── Text ─────────────────────────────────────── */} -
- {meta.name &&

{meta.name}

} - {meta.location && ( -

{meta.location}

- )} + {/* ── Text ─────────────────────────────────────── */} +
+ {meta.name &&

{meta.name}

} + {meta.location &&

{meta.location}

} -
+
- {body && ( -
- {body.split('\n').filter(Boolean).map((line, i) => ( -

{line}

- ))} -
- )} + {body && ( +
+ {body + .split('\n') + .filter(Boolean) + .map((line, i) => ( +

{line}

+ ))} +
+ )} - {meta.gear && meta.gear.length > 0 && ( - <> -
-

Gear

-
    - {meta.gear.map((item) => ( -
  • {item}
  • - ))} -
- - )} -
-
- ); + {meta.gear && meta.gear.length > 0 && ( + <> +
+

Gear

+
    + {meta.gear.map((item) => ( +
  • {item}
  • + ))} +
+ + )} +
+
+ ); } - diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index 36223e9..836dce4 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -7,40 +7,25 @@ import { NextRequest, NextResponse } from 'next/server'; import { authenticate, isProtected } from '@/lib/auth'; export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { slug, password } = body; + try { + const body = await request.json(); + const { slug, password } = body; - if (!slug || !password) { - return NextResponse.json( - { error: 'Missing slug or password' }, - { status: 400 }, - ); - } - - if (!isProtected(slug)) { - return NextResponse.json( - { error: 'Album is not password-protected' }, - { status: 400 }, - ); - } - - const setCookie = authenticate(slug, password); - if (!setCookie) { - return NextResponse.json( - { error: 'Invalid password' }, - { status: 401 }, - ); - } - - return NextResponse.json( - { success: true }, - { headers: { 'Set-Cookie': setCookie } }, - ); - } catch { - return NextResponse.json( - { error: 'Invalid request body' }, - { status: 400 }, - ); + if (!slug || !password) { + return NextResponse.json({ error: 'Missing slug or password' }, { status: 400 }); } + + if (!isProtected(slug)) { + return NextResponse.json({ error: 'Album is not password-protected' }, { status: 400 }); + } + + const setCookie = authenticate(slug, password); + if (!setCookie) { + return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); + } + + return NextResponse.json({ success: true }, { headers: { 'Set-Cookie': setCookie } }); + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } } diff --git a/app/api/exif/[id]/route.ts b/app/api/exif/[id]/route.ts index 0038d50..78a1de3 100644 --- a/app/api/exif/[id]/route.ts +++ b/app/api/exif/[id]/route.ts @@ -9,33 +9,30 @@ import { NextResponse } from 'next/server'; import { immich } from '@/lib/immich'; import { decodeAssetId } from '@/lib/tokens'; -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: token } = await params; +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id: token } = await params; - // Decode the opaque token back to an Immich asset ID - const assetId = decodeAssetId(token); - if (!assetId) { - return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); - } + // Decode the opaque token back to an Immich asset ID + const assetId = decodeAssetId(token); + if (!assetId) { + return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); + } - const asset = await immich.getAssetInfo(assetId); - if (!asset?.exifInfo) { - return NextResponse.json({ error: 'No EXIF data' }, { status: 404 }); - } + const asset = await immich.getAssetInfo(assetId); + if (!asset?.exifInfo) { + return NextResponse.json({ error: 'No EXIF data' }, { status: 404 }); + } - const exif = asset.exifInfo; - 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, - }); + const exif = asset.exifInfo; + 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, + }); } diff --git a/app/api/health/route.ts b/app/api/health/route.ts index e61da6a..122e45f 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -11,16 +11,16 @@ import { immich } from '@/lib/immich'; const startTime = Date.now(); export async function GET() { - const immichOk = await immich.ping(); + const immichOk = await immich.ping(); - const body = { - status: immichOk ? 'ok' : 'degraded', - immich: immichOk ? 'connected' : 'unreachable', - uptime: Math.floor((Date.now() - startTime) / 1000), - }; + const body = { + status: immichOk ? 'ok' : 'degraded', + immich: immichOk ? 'connected' : 'unreachable', + uptime: Math.floor((Date.now() - startTime) / 1000), + }; - return NextResponse.json(body, { - status: immichOk ? 200 : 503, - headers: { 'Cache-Control': 'no-store' }, - }); + return NextResponse.json(body, { + status: immichOk ? 200 : 503, + headers: { 'Cache-Control': 'no-store' }, + }); } diff --git a/app/api/image/[id]/route.ts b/app/api/image/[id]/route.ts index 8fdadae..0b0df80 100644 --- a/app/api/image/[id]/route.ts +++ b/app/api/image/[id]/route.ts @@ -19,71 +19,67 @@ const VALID_SIZES: ImageSize[] = ['thumbnail', 'preview', 'original']; * Map a requested pixel width to the best Immich size tier. */ function widthToSize(w: number): ImageSize { - if (w <= 250) return 'thumbnail'; - if (w <= 1440) return 'preview'; - return 'original'; + if (w <= 250) return 'thumbnail'; + if (w <= 1440) return 'preview'; + return 'original'; } -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - // ── Rate limiting ────────────────────────────────── - const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown'; - const { success, remaining, resetAt } = checkRateLimit(ip, getConfig().rateLimitRpm); +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + // ── Rate limiting ────────────────────────────────── + const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown'; + const { success, remaining, resetAt } = checkRateLimit(ip, getConfig().rateLimitRpm); - if (!success) { - const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); - return NextResponse.json( - { error: 'Too many requests' }, - { - status: 429, - headers: { - 'Retry-After': String(retryAfter), - 'X-RateLimit-Remaining': '0', - }, - }, - ); + if (!success) { + const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); + return NextResponse.json( + { error: 'Too many requests' }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfter), + 'X-RateLimit-Remaining': '0', + }, + }, + ); + } + + const { id: token } = await params; + + // Decode the opaque token back to an Immich asset ID + const assetId = decodeAssetId(token); + if (!assetId) { + return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); + } + + // Determine size: prefer explicit ?size=, then infer from ?w= + const sizeParam = request.nextUrl.searchParams.get('size'); + const widthParam = request.nextUrl.searchParams.get('w'); + + let size: ImageSize = 'preview'; + if (sizeParam && VALID_SIZES.includes(sizeParam as ImageSize)) { + size = sizeParam as ImageSize; + } else if (widthParam) { + const w = parseInt(widthParam, 10); + if (!isNaN(w) && w > 0) { + size = widthToSize(w); } + } - const { id: token } = await params; + const result = await immich.streamAsset(assetId, size); + if (!result) { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); + } - // Decode the opaque token back to an Immich asset ID - const assetId = decodeAssetId(token); - if (!assetId) { - return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); - } + const headers: Record = { + 'Content-Type': result.contentType, + // Images are immutable once uploaded to Immich — cache aggressively + 'Cache-Control': 'public, max-age=31536000, immutable', + 'X-RateLimit-Remaining': String(remaining), + }; - // Determine size: prefer explicit ?size=, then infer from ?w= - const sizeParam = request.nextUrl.searchParams.get('size'); - const widthParam = request.nextUrl.searchParams.get('w'); + if (result.contentLength) { + headers['Content-Length'] = result.contentLength; + } - let size: ImageSize = 'preview'; - if (sizeParam && VALID_SIZES.includes(sizeParam as ImageSize)) { - size = sizeParam as ImageSize; - } else if (widthParam) { - const w = parseInt(widthParam, 10); - if (!isNaN(w) && w > 0) { - size = widthToSize(w); - } - } - - const result = await immich.streamAsset(assetId, size); - if (!result) { - return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); - } - - const headers: Record = { - 'Content-Type': result.contentType, - // Images are immutable once uploaded to Immich — cache aggressively - 'Cache-Control': 'public, max-age=31536000, immutable', - 'X-RateLimit-Remaining': String(remaining), - }; - - if (result.contentLength) { - headers['Content-Length'] = result.contentLength; - } - - return new NextResponse(result.stream, { headers }); + return new NextResponse(result.stream, { headers }); } - diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index c1bcb73..4ce325e 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -11,83 +11,81 @@ import { NextRequest } from 'next/server'; export const runtime = 'edge'; export async function GET(request: NextRequest) { - const { searchParams } = request.nextUrl; - const title = searchParams.get('title') || 'Gallery'; - const subtitle = searchParams.get('subtitle') || ''; + const { searchParams } = request.nextUrl; + const title = searchParams.get('title') || 'Gallery'; + const subtitle = searchParams.get('subtitle') || ''; - return new ImageResponse( - ( -
- {/* Decorative top line */} -
+ return new ImageResponse( +
+ {/* Decorative top line */} +
- {/* Title */} -
- {title} -
+ {/* Title */} +
+ {title} +
- {/* Subtitle */} - {subtitle && ( -
- {subtitle} -
- )} + {/* Subtitle */} + {subtitle && ( +
+ {subtitle} +
+ )} - {/* Bottom decorative line */} -
-
- ), - { - width: 1200, - height: 630, - headers: { - 'Cache-Control': 'public, max-age=86400, s-maxage=86400', - }, - }, - ); + {/* Bottom decorative line */} +
+
, + { + width: 1200, + height: 630, + headers: { + 'Cache-Control': 'public, max-age=86400, s-maxage=86400', + }, + }, + ); } diff --git a/app/globals.css b/app/globals.css index dd41ed3..6c70e28 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,69 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Playfair+Display:wght@400;500;600&display=swap'); - -/* ── Design Tokens ────────────────────────────────── */ -:root { - --bg-primary: #0a0a0a; - --bg-secondary: #111111; - --bg-card: #161616; - --bg-card-hover: #1a1a1a; - --bg-overlay: rgba(0, 0, 0, 0.92); - - --text-primary: #f0f0f0; - --text-secondary: #999999; - --text-muted: #666666; - - --border-subtle: #222222; - --border-hover: #333333; - - --accent: #d4af37; - --accent-dim: rgba(212, 175, 55, 0.15); - - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif; - - --radius-sm: 4px; - --radius-md: 8px; - --radius-lg: 12px; - - --transition-fast: 150ms ease; - --transition-normal: 300ms ease; - --transition-slow: 500ms ease; - - --max-width: 1400px; - --header-height: 64px; -} - -/* ── Reset ────────────────────────────────────────── */ -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -body { - font-family: var(--font-sans); - background: var(--bg-primary); - color: var(--text-primary); - min-height: 100vh; - line-height: 1.6; -} - -a { - color: inherit; - text-decoration: none; -} - -img { - display: block; - max-width: 100%; -} +@import url('./tokens.css'); /* ── Header / Navigation ──────────────────────────── */ .header { @@ -77,6 +12,10 @@ img { padding: 0 24px; } +[data-theme='light'] .header { + background: rgba(250, 250, 250, 0.9); +} + .header__nav { display: flex; align-items: center; @@ -180,7 +119,9 @@ img { text-decoration: underline; text-underline-offset: 3px; text-decoration-color: rgba(255, 255, 255, 0.2); - transition: color var(--transition-fast), text-decoration-color var(--transition-fast); + transition: + color var(--transition-fast), + text-decoration-color var(--transition-fast); padding: 4px 0; } @@ -252,7 +193,9 @@ img { width: 100%; height: 100%; object-fit: cover; - transition: transform var(--transition-slow), filter var(--transition-slow); + transition: + transform var(--transition-slow), + filter var(--transition-slow); } .subpage-grid__item:hover img { @@ -267,9 +210,7 @@ img { flex-direction: column; justify-content: flex-end; padding: 20px; - background: linear-gradient(to top, - rgba(0, 0, 0, 0.6) 0%, - transparent 50%); + background: linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, transparent 50%); opacity: 0; transition: opacity var(--transition-normal); } @@ -307,7 +248,9 @@ img { cursor: pointer; background: var(--bg-card); border: 1px solid var(--border-subtle); - transition: border-color var(--transition-normal), transform var(--transition-normal); + transition: + border-color var(--transition-normal), + transform var(--transition-normal); } .album-card:hover { @@ -319,7 +262,9 @@ img { width: 100%; height: 100%; object-fit: cover; - transition: transform var(--transition-slow), filter var(--transition-slow); + transition: + transform var(--transition-slow), + filter var(--transition-slow); } .album-card:hover .album-card__image { @@ -430,7 +375,9 @@ img { height: 100%; object-fit: cover; display: block; - transition: filter var(--transition-normal), transform var(--transition-slow); + transition: + filter var(--transition-normal), + transform var(--transition-slow); } .photo-grid__item:hover img { @@ -463,218 +410,12 @@ img { opacity: 1; } -/* ── Lightbox ─────────────────────────────────────── */ -.lightbox-overlay { - position: fixed; - inset: 0; - z-index: 1000; - background: var(--bg-overlay); - display: flex; - align-items: center; - justify-content: center; - animation: lightbox-fade-in var(--transition-normal) ease forwards; -} - -@keyframes lightbox-fade-in { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -.lightbox-overlay.closing { - animation: lightbox-fade-out var(--transition-fast) ease forwards; -} - -@keyframes lightbox-fade-out { - from { - opacity: 1; - } - - to { - opacity: 0; - } -} - -.lightbox__image-container { - position: relative; - max-width: 95vw; - max-height: 90vh; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - overflow: hidden; -} - -.lightbox__image { - max-width: 95vw; - max-height: 90vh; - object-fit: contain; - border-radius: var(--radius-sm); - user-select: none; - -webkit-user-drag: none; - opacity: 0; - transition: opacity var(--transition-normal); -} - -.lightbox__image--loaded { - opacity: 1; -} - -.lightbox__close { - position: fixed; - top: 20px; - right: 24px; - z-index: 1001; - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 8px; - transition: color var(--transition-fast); -} - -.lightbox__close:hover { - color: var(--text-primary); -} - -.lightbox__close svg { - width: 28px; - height: 28px; -} - -.lightbox__nav { - position: fixed; - top: 50%; - transform: translateY(-50%); - z-index: 1001; - background: rgba(255, 255, 255, 0.08); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 50%; - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: var(--text-secondary); - transition: background var(--transition-fast), color var(--transition-fast); -} - -.lightbox__nav:hover { - background: rgba(255, 255, 255, 0.15); - color: var(--text-primary); -} - -.lightbox__nav--prev { - left: 20px; -} - -.lightbox__nav--next { - right: 20px; -} - -.lightbox__nav svg { - width: 20px; - height: 20px; -} - -.lightbox__counter { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%); - font-size: 0.85rem; - color: var(--text-muted); - font-weight: 300; - letter-spacing: 0.1em; -} - -/* ── EXIF Info Panel ──────────────────────────────── */ -.lightbox__info-toggle { - position: fixed; - bottom: 24px; - right: 24px; - z-index: 1001; - background: rgba(255, 255, 255, 0.08); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: var(--radius-md); - padding: 8px 12px; - color: var(--text-secondary); - cursor: pointer; - font-size: 0.8rem; - font-family: var(--font-sans); - transition: background var(--transition-fast), color var(--transition-fast); -} - -.lightbox__info-toggle:hover { - background: rgba(255, 255, 255, 0.15); - color: var(--text-primary); -} - -.exif-panel { - position: fixed; - bottom: 60px; - right: 24px; - z-index: 1001; - background: rgba(20, 20, 20, 0.92); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - padding: 20px; - min-width: 240px; - animation: exif-slide-up var(--transition-normal) ease forwards; -} - -@keyframes exif-slide-up { - from { - opacity: 0; - transform: translateY(10px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.exif-panel__row { - display: flex; - justify-content: space-between; - align-items: baseline; - padding: 6px 0; - border-bottom: 1px solid var(--border-subtle); -} - -.exif-panel__row:last-child { - border-bottom: none; -} - -.exif-panel__label { - font-size: 0.75rem; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.exif-panel__value { - font-size: 0.85rem; - color: var(--text-primary); - font-weight: 400; -} - /* ── 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); @@ -690,6 +431,155 @@ img { } } +/* ── Fade-In Animations ────────────────────────────── */ +.fade-in { + opacity: 0; + transition: opacity 0.6s ease, transform 0.6s ease; +} + +.fade-in--up { + transform: translateY(20px); +} + +.fade-in--visible { + opacity: 1; + transform: translateY(0); +} + +@media (prefers-reduced-motion: reduce) { + .fade-in { + opacity: 1; + transform: none; + transition: none; + } +} + +/* ── Custom Cursor (Photo Grid) ─────────────────── */ +.photo-grid__item { + cursor: crosshair; +} + +/* ── Scroll-to-Top Button ────────────────────────── */ +.scroll-to-top { + position: fixed; + bottom: 32px; + right: 32px; + z-index: 90; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--border-subtle); + background: var(--bg-card); + color: var(--text-secondary); + font-size: 1.1rem; + font-weight: 300; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + pointer-events: none; + transform: translateY(8px); + transition: + opacity var(--transition-normal), + transform var(--transition-normal), + background var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast); +} + +.scroll-to-top--visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.scroll-to-top:hover { + background: var(--bg-card-hover); + border-color: var(--border-hover); + color: var(--text-primary); +} + +/* ── Theme Toggle ────────────────────────────────── */ +.theme-toggle { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + border-radius: var(--radius-sm); + transition: color var(--transition-fast); + margin-left: auto; +} + +.theme-toggle:hover { + color: var(--text-primary); +} + +/* ── Footer ──────────────────────────────────────── */ +.footer { + border-top: 1px solid var(--border-subtle); + padding: 32px 24px; + max-width: var(--max-width); + margin: 0 auto; +} + +.footer__inner { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + flex-wrap: wrap; +} + +.footer__name { + font-family: var(--font-sans); + font-size: 0.75rem; + font-weight: 400; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +.footer__links { + display: flex; + gap: 12px; + align-items: center; +} + +.footer__link { + color: var(--text-muted); + transition: color var(--transition-fast); + display: flex; + align-items: center; +} + +.footer__link:hover { + color: var(--text-primary); +} + +/* ── Image Count Badge (Subpage Covers) ─────────── */ +.subpage-grid__item-badge { + position: absolute; + top: 12px; + right: 12px; + z-index: 2; + padding: 4px 10px; + border-radius: 100px; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + font-family: var(--font-sans); + font-size: 0.65rem; + font-weight: 400; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.85); + pointer-events: none; +} + /* ── Error / Empty States ─────────────────────────── */ .empty-state { text-align: center; @@ -708,97 +598,6 @@ img { font-weight: 300; } -/* ── Password Gate ────────────────────────────────── */ -.password-gate { - display: flex; - align-items: center; - justify-content: center; - min-height: calc(100vh - var(--header-height)); - padding: 24px; -} - -.password-gate__card { - text-align: center; - padding: 48px 40px; - max-width: 400px; - width: 100%; - background: var(--bg-secondary); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); -} - -.password-gate__title { - font-family: var(--font-serif); - font-size: 1.8rem; - font-weight: 400; - letter-spacing: 0.02em; - margin-bottom: 8px; -} - -.password-gate__subtitle { - font-size: 0.85rem; - color: var(--text-secondary); - font-weight: 300; - margin-bottom: 32px; -} - -.password-gate__form { - display: flex; - flex-direction: column; - gap: 12px; -} - -.password-gate__input { - width: 100%; - padding: 12px 16px; - background: var(--bg-primary); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - color: var(--text-primary); - font-family: var(--font-sans); - font-size: 0.9rem; - outline: none; - transition: border-color var(--transition-fast); -} - -.password-gate__input:focus { - border-color: var(--accent); -} - -.password-gate__input::placeholder { - color: var(--text-muted); -} - -.password-gate__button { - padding: 12px 24px; - background: var(--accent); - color: var(--bg-primary); - border: none; - border-radius: var(--radius-md); - font-family: var(--font-sans); - font-size: 0.85rem; - font-weight: 500; - letter-spacing: 0.05em; - cursor: pointer; - transition: opacity var(--transition-fast); -} - -.password-gate__button:hover { - opacity: 0.9; -} - -.password-gate__button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.password-gate__error { - margin-top: 12px; - font-size: 0.8rem; - color: #e74c3c; - font-weight: 400; -} - /* ── Responsive ───────────────────────────────────── */ @media (max-width: 1024px) { .photo-grid { @@ -860,21 +659,4 @@ img { .subpage-grid__item-overlay { opacity: 1; } - - .lightbox__nav { - width: 40px; - height: 40px; - } - - .lightbox__nav--prev { - left: 8px; - } - - .lightbox__nav--next { - right: 8px; - } - - .password-gate__card { - padding: 36px 24px; - } } \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index b684e07..87be027 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,9 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import './globals.css'; import { SubpageNav } from '@/components/SubpageNav'; +import { ThemeToggle } from '@/components/ThemeToggle'; +import { ScrollToTop } from '@/components/ScrollToTop'; +import { Footer } from '@/components/Footer'; const siteTitle = process.env.SITE_TITLE || 'Gallery'; const siteDescription = 'A curated photography portfolio'; @@ -28,13 +31,9 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - +
{children}
+
+ ); diff --git a/app/page.tsx b/app/page.tsx index d23b421..2758ef8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,6 +9,7 @@ import { immich } from '@/lib/immich'; import { getConfig } from '@/lib/config'; import { imageUrl, assetPlaceholder } from '@/lib/urls'; import { HeroCarousel } from '@/components/HeroCarousel'; +import { FadeIn } from '@/components/FadeIn'; // Render at request time — requires live Immich connection export const dynamic = 'force-dynamic'; @@ -37,31 +38,29 @@ export default async function HomePage() { {/* ── Left Panel ──────────────────────────────── */}
-

{config.siteTitle}

+ +

{config.siteTitle}

+
{config.siteSubtitle && ( -

{config.siteSubtitle}

+ +

{config.siteSubtitle}

+
)} - + + +
@@ -72,4 +71,3 @@ export default async function HomePage() {
); } - diff --git a/app/tokens.css b/app/tokens.css new file mode 100644 index 0000000..54a1442 --- /dev/null +++ b/app/tokens.css @@ -0,0 +1,85 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Playfair+Display:wght@400;500;600&display=swap'); + +/* ── Design Tokens ────────────────────────────────── */ +:root { + --bg-primary: #0a0a0a; + --bg-secondary: #111111; + --bg-card: #161616; + --bg-card-hover: #1a1a1a; + --bg-overlay: rgba(0, 0, 0, 0.92); + + --text-primary: #f0f0f0; + --text-secondary: #999999; + --text-muted: #666666; + + --border-subtle: #222222; + --border-hover: #333333; + + --accent: #d4af37; + --accent-dim: rgba(212, 175, 55, 0.15); + + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + + --transition-fast: 150ms ease; + --transition-normal: 300ms ease; + --transition-slow: 500ms ease; + + --max-width: 1400px; + --header-height: 64px; +} + +/* ── Light Theme ──────────────────────────────────── */ +[data-theme='light'] { + --bg-primary: #fafafa; + --bg-secondary: #f0f0f0; + --bg-card: #ffffff; + --bg-card-hover: #f5f5f5; + --bg-overlay: rgba(255, 255, 255, 0.92); + + --text-primary: #1a1a1a; + --text-secondary: #666666; + --text-muted: #999999; + + --border-subtle: #e0e0e0; + --border-hover: #cccccc; + + --accent: #b8960e; + --accent-dim: rgba(184, 150, 14, 0.12); +} + +/* ── Reset ────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + line-height: 1.6; +} + +a { + color: inherit; + text-decoration: none; +} + +img { + display: block; + max-width: 100%; +} \ No newline at end of file diff --git a/components/FadeIn.tsx b/components/FadeIn.tsx new file mode 100644 index 0000000..0b61b24 --- /dev/null +++ b/components/FadeIn.tsx @@ -0,0 +1,63 @@ +/** + * FadeIn — scroll-reveal wrapper using IntersectionObserver. + * + * Wraps children in a div that fades/slides in when it enters the viewport. + * Supports an optional stagger delay for grid items. + * On the homepage (no scroll), acts as a simple load-in animation. + */ + +'use client'; + +import { useRef, useEffect, useCallback } from 'react'; + +interface FadeInProps { + children: React.ReactNode; + /** Stagger delay in ms (e.g. index * 60) */ + delay?: number; + /** Slide direction */ + direction?: 'up' | 'none'; + /** CSS class name for the wrapper */ + className?: string; +} + +export function FadeIn({ children, delay = 0, direction = 'up', className }: FadeInProps) { + const ref = useRef(null); + + const reveal = useCallback(() => { + ref.current?.classList.add('fade-in--visible'); + }, []); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + // Respect reduced-motion preference — reveal immediately + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + el.classList.add('fade-in--visible'); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + reveal(); + observer.unobserve(el); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [reveal]); + + return ( +
+ {children} +
+ ); +} diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..a8fa9d8 --- /dev/null +++ b/components/Footer.tsx @@ -0,0 +1,92 @@ +/** + * Footer — minimal footer with photographer name + social links. + * Reads configuration from gallery.yaml via getConfig(). + */ + +import { getConfig } from '@/lib/config'; + +export function Footer() { + const config = getConfig(); + const { footer } = config; + + // Don't render if no footer config + if (!footer || (!footer.name && !footer.instagram && !footer.email && !footer.website)) { + return null; + } + + return ( + + ); +} diff --git a/components/HeroCarousel.tsx b/components/HeroCarousel.tsx index 178c957..a27c482 100644 --- a/components/HeroCarousel.tsx +++ b/components/HeroCarousel.tsx @@ -11,67 +11,65 @@ import { useState, useEffect, useCallback } from 'react'; import Image from 'next/image'; interface HeroImage { - src: string; - blurDataURL?: string; + src: string; + blurDataURL?: string; } interface HeroCarouselProps { - images: HeroImage[]; + images: HeroImage[]; } const INTERVAL_MS = 6000; export function HeroCarousel({ images }: HeroCarouselProps) { - const [currentIndex, setCurrentIndex] = useState(0); + const [currentIndex, setCurrentIndex] = useState(0); - const advance = useCallback(() => { - setCurrentIndex((prev) => (prev + 1) % images.length); - }, [images.length]); + const advance = useCallback(() => { + setCurrentIndex((prev) => (prev + 1) % images.length); + }, [images.length]); - useEffect(() => { - if (images.length <= 1) return; + useEffect(() => { + if (images.length <= 1) return; - const timer = setInterval(advance, INTERVAL_MS); - return () => clearInterval(timer); - }, [images.length, advance]); + const timer = setInterval(advance, INTERVAL_MS); + return () => clearInterval(timer); + }, [images.length, advance]); - if (images.length === 0) { - return
; - } - - if (images.length === 1) { - const img = images[0]; - return ( - - ); - } + if (images.length === 0) { + return
; + } + if (images.length === 1) { + const img = images[0]; return ( - <> - {images.map((img, i) => ( - - ))} - + ); + } + + return ( + <> + {images.map((img, i) => ( + + ))} + + ); } diff --git a/components/Lightbox.module.css b/components/Lightbox.module.css new file mode 100644 index 0000000..d48fb83 --- /dev/null +++ b/components/Lightbox.module.css @@ -0,0 +1,229 @@ +/* ── Lightbox Module ──────────────────────────────── */ + +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: var(--bg-overlay); + display: flex; + align-items: center; + justify-content: center; + animation: lightbox-fade-in var(--transition-normal) ease forwards; +} + +@keyframes lightbox-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.overlay.closing { + animation: lightbox-fade-out var(--transition-fast) ease forwards; +} + +@keyframes lightbox-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +.imageContainer { + position: relative; + max-width: 95vw; + max-height: 90vh; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + overflow: hidden; +} + +.image { + max-width: 95vw; + max-height: 90vh; + object-fit: contain; + border-radius: var(--radius-sm); + user-select: none; + -webkit-user-drag: none; + opacity: 0; + transition: opacity var(--transition-normal); +} + +.imageLoaded { + opacity: 1; +} + +.close { + position: fixed; + top: 20px; + right: 24px; + z-index: 1001; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + transition: color var(--transition-fast); +} + +.close:hover { + color: var(--text-primary); +} + +.close svg { + width: 28px; + height: 28px; +} + +.nav { + position: fixed; + top: 50%; + transform: translateY(-50%); + z-index: 1001; + background: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 50%; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); + transition: + background var(--transition-fast), + color var(--transition-fast); +} + +.nav:hover { + background: rgba(255, 255, 255, 0.15); + color: var(--text-primary); +} + +.navPrev { + left: 20px; +} + +.navNext { + right: 20px; +} + +.nav svg { + width: 20px; + height: 20px; +} + +.counter { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + font-size: 0.85rem; + color: var(--text-muted); + font-weight: 300; + letter-spacing: 0.1em; +} + +/* ── EXIF Info Panel ──────────────────────────────── */ +.infoToggle { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 1001; + background: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + padding: 8px 12px; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.8rem; + font-family: var(--font-sans); + transition: + background var(--transition-fast), + color var(--transition-fast); +} + +.infoToggle:hover { + background: rgba(255, 255, 255, 0.15); + color: var(--text-primary); +} + +.exifPanel { + position: fixed; + bottom: 60px; + right: 24px; + z-index: 1001; + background: rgba(20, 20, 20, 0.92); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 20px; + min-width: 240px; + animation: exif-slide-up var(--transition-normal) ease forwards; +} + +@keyframes exif-slide-up { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.exifRow { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 6px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.exifRow:last-child { + border-bottom: none; +} + +.exifLabel { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.exifValue { + font-size: 0.85rem; + color: var(--text-primary); + font-weight: 400; +} + +/* ── Responsive ──────────────────────────────────── */ +@media (max-width: 640px) { + .nav { + width: 40px; + height: 40px; + } + + .navPrev { + left: 8px; + } + + .navNext { + right: 8px; + } +} diff --git a/components/Lightbox.tsx b/components/Lightbox.tsx index d1df97c..2dee560 100644 --- a/components/Lightbox.tsx +++ b/components/Lightbox.tsx @@ -13,250 +13,263 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import type { PhotoItem } from '@/app/[...path]/PhotoGrid'; +import styles from './Lightbox.module.css'; 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; } interface LightboxProps { - assets: PhotoItem[]; - currentIndex: number; - onClose: () => void; - onNext: () => void; - onPrev: () => void; + assets: PhotoItem[]; + currentIndex: number; + onClose: () => void; + onNext: () => void; + onPrev: () => void; } -export function Lightbox({ - assets, - currentIndex, - onClose, - onNext, - onPrev, -}: LightboxProps) { - const [showExif, setShowExif] = useState(false); - const [exifData, setExifData] = useState(null); - const [exifLoading, setExifLoading] = useState(false); - const [imageLoaded, setImageLoaded] = useState(false); - const overlayRef = useRef(null); - const touchStartX = useRef(0); - const touchStartY = useRef(0); +export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: LightboxProps) { + const [showExif, setShowExif] = useState(false); + const [exifData, setExifData] = useState(null); + const [exifLoading, setExifLoading] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); + const overlayRef = useRef(null); + const touchStartX = useRef(0); + const touchStartY = useRef(0); - const current = assets[currentIndex]; + const current = assets[currentIndex]; - // Fetch EXIF data on demand - 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)); - }, []); + // Fetch EXIF data on demand + 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)); + }, []); - // Reset EXIF data when switching images; refetch if panel is open - useEffect(() => { - setExifData(null); - setImageLoaded(false); - if (showExif && current) { - fetchExif(current.exifUrl); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentIndex]); + // Reset EXIF data when switching images; refetch if panel is open + useEffect(() => { + setExifData(null); + setImageLoaded(false); + if (showExif && current) { + fetchExif(current.exifUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentIndex]); - // When the toggle is turned on or the image changes while open, fetch - const handleExifToggle = useCallback(() => { - const next = !showExif; - setShowExif(next); - if (next && current) { - fetchExif(current.exifUrl); - } - }, [showExif, current, fetchExif]); + // When the toggle is turned on or the image changes while open, fetch + const handleExifToggle = useCallback(() => { + const next = !showExif; + setShowExif(next); + if (next && current) { + fetchExif(current.exifUrl); + } + }, [showExif, current, fetchExif]); - // Preload adjacent images - useEffect(() => { - const preload = (index: number) => { - if (index >= 0 && index < assets.length) { - const img = new Image(); - img.src = assets[index].previewUrl; - } - }; - preload(currentIndex + 1); - preload(currentIndex - 1); - }, [currentIndex, assets]); + // Preload adjacent images + useEffect(() => { + const preload = (index: number) => { + if (index >= 0 && index < assets.length) { + const img = new Image(); + img.src = assets[index].previewUrl; + } + }; + preload(currentIndex + 1); + preload(currentIndex - 1); + }, [currentIndex, assets]); - // Touch/swipe support - const handleTouchStart = useCallback((e: React.TouchEvent) => { - touchStartX.current = e.touches[0].clientX; - touchStartY.current = e.touches[0].clientY; - }, []); + // Touch/swipe support + const handleTouchStart = useCallback((e: React.TouchEvent) => { + touchStartX.current = e.touches[0].clientX; + touchStartY.current = e.touches[0].clientY; + }, []); - const handleTouchEnd = useCallback( - (e: React.TouchEvent) => { - const dx = e.changedTouches[0].clientX - touchStartX.current; - const dy = e.changedTouches[0].clientY - touchStartY.current; + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + const dx = e.changedTouches[0].clientX - touchStartX.current; + const dy = e.changedTouches[0].clientY - touchStartY.current; - // Only register horizontal swipes (not vertical scrolling) - if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) { - if (dx > 0) onPrev(); - else onNext(); - } - }, - [onNext, onPrev], - ); + // Only register horizontal swipes (not vertical scrolling) + if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) { + if (dx > 0) onPrev(); + else onNext(); + } + }, + [onNext, onPrev], + ); - // Click on overlay background → close - const handleOverlayClick = useCallback( - (e: React.MouseEvent) => { - if (e.target === overlayRef.current) { - onClose(); - } - }, - [onClose], - ); + // Click on overlay background → close + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current) { + onClose(); + } + }, + [onClose], + ); - return ( -
+ {/* Close button */} + - {/* Previous button */} - + {/* Previous button */} + - {/* Image */} -
- {/* eslint-disable-next-line @next/next/no-img-element */} - setImageLoaded(true)} - /> + {/* Image */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + setImageLoaded(true)} + /> +
+ + {/* Next button */} + + + {/* Counter */} +
+ {currentIndex + 1} / {assets.length} +
+ + {/* EXIF toggle */} + + + {/* EXIF panel */} + {showExif && ( +
+ {exifLoading ? ( +
+ Loading...
- - {/* Next button */} - - - {/* Counter */} -
- {currentIndex + 1} / {assets.length} -
- - {/* EXIF toggle */} - - - {/* EXIF panel */} - {showExif && ( -
- {exifLoading ? ( -
- Loading... -
- ) : exifData ? ( - <> - {exifData.model && ( -
- Camera - - {[exifData.make, exifData.model].filter(Boolean).join(' ')} - -
- )} - {exifData.lensModel && ( -
- Lens - {exifData.lensModel} -
- )} - {exifData.focalLength && ( -
- Focal Length - {exifData.focalLength}mm -
- )} - {exifData.fNumber && ( -
- Aperture - ƒ/{exifData.fNumber} -
- )} - {exifData.exposureTime && ( -
- Shutter - {exifData.exposureTime}s -
- )} - {exifData.iso && ( -
- ISO - {exifData.iso} -
- )} - {(exifData.city || exifData.country) && ( -
- Location - - {[exifData.city, exifData.country].filter(Boolean).join(', ')} - -
- )} - - ) : ( -
- No EXIF data available -
- )} + ) : exifData ? ( + <> + {exifData.model && ( +
+ Camera + + {[exifData.make, exifData.model].filter(Boolean).join(' ')} +
- )} + )} + {exifData.lensModel && ( +
+ Lens + {exifData.lensModel} +
+ )} + {exifData.focalLength && ( +
+ Focal Length + {exifData.focalLength}mm +
+ )} + {exifData.fNumber && ( +
+ Aperture + ƒ/{exifData.fNumber} +
+ )} + {exifData.exposureTime && ( +
+ Shutter + {exifData.exposureTime}s +
+ )} + {exifData.iso && ( +
+ ISO + {exifData.iso} +
+ )} + {(exifData.city || exifData.country) && ( +
+ Location + + {[exifData.city, exifData.country].filter(Boolean).join(', ')} + +
+ )} + + ) : ( +
+ No EXIF data available +
+ )}
- ); + )} +
+ ); } diff --git a/components/PasswordGate.module.css b/components/PasswordGate.module.css new file mode 100644 index 0000000..ed90c8f --- /dev/null +++ b/components/PasswordGate.module.css @@ -0,0 +1,98 @@ +/* ── Password Gate Module ─────────────────────────── */ + +.gate { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - var(--header-height)); + padding: 24px; +} + +.card { + text-align: center; + padding: 48px 40px; + max-width: 400px; + width: 100%; + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} + +.title { + font-family: var(--font-serif); + font-size: 1.8rem; + font-weight: 400; + letter-spacing: 0.02em; + margin-bottom: 8px; +} + +.subtitle { + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 300; + margin-bottom: 32px; +} + +.form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.input { + width: 100%; + padding: 12px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.9rem; + outline: none; + transition: border-color var(--transition-fast); +} + +.input:focus { + border-color: var(--accent); +} + +.input::placeholder { + color: var(--text-muted); +} + +.button { + padding: 12px 24px; + background: var(--accent); + color: var(--bg-primary); + border: none; + border-radius: var(--radius-md); + font-family: var(--font-sans); + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.05em; + cursor: pointer; + transition: opacity var(--transition-fast); +} + +.button:hover { + opacity: 0.9; +} + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.error { + margin-top: 12px; + font-size: 0.8rem; + color: #e74c3c; + font-weight: 400; +} + +/* ── Responsive ──────────────────────────────────── */ +@media (max-width: 640px) { + .card { + padding: 36px 24px; + } +} diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index 7f76b01..d99407c 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -6,71 +6,66 @@ */ import { useState } from 'react'; +import styles from './PasswordGate.module.css'; interface PasswordGateProps { - slug: string; - title: string; + slug: string; + title: string; } export default function PasswordGate({ slug, title }: PasswordGateProps) { - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(''); - setLoading(true); + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); - try { - const res = await fetch('/api/auth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slug, password }), - }); + try { + const res = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug, password }), + }); - if (res.ok) { - window.location.reload(); - } else { - setError('Wrong password'); - setPassword(''); - } - } catch { - setError('Something went wrong'); - } finally { - setLoading(false); - } + if (res.ok) { + window.location.reload(); + } else { + setError('Wrong password'); + setPassword(''); + } + } catch { + setError('Something went wrong'); + } finally { + setLoading(false); } + } - return ( -
-
-

{title}

-

- This gallery is password-protected. -

+ return ( +
+
+

{title}

+

This gallery is password-protected.

-
- setPassword(e.target.value)} - placeholder="Enter password" - className="password-gate__input" - autoFocus - required - /> - -
+
+ setPassword(e.target.value)} + placeholder="Enter password" + className={styles.input} + autoFocus + required + /> + +
- {error &&

{error}

} -
-
- ); + {error &&

{error}

} +
+
+ ); } diff --git a/components/ScrollToTop.tsx b/components/ScrollToTop.tsx new file mode 100644 index 0000000..0877ebc --- /dev/null +++ b/components/ScrollToTop.tsx @@ -0,0 +1,37 @@ +/** + * ScrollToTop — fixed ↑ button that appears after scrolling down. + * Smooth-scrolls back to the top when clicked. + */ + +'use client'; + +import { useState, useEffect } from 'react'; + +const SHOW_THRESHOLD = 300; + +export function ScrollToTop() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const onScroll = () => { + setVisible(window.scrollY > SHOW_THRESHOLD); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); + + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( + + ); +} diff --git a/components/SubpageNav.tsx b/components/SubpageNav.tsx index 38404e9..21b980b 100644 --- a/components/SubpageNav.tsx +++ b/components/SubpageNav.tsx @@ -7,31 +7,23 @@ import Link from 'next/link'; import { immich } from '@/lib/immich'; export async function SubpageNav() { - const [subpages, standaloneAlbums] = await Promise.all([ - immich.getSubpages(), - immich.getStandaloneAlbums(), - ]); + const [subpages, standaloneAlbums] = await Promise.all([ + immich.getSubpages(), + immich.getStandaloneAlbums(), + ]); - return ( - <> - {subpages.map((sp) => ( - - {sp.name} - - ))} - {standaloneAlbums.map((album) => ( - - {album.albumName} - - ))} - - ); + return ( + <> + {subpages.map((sp) => ( + + {sp.name} + + ))} + {standaloneAlbums.map((album) => ( + + {album.albumName} + + ))} + + ); } diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx new file mode 100644 index 0000000..ce3af8d --- /dev/null +++ b/components/ThemeToggle.tsx @@ -0,0 +1,102 @@ +/** + * ThemeToggle — dark/light mode switch. + * + * Persists preference in localStorage and sets data-theme on . + * Defaults to dark theme (matching existing design). + * Uses useSyncExternalStore to satisfy React strict mode lint rules. + */ + +'use client'; + +import { useSyncExternalStore, useCallback } from 'react'; + +type Theme = 'dark' | 'light'; + +/** Module-level theme state — shared across all instances. */ +let currentTheme: Theme = 'dark'; +const listeners = new Set<() => void>(); + +function subscribe(callback: () => void): () => void { + listeners.add(callback); + return () => listeners.delete(callback); +} + +function getSnapshot(): Theme { + return currentTheme; +} + +function getServerSnapshot(): Theme { + return 'dark'; // SSR default +} + +function applyTheme(theme: Theme) { + currentTheme = theme; + if (typeof document !== 'undefined') { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + } + listeners.forEach((cb) => cb()); +} + +// Initialize from localStorage on module load (client-side only) +if (typeof window !== 'undefined') { + const stored = localStorage.getItem('theme') as Theme | null; + currentTheme = stored || 'dark'; + document.documentElement.setAttribute('data-theme', currentTheme); +} + +export function ThemeToggle() { + const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const toggle = useCallback(() => { + applyTheme(theme === 'dark' ? 'light' : 'dark'); + }, [theme]); + + return ( + + ); +} diff --git a/content/gallery.yaml b/content/gallery.yaml index 180da08..596c6c1 100644 --- a/content/gallery.yaml +++ b/content/gallery.yaml @@ -23,3 +23,10 @@ subpages: password: clientpass123 albums: - 4ad0ed89-55de-4e69-937f-2d9988f63ad1 + +# Footer — optional minimal footer with social links +footer: + name: Ralf Ohlenbostel + instagram: quasiralf +# email: hello@example.com +# website: https://example.com diff --git a/content/gallery.yaml.example b/content/gallery.yaml.example index 6c71a28..849faa9 100644 --- a/content/gallery.yaml.example +++ b/content/gallery.yaml.example @@ -42,3 +42,10 @@ subpages: password: clientpass123 # visitors must enter this to view albums: - 55555555-5555-5555-5555-555555555555 + +# Footer — optional minimal footer with social links +# footer: +# name: John Doe +# instagram: johndoe +# email: hello@example.com +# website: https://example.com diff --git a/docker-compose.yml b/docker-compose.yml index d027354..aef4fa1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: immich-lightbox restart: unless-stopped ports: - - "7211:7211" + - '7211:7211' env_file: - .env.local volumes: diff --git a/e2e/about.spec.ts b/e2e/about.spec.ts new file mode 100644 index 0000000..9a83641 --- /dev/null +++ b/e2e/about.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; + +test.describe('About page', () => { + test('loads and shows the about content', async ({ page }) => { + await page.goto('/about'); + const about = page.locator('.about'); + await expect(about).toBeVisible(); + }); + + test('has a portrait column', async ({ page }) => { + await page.goto('/about'); + const portrait = page.locator('.about__portrait-col'); + await expect(portrait).toBeVisible(); + }); + + test('has a text column with name', async ({ page }) => { + await page.goto('/about'); + const name = page.locator('.about__name'); + await expect(name).toBeVisible(); + const text = await name.textContent(); + expect(text).toBeTruthy(); + }); +}); diff --git a/e2e/health.spec.ts b/e2e/health.spec.ts new file mode 100644 index 0000000..5e1e683 --- /dev/null +++ b/e2e/health.spec.ts @@ -0,0 +1,10 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Health API', () => { + test('GET /api/health returns 200 with expected shape', async ({ request }) => { + const response = await request.get('/api/health'); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + expect(body).toHaveProperty('status'); + }); +}); diff --git a/e2e/homepage.spec.ts b/e2e/homepage.spec.ts new file mode 100644 index 0000000..957a1ed --- /dev/null +++ b/e2e/homepage.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Homepage', () => { + test('loads and renders the hero section', async ({ page }) => { + await page.goto('/'); + // Wait for the hero section to appear + const hero = page.locator('.hero'); + await expect(hero).toBeVisible(); + }); + + test('shows the site title in the hero', async ({ page }) => { + await page.goto('/'); + const title = page.locator('.hero__title'); + await expect(title).toBeVisible(); + const text = await title.textContent(); + expect(text).toBeTruthy(); + }); + + test('has navigation links in the header', async ({ page }) => { + await page.goto('/'); + const nav = page.locator('.header__nav'); + await expect(nav).toBeVisible(); + + // Should have at least the Home and About links + const links = nav.locator('.header__nav-link'); + const count = await links.count(); + expect(count).toBeGreaterThanOrEqual(2); + }); + + test('hero nav links are present for subpages', async ({ page }) => { + await page.goto('/'); + const heroNav = page.locator('.hero__nav'); + await expect(heroNav).toBeVisible(); + const links = heroNav.locator('.hero__nav-link'); + const count = await links.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..061f5da --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Navigation', () => { + test('clicking a hero nav link navigates to a subpage', async ({ page }) => { + await page.goto('/'); + const heroNav = page.locator('.hero__nav .hero__nav-link').first(); + const href = await heroNav.getAttribute('href'); + expect(href).toBeTruthy(); + + await heroNav.click(); + await page.waitForURL(`**${href}`); + expect(page.url()).toContain(href); + }); + + test('header About link navigates to /about', async ({ page }) => { + await page.goto('/'); + const aboutLink = page.locator('.header__nav-link', { hasText: 'About' }); + await aboutLink.click(); + await page.waitForURL('**/about'); + expect(page.url()).toContain('/about'); + }); + + test('header Home link navigates back to /', async ({ page }) => { + await page.goto('/about'); + const homeLink = page.locator('.header__nav-link', { hasText: 'Home' }); + await homeLink.click(); + await page.waitForURL('**/'); + // Accept both "/" and "?..." after root + expect(new URL(page.url()).pathname).toBe('/'); + }); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..fcc8f97 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,17 +1,38 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; +import { defineConfig, globalIgnores } from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; +import prettier from 'eslint-config-prettier'; +import prettierPlugin from 'eslint-plugin-prettier'; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + plugins: { + prettier: prettierPlugin, + }, + rules: { + 'prettier/prettier': 'error', + 'no-console': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + }, + }, + prettier, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', ]), ]); diff --git a/lib/__tests__/config.test.ts b/lib/__tests__/config.test.ts new file mode 100644 index 0000000..e90597d --- /dev/null +++ b/lib/__tests__/config.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock env.ts so importing config.ts doesn't trigger Zod validation +vi.mock('@/lib/env', () => ({ + env: { + IMMICH_API_URL: 'http://localhost:2283', + IMMICH_API_KEY: 'test-key', + SITE_TITLE: 'Test Gallery', + SITE_SUBTITLE: '', + CACHE_TTL: 300, + RATE_LIMIT_RPM: 120, + }, +})); + +import { slugify } from '@/lib/config'; + +describe('slugify', () => { + it('converts a simple name to lowercase with hyphens', () => { + expect(slugify('Kloster Chorin')).toBe('kloster-chorin'); + }); + + it('strips diacritics', () => { + expect(slugify('Schöne Aussicht')).toBe('schone-aussicht'); + expect(slugify('Café Müller')).toBe('cafe-muller'); + }); + + it('replaces special characters with hyphens', () => { + expect(slugify('Hello, World!')).toBe('hello-world'); + expect(slugify('foo & bar (baz)')).toBe('foo-bar-baz'); + }); + + it('trims leading and trailing hyphens', () => { + expect(slugify('--leading')).toBe('leading'); + expect(slugify('trailing---')).toBe('trailing'); + expect(slugify('---both---')).toBe('both'); + }); + + it('collapses consecutive hyphens', () => { + expect(slugify('a b c')).toBe('a-b-c'); + }); + + it('handles an empty string', () => { + expect(slugify('')).toBe(''); + }); + + it('handles all-special-character input', () => { + expect(slugify('!@#$%')).toBe(''); + }); + + it('preserves numbers', () => { + expect(slugify('Album 2024')).toBe('album-2024'); + }); +}); diff --git a/lib/__tests__/thumbhash.test.ts b/lib/__tests__/thumbhash.test.ts new file mode 100644 index 0000000..041478f --- /dev/null +++ b/lib/__tests__/thumbhash.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { thumbHashToBlurDataUrl, thumbHashToDominantHex } from '@/lib/thumbhash'; + +// A real ThumbHash captured from an Immich asset (13 bytes, valid for the library) +// This is a minimal valid ThumbHash that produces a tiny image. +// Generate one via: thumbhash.rgbaToThumbHash(w, h, rgba) +// For testing, we use a known-good base64 string that the library accepts. +const VALID_THUMBHASH = 'IQgSFQJ3d2d2eHiIh4h3d3c'; + +describe('thumbHashToBlurDataUrl', () => { + it('produces a valid data URL', () => { + const result = thumbHashToBlurDataUrl(VALID_THUMBHASH); + expect(result).toMatch(/^data:image\/png;base64,/); + }); + + it('produces a non-trivial string (has actual image data)', () => { + const result = thumbHashToBlurDataUrl(VALID_THUMBHASH); + expect(result.length).toBeGreaterThan(30); + }); +}); + +describe('thumbHashToDominantHex', () => { + it('returns a valid hex color string', () => { + const result = thumbHashToDominantHex(VALID_THUMBHASH); + expect(result).toMatch(/^#[0-9a-f]{6}$/i); + }); +}); diff --git a/lib/__tests__/tokens.test.ts b/lib/__tests__/tokens.test.ts new file mode 100644 index 0000000..4b10e14 --- /dev/null +++ b/lib/__tests__/tokens.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the config module so tokens.ts can resolve getConfig().immich.apiKey +vi.mock('@/lib/config', () => ({ + getConfig: () => ({ + immich: { apiKey: 'test-api-key-for-vitest-unit-tests' }, + }), +})); + +import { encodeAssetId, decodeAssetId } from '@/lib/tokens'; + +const SAMPLE_UUID = '550e8400-e29b-41d4-a716-446655440000'; + +describe('tokens', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('encodeAssetId', () => { + it('produces a non-empty base64url string', () => { + const token = encodeAssetId(SAMPLE_UUID); + expect(token).toBeTruthy(); + expect(typeof token).toBe('string'); + // base64url characters only + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('is deterministic — same input yields same token', () => { + const t1 = encodeAssetId(SAMPLE_UUID); + const t2 = encodeAssetId(SAMPLE_UUID); + expect(t1).toBe(t2); + }); + + it('produces different tokens for different UUIDs', () => { + const t1 = encodeAssetId(SAMPLE_UUID); + const t2 = encodeAssetId('12345678-aaaa-bbbb-cccc-123456789abc'); + expect(t1).not.toBe(t2); + }); + }); + + describe('decodeAssetId', () => { + it('round-trips a valid UUID', () => { + const token = encodeAssetId(SAMPLE_UUID); + const decoded = decodeAssetId(token); + expect(decoded).toBe(SAMPLE_UUID); + }); + + it('returns null for a garbage string', () => { + expect(decodeAssetId('not-a-valid-token')).toBeNull(); + }); + + it('returns null for an empty string', () => { + expect(decodeAssetId('')).toBeNull(); + }); + + it('returns null for a token that is too short', () => { + // less than 17 bytes when decoded + const short = Buffer.from('shortdata').toString('base64url'); + expect(decodeAssetId(short)).toBeNull(); + }); + }); +}); diff --git a/lib/__tests__/urls.test.ts b/lib/__tests__/urls.test.ts new file mode 100644 index 0000000..71bdb3a --- /dev/null +++ b/lib/__tests__/urls.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock tokens so we get predictable URL output +vi.mock('@/lib/tokens', () => ({ + encodeAssetId: (id: string) => `ENCODED_${id}`, +})); + +// Mock thumbhash helpers +vi.mock('@/lib/thumbhash', () => ({ + thumbHashToBlurDataUrl: () => 'data:image/png;base64,mockblur', + thumbHashToDominantHex: () => '#aabbcc', +})); + +import { + imageUrl, + exifUrl, + assetPlaceholder, + assetExifSummary, + assetAspectRatio, +} from '@/lib/urls'; + +describe('imageUrl', () => { + it('generates a proxy URL with encoded token and default size', () => { + const url = imageUrl('some-uuid'); + expect(url).toBe('/api/image/ENCODED_some-uuid?size=preview'); + }); + + it('uses the specified size parameter', () => { + expect(imageUrl('id', 'thumbnail')).toBe('/api/image/ENCODED_id?size=thumbnail'); + expect(imageUrl('id', 'original')).toBe('/api/image/ENCODED_id?size=original'); + }); +}); + +describe('exifUrl', () => { + it('generates an EXIF API URL with encoded token', () => { + expect(exifUrl('my-asset')).toBe('/api/exif/ENCODED_my-asset'); + }); +}); + +describe('assetPlaceholder', () => { + it('returns blur data and dominant color for an asset with thumbhash', () => { + const result = assetPlaceholder({ thumbhash: 'abc123' }); + expect(result).toEqual({ + blurDataURL: 'data:image/png;base64,mockblur', + dominantColor: '#aabbcc', + }); + }); + + it('returns null when thumbhash is null', () => { + expect(assetPlaceholder({ thumbhash: null })).toBeNull(); + }); + + it('returns null when thumbhash is empty string', () => { + expect(assetPlaceholder({ thumbhash: '' })).toBeNull(); + }); +}); + +describe('assetExifSummary', () => { + it('returns camera, lens, and focal length from exifInfo', () => { + const result = assetExifSummary({ + exifInfo: { + make: 'Leica', + model: 'M11-P', + lensModel: 'Summilux-M 50mm', + focalLength: 50, + fNumber: null, + exposureTime: null, + iso: null, + exifImageWidth: null, + exifImageHeight: null, + city: null, + state: null, + country: null, + dateTimeOriginal: null, + description: null, + }, + }); + expect(result).toEqual({ + camera: 'M11-P', + lens: 'Summilux-M 50mm', + focalLength: '50mm', + }); + }); + + it('returns undefined when no exifInfo exists', () => { + expect(assetExifSummary({ exifInfo: undefined })).toBeUndefined(); + }); + + it('returns undefined when all relevant fields are null', () => { + expect( + assetExifSummary({ + exifInfo: { + make: null, + model: null, + lensModel: null, + focalLength: null, + fNumber: null, + exposureTime: null, + iso: null, + exifImageWidth: null, + exifImageHeight: null, + city: null, + state: null, + country: null, + dateTimeOriginal: null, + description: null, + }, + }), + ).toBeUndefined(); + }); +}); + +describe('assetAspectRatio', () => { + it('computes width / height ratio', () => { + const result = assetAspectRatio({ + exifInfo: { + make: null, + model: null, + lensModel: null, + focalLength: null, + fNumber: null, + exposureTime: null, + iso: null, + exifImageWidth: 3000, + exifImageHeight: 2000, + city: null, + state: null, + country: null, + dateTimeOriginal: null, + description: null, + }, + }); + expect(result).toBe(1.5); + }); + + it('returns undefined when dimensions are missing', () => { + expect(assetAspectRatio({ exifInfo: undefined })).toBeUndefined(); + }); + + it('returns undefined when height is 0', () => { + expect( + assetAspectRatio({ + exifInfo: { + make: null, + model: null, + lensModel: null, + focalLength: null, + fNumber: null, + exposureTime: null, + iso: null, + exifImageWidth: 100, + exifImageHeight: 0, + city: null, + state: null, + country: null, + dateTimeOriginal: null, + description: null, + }, + }), + ).toBeUndefined(); + }); +}); diff --git a/lib/auth.ts b/lib/auth.ts index 7061c25..90bfeb9 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -12,52 +12,46 @@ import { getConfig, SubpageConfig } from './config'; const TOKEN_EXPIRY_HOURS = 24; function hmac(data: string): string { - return crypto - .createHmac('sha256', getConfig().immich.apiKey) - .update(data) - .digest('hex'); + return crypto.createHmac('sha256', getConfig().immich.apiKey).update(data).digest('hex'); } function authToken(slug: string, password: string): string { - return hmac(`${slug}:${password}`); + return hmac(`${slug}:${password}`); } function cookieName(slug: string): string { - return `lb_auth_${slug}`; + return `lb_auth_${slug}`; } /** * Find the SubpageConfig for a given slug. */ export function findSubpageBySlug(slug: string): SubpageConfig | undefined { - return getConfig().subpages.find((sp) => sp.slug === slug); + return getConfig().subpages.find((sp) => sp.slug === slug); } /** * Check if a slug corresponds to a password-protected subpage. */ export function isProtected(slug: string): boolean { - const sp = findSubpageBySlug(slug); - return !!sp?.password; + const sp = findSubpageBySlug(slug); + return !!sp?.password; } /** * Validate a password attempt and return a Set-Cookie header value on success. * Returns null if the password is wrong. */ -export function authenticate( - slug: string, - password: string, -): string | null { - const sp = findSubpageBySlug(slug); - if (!sp?.password) return null; +export function authenticate(slug: string, password: string): string | null { + const sp = findSubpageBySlug(slug); + if (!sp?.password) return null; - if (password !== sp.password) return null; + if (password !== sp.password) return null; - const token = authToken(slug, password); - const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60; + const token = authToken(slug, password); + const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60; - return `${cookieName(slug)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict`; + return `${cookieName(slug)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict`; } /** @@ -65,23 +59,20 @@ export function authenticate( * Works with the cookies() API from Next.js server components. */ export function isAuthenticated( - slug: string, - getCookie: (name: string) => string | undefined, + slug: string, + getCookie: (name: string) => string | undefined, ): boolean { - const sp = findSubpageBySlug(slug); - if (!sp?.password) return true; // not protected + const sp = findSubpageBySlug(slug); + if (!sp?.password) return true; // not protected - const cookie = getCookie(cookieName(slug)); - if (!cookie) return false; + const cookie = getCookie(cookieName(slug)); + if (!cookie) return false; - const expected = authToken(slug, sp.password); - // Constant-time comparison to prevent timing attacks - try { - return crypto.timingSafeEqual( - Buffer.from(cookie, 'hex'), - Buffer.from(expected, 'hex'), - ); - } catch { - return false; - } + const expected = authToken(slug, sp.password); + // Constant-time comparison to prevent timing attacks + try { + return crypto.timingSafeEqual(Buffer.from(cookie, 'hex'), Buffer.from(expected, 'hex')); + } catch { + return false; + } } diff --git a/lib/cache.ts b/lib/cache.ts index 228bffe..325ed44 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -4,42 +4,42 @@ */ interface CacheEntry { - data: T; - expiresAt: number; + data: T; + expiresAt: number; } class MemoryCache { - private store = new Map>(); - private maxEntries = 200; + private store = new Map>(); + private maxEntries = 200; - get(key: string): T | null { - const entry = this.store.get(key); - if (!entry) return null; + get(key: string): T | null { + const entry = this.store.get(key); + if (!entry) return null; - if (Date.now() > entry.expiresAt) { - this.store.delete(key); - return null; - } - - return entry.data as T; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; } - set(key: string, data: T, ttlMs: number): void { - // Evict oldest entries if at capacity - if (this.store.size >= this.maxEntries) { - const firstKey = this.store.keys().next().value; - if (firstKey) this.store.delete(firstKey); - } + return entry.data as T; + } - this.store.set(key, { - data, - expiresAt: Date.now() + ttlMs, - }); + set(key: string, data: T, ttlMs: number): void { + // Evict oldest entries if at capacity + if (this.store.size >= this.maxEntries) { + const firstKey = this.store.keys().next().value; + if (firstKey) this.store.delete(firstKey); } - clear(): void { - this.store.clear(); - } + this.store.set(key, { + data, + expiresAt: Date.now() + ttlMs, + }); + } + + clear(): void { + this.store.clear(); + } } export const cache = new MemoryCache(); diff --git a/lib/config.ts b/lib/config.ts index 9deb896..ad5a72d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -6,16 +6,9 @@ import fs from 'fs'; import path from 'path'; import yaml from 'js-yaml'; +import { env } from './env'; -// ── Env helpers ──────────────────────────────────────────────── - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`Missing required environment variable: ${name}`); - } - return value; -} +// ── Env helpers (now handled by lib/env.ts via Zod) ─────────── // ── Types ────────────────────────────────────────────────────── @@ -30,6 +23,14 @@ export interface SubpageConfig { grid?: Partial; } +/** Footer configuration for social links. */ +export interface FooterConfig { + name?: string; + instagram?: string; + email?: string; + website?: string; +} + /** Raw YAML structure (before validation). */ interface GalleryYaml { hero?: string | string[]; @@ -41,6 +42,12 @@ interface GalleryYaml { aspectRatio?: string; layout?: string; }; + footer?: { + name?: string; + instagram?: string; + email?: string; + website?: string; + }; subpages?: Array<{ name: string; albums: string[]; @@ -62,7 +69,7 @@ function loadGalleryYaml(): GalleryYaml { if (!fs.existsSync(yamlPath)) { throw new Error( `Gallery config not found: ${yamlPath}\n` + - `Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`, + `Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`, ); } @@ -99,8 +106,8 @@ export function slugify(name: string): string { .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') // strip diacritics (ü→u, é→e) - .replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens - .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens + .replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens + .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens } // ── Config type ──────────────────────────────────────────────── @@ -126,6 +133,8 @@ export interface AppConfig { exifOnHover: boolean; /** Photo grid layout configuration. */ grid: GridConfig; + /** Footer social links config. */ + footer: FooterConfig | null; cacheTtl: number; rateLimitRpm: number; } @@ -137,13 +146,14 @@ let _config: AppConfig | null = null; export function getConfig(): AppConfig { if (_config) return _config; - const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, ''); - const apiKey = requireEnv('IMMICH_API_KEY'); + const apiUrl = env.IMMICH_API_URL; + const apiKey = env.IMMICH_API_KEY; const gallery = loadGalleryYaml(); // Parse standalone albums - const standaloneAlbumIds = (gallery.albums ?? []) - .map((id) => validateUuid(id, 'gallery.yaml albums')); + const standaloneAlbumIds = (gallery.albums ?? []).map((id) => + validateUuid(id, 'gallery.yaml albums'), + ); if (standaloneAlbumIds.length === 0 && (!gallery.subpages || gallery.subpages.length === 0)) { throw new Error('gallery.yaml must define at least one album or subpage'); @@ -152,21 +162,31 @@ export function getConfig(): AppConfig { // Parse subpages const subpages: SubpageConfig[] = (gallery.subpages ?? []).map((sp) => { if (!sp.name || !sp.albums || sp.albums.length === 0) { - throw new Error(`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`); + throw new Error( + `Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`, + ); } return { name: sp.name, slug: slugify(sp.name), albumIds: sp.albums.map((id) => validateUuid(id, `subpage "${sp.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: (sp.grid.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'] } : {}), - }, - } : {}), + ...(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: (sp.grid.layout === 'uniform' + ? 'uniform' + : 'masonry') as GridConfig['layout'], + } + : {}), + }, + } + : {}), }; }); @@ -182,11 +202,12 @@ export function getConfig(): AppConfig { albums: allAlbumIds, standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)), subpages, - siteTitle: process.env.SITE_TITLE || 'Gallery', - siteSubtitle: process.env.SITE_SUBTITLE || '', + siteTitle: env.SITE_TITLE, + siteSubtitle: env.SITE_SUBTITLE, heroImages: gallery.hero - ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]) - .map((id) => validateUuid(id, 'gallery.yaml hero')) + ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => + validateUuid(id, 'gallery.yaml hero'), + ) : [], exifOnHover: gallery.exifOnHover !== false, grid: { @@ -195,8 +216,16 @@ export function getConfig(): AppConfig { aspectRatio: gallery.grid?.aspectRatio ?? '1', layout: (gallery.grid?.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'], }, - cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000, - rateLimitRpm: parseInt(process.env.RATE_LIMIT_RPM || '120', 10), + footer: gallery.footer + ? { + name: gallery.footer.name, + instagram: gallery.footer.instagram, + email: gallery.footer.email, + website: gallery.footer.website, + } + : null, + cacheTtl: env.CACHE_TTL * 1000, + rateLimitRpm: env.RATE_LIMIT_RPM, }; return _config; diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 0000000..855b6eb --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,45 @@ +/** + * Zod-validated environment variables. + * Parsed once at startup — all env access should go through this module. + */ + +import { z } from 'zod'; + +const envSchema = z.object({ + /** Base URL of your Immich instance, e.g. https://immich.example.com */ + IMMICH_API_URL: z + .string() + .url('IMMICH_API_URL must be a valid URL') + .transform((url) => url.replace(/\/+$/, '')), + + /** Immich API key (from Admin → API Keys) */ + IMMICH_API_KEY: z.string().min(1, 'IMMICH_API_KEY is required'), + + /** Public site title shown in the header */ + SITE_TITLE: z.string().default('Gallery'), + + /** Subtitle displayed below the title */ + SITE_SUBTITLE: z.string().default(''), + + /** Cache TTL in seconds */ + CACHE_TTL: z.coerce.number().int().min(0).default(300), + + /** Rate limit: max requests per minute per IP */ + RATE_LIMIT_RPM: z.coerce.number().int().min(1).default(120), +}); + +export type Env = z.infer; + +function parseEnv(): Env { + const result = envSchema.safeParse(process.env); + if (!result.success) { + const formatted = result.error.issues + .map((issue) => ` ✗ ${issue.path.join('.')}: ${issue.message}`) + .join('\n'); + throw new Error(`\n❌ Environment validation failed:\n${formatted}\n`); + } + return result.data; +} + +/** Validated, typed environment variables. */ +export const env = parseEnv(); diff --git a/lib/immich.ts b/lib/immich.ts index 4f181b5..7de14f8 100644 --- a/lib/immich.ts +++ b/lib/immich.ts @@ -13,310 +13,312 @@ import { cache } from './cache'; // ── Types ────────────────────────────────────────────────────── export interface ImmichAlbum { - id: string; - slug: string; - albumName: string; - description: string; - albumThumbnailAssetId: string | null; - assetCount: number; - assets: ImmichAsset[]; - createdAt: string; - updatedAt: string; - order: 'asc' | 'desc'; + id: string; + slug: string; + albumName: string; + description: string; + albumThumbnailAssetId: string | null; + assetCount: number; + assets: ImmichAsset[]; + createdAt: string; + updatedAt: string; + order: 'asc' | 'desc'; } export interface ImmichAsset { - id: string; - type: 'IMAGE' | 'VIDEO'; - originalFileName: string; - originalMimeType: string; - thumbhash: string | null; - fileCreatedAt: string; - exifInfo?: ImmichExifInfo; - isTrashed: boolean; + id: string; + type: 'IMAGE' | 'VIDEO'; + originalFileName: string; + originalMimeType: string; + thumbhash: string | null; + fileCreatedAt: string; + exifInfo?: ImmichExifInfo; + isTrashed: boolean; } export interface ImmichExifInfo { - make: string | null; - model: string | null; - lensModel: string | null; - focalLength: number | null; - fNumber: number | null; - exposureTime: string | null; - iso: number | null; - exifImageWidth: number | null; - exifImageHeight: number | null; - city: string | null; - state: string | null; - country: string | null; - dateTimeOriginal: string | null; - description: string | null; + make: string | null; + model: string | null; + lensModel: string | null; + focalLength: number | null; + fNumber: number | null; + exposureTime: string | null; + iso: number | null; + exifImageWidth: number | null; + exifImageHeight: number | null; + city: string | null; + state: string | null; + country: string | null; + dateTimeOriginal: string | null; + description: string | null; } export type ImageSize = 'thumbnail' | 'preview' | 'original'; /** Enriched subpage with album metadata (for rendering cards). */ export interface SubpageSummary { - name: string; - slug: string; - albumCount: number; - totalAssetCount: number; - coverAssetId: string | null; + name: string; + slug: string; + albumCount: number; + totalAssetCount: number; + coverAssetId: string | null; } // ── API Client ───────────────────────────────────────────────── class ImmichClient { - private hasLoggedAlbums = false; + private hasLoggedAlbums = false; - private get config() { - return getConfig(); + private get config() { + return getConfig(); + } + + /** + * Make an authenticated request to the Immich API. + */ + private async request(endpoint: string): Promise { + const url = `${this.config.immich.apiUrl}${endpoint}`; + try { + const res = await fetch(url, { + headers: { + 'x-api-key': this.config.immich.apiKey, + Accept: 'application/json', + }, + }); + + if (!res.ok) { + console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`); + return null; + } + + const contentType = res.headers.get('Content-Type') || ''; + if (contentType.includes('application/json')) { + return (await res.json()) as T; + } + return null; + } catch (error) { + console.error(`[Immich] Failed to reach ${url}:`, error); + return null; } + } - /** - * Make an authenticated request to the Immich API. - */ - private async request(endpoint: string): Promise { - const url = `${this.config.immich.apiUrl}${endpoint}`; - try { - const res = await fetch(url, { - headers: { - 'x-api-key': this.config.immich.apiKey, - Accept: 'application/json', - }, - }); + /** + * Stream a binary response from Immich (for image proxying). + */ + async streamAsset( + assetId: string, + size: ImageSize = 'preview', + ): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> { + const endpoint = + size === 'original' + ? `/assets/${encodeURIComponent(assetId)}/original` + : `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`; - if (!res.ok) { - console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`); - return null; - } + const url = `${this.config.immich.apiUrl}${endpoint}`; + try { + const res = await fetch(url, { + headers: { + 'x-api-key': this.config.immich.apiKey, + }, + }); - const contentType = res.headers.get('Content-Type') || ''; - if (contentType.includes('application/json')) { - return (await res.json()) as T; - } - return null; - } catch (error) { - console.error(`[Immich] Failed to reach ${url}:`, error); - return null; + if (!res.ok || !res.body) { + console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`); + return null; + } + + return { + stream: res.body, + contentType: res.headers.get('Content-Type') || 'application/octet-stream', + contentLength: res.headers.get('Content-Length'), + }; + } catch (error) { + console.error(`[Immich] Stream error for ${assetId}:`, error); + return null; + } + } + + /** + * Get ALL configured albums (filtered by the full allowlist). + * Uses ?shared=true to only fetch albums that have been shared in Immich. + */ + async getAlbums(): Promise { + const cacheKey = 'albums-list'; + const cached = cache.get(cacheKey); + if (cached) return cached; + + const all = await this.request('/albums?shared=true'); + if (!all) return []; + + const allowedIds = new Set(this.config.albums); + const filtered = all + .filter((album) => allowedIds.has(album.id)) + .map((album) => ({ + ...album, + slug: slugify(album.albumName), + })); + + // Log album summary on first load so admins can see what's published + if (!this.hasLoggedAlbums) { + this.hasLoggedAlbums = true; + console.log('\n[Lightbox] Published albums:'); + console.log('─'.repeat(80)); + + // Log standalone albums + const standaloneIds = new Set(this.config.standaloneAlbums); + const standalone = filtered.filter((a) => standaloneIds.has(a.id)); + if (standalone.length > 0) { + console.log(' Standalone:'); + for (const a of standalone) { + console.log(` 📷 ${a.albumName}`); + console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`); } - } + } - /** - * Stream a binary response from Immich (for image proxying). - */ - async streamAsset( - assetId: string, - size: ImageSize = 'preview', - ): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> { - const endpoint = - size === 'original' - ? `/assets/${encodeURIComponent(assetId)}/original` - : `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`; - - const url = `${this.config.immich.apiUrl}${endpoint}`; - try { - const res = await fetch(url, { - headers: { - 'x-api-key': this.config.immich.apiKey, - }, - }); - - if (!res.ok || !res.body) { - console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`); - return null; - } - - return { - stream: res.body, - contentType: res.headers.get('Content-Type') || 'application/octet-stream', - contentLength: res.headers.get('Content-Length'), - }; - } catch (error) { - console.error(`[Immich] Stream error for ${assetId}:`, error); - return null; + // Log subpage groupings + for (const sp of this.config.subpages) { + const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id)); + console.log(` 📁 ${sp.name} (/${sp.slug}):`); + for (const a of spAlbums) { + console.log(` 📷 ${a.albumName}`); + console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`); } + } + + const missing = this.config.albums.filter((id) => !all.some((a) => a.id === id)); + if (missing.length > 0) { + console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`); + } + console.log('─'.repeat(80) + '\n'); } - /** - * Get ALL configured albums (filtered by the full allowlist). - * Uses ?shared=true to only fetch albums that have been shared in Immich. - */ - async getAlbums(): Promise { - const cacheKey = 'albums-list'; - const cached = cache.get(cacheKey); - if (cached) return cached; + cache.set(cacheKey, filtered, this.config.cacheTtl); + return filtered; + } - const all = await this.request('/albums?shared=true'); - if (!all) return []; + /** + * Get only standalone albums (not in any subpage) — for the homepage. + */ + async getStandaloneAlbums(): Promise { + const albums = await this.getAlbums(); + const standaloneIds = new Set(this.config.standaloneAlbums); + return albums.filter((a) => standaloneIds.has(a.id)); + } - const allowedIds = new Set(this.config.albums); - const filtered = all - .filter((album) => allowedIds.has(album.id)) - .map((album) => ({ - ...album, - slug: slugify(album.albumName), - })); + /** + * Get enriched subpage summaries for the homepage. + */ + async getSubpages(): Promise { + if (this.config.subpages.length === 0) return []; - // Log album summary on first load so admins can see what's published - if (!this.hasLoggedAlbums) { - this.hasLoggedAlbums = true; - console.log('\n[Lightbox] Published albums:'); - console.log('─'.repeat(80)); + const albums = await this.getAlbums(); + const albumMap = new Map(albums.map((a) => [a.id, a])); - // Log standalone albums - const standaloneIds = new Set(this.config.standaloneAlbums); - const standalone = filtered.filter((a) => standaloneIds.has(a.id)); - if (standalone.length > 0) { - console.log(' Standalone:'); - for (const a of standalone) { - console.log(` 📷 ${a.albumName}`); - console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`); - } - } + return this.config.subpages.map((sp) => { + const spAlbums = sp.albumIds + .map((id) => albumMap.get(id)) + .filter((a): a is ImmichAlbum => a !== undefined); - // Log subpage groupings - for (const sp of this.config.subpages) { - const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id)); - console.log(` 📁 ${sp.name} (/${sp.slug}):`); - for (const a of spAlbums) { - console.log(` 📷 ${a.albumName}`); - console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`); - } - } + return { + name: sp.name, + slug: sp.slug, + albumCount: spAlbums.length, + totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0), + coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null, + }; + }); + } - const missing = this.config.albums.filter((id) => !all.some((a) => a.id === id)); - if (missing.length > 0) { - console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`); - } - console.log('─'.repeat(80) + '\n'); - } + /** + * Get albums belonging to a specific subpage. + */ + async getSubpageAlbums( + subpageSlug: string, + ): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> { + const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug); + if (!subpage) return null; - cache.set(cacheKey, filtered, this.config.cacheTtl); - return filtered; + const allAlbums = await this.getAlbums(); + const subpageAlbumIds = new Set(subpage.albumIds); + const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id)); + + return { subpage, albums }; + } + + /** + * Get a single album with its assets. + */ + async getAlbum(albumId: string): Promise { + // Security: only serve configured albums + if (!this.config.albums.includes(albumId)) { + console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`); + return null; } - /** - * Get only standalone albums (not in any subpage) — for the homepage. - */ - async getStandaloneAlbums(): Promise { - const albums = await this.getAlbums(); - const standaloneIds = new Set(this.config.standaloneAlbums); - return albums.filter((a) => standaloneIds.has(a.id)); + const cacheKey = `album-${albumId}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + const album = await this.request(`/albums/${encodeURIComponent(albumId)}`); + if (!album) return null; + + // Filter out trashed assets + album.assets = (album.assets || []).filter((a) => !a.isTrashed); + album.slug = slugify(album.albumName); + + cache.set(cacheKey, album, this.config.cacheTtl); + return album; + } + + /** + * Find an album by its URL slug. + * If subpageSlug is provided, only search within that subpage's albums. + */ + async getAlbumBySlug(slug: string, subpageSlug?: string): Promise { + const albums = await this.getAlbums(); + + let searchSet = albums; + if (subpageSlug) { + const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug); + if (!subpage) return null; + const subpageIds = new Set(subpage.albumIds); + searchSet = albums.filter((a) => subpageIds.has(a.id)); } - /** - * Get enriched subpage summaries for the homepage. - */ - async getSubpages(): Promise { - if (this.config.subpages.length === 0) return []; + const match = searchSet.find((a) => a.slug === slug); + if (!match) return null; + return this.getAlbum(match.id); + } - const albums = await this.getAlbums(); - const albumMap = new Map(albums.map((a) => [a.id, a])); + /** + * Check if a slug corresponds to a subpage. + */ + isSubpageSlug(slug: string): boolean { + return this.config.subpages.some((sp) => sp.slug === slug); + } - return this.config.subpages.map((sp) => { - const spAlbums = sp.albumIds - .map((id) => albumMap.get(id)) - .filter((a): a is ImmichAlbum => a !== undefined); + /** + * Get asset info including EXIF data. + */ + async getAssetInfo(assetId: string): Promise { + const cacheKey = `asset-${assetId}`; + const cached = cache.get(cacheKey); + if (cached) return cached; - return { - name: sp.name, - slug: sp.slug, - albumCount: spAlbums.length, - totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0), - coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null, - }; - }); - } + const asset = await this.request(`/assets/${encodeURIComponent(assetId)}`); + if (!asset) return null; - /** - * Get albums belonging to a specific subpage. - */ - async getSubpageAlbums(subpageSlug: string): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> { - const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug); - if (!subpage) return null; + cache.set(cacheKey, asset, this.config.cacheTtl); + return asset; + } - const allAlbums = await this.getAlbums(); - const subpageAlbumIds = new Set(subpage.albumIds); - const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id)); - - return { subpage, albums }; - } - - /** - * Get a single album with its assets. - */ - async getAlbum(albumId: string): Promise { - // Security: only serve configured albums - if (!this.config.albums.includes(albumId)) { - console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`); - return null; - } - - const cacheKey = `album-${albumId}`; - const cached = cache.get(cacheKey); - if (cached) return cached; - - const album = await this.request(`/albums/${encodeURIComponent(albumId)}`); - if (!album) return null; - - // Filter out trashed assets - album.assets = (album.assets || []).filter((a) => !a.isTrashed); - album.slug = slugify(album.albumName); - - cache.set(cacheKey, album, this.config.cacheTtl); - return album; - } - - /** - * Find an album by its URL slug. - * If subpageSlug is provided, only search within that subpage's albums. - */ - async getAlbumBySlug(slug: string, subpageSlug?: string): Promise { - const albums = await this.getAlbums(); - - let searchSet = albums; - if (subpageSlug) { - const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug); - if (!subpage) return null; - const subpageIds = new Set(subpage.albumIds); - searchSet = albums.filter((a) => subpageIds.has(a.id)); - } - - const match = searchSet.find((a) => a.slug === slug); - if (!match) return null; - return this.getAlbum(match.id); - } - - /** - * Check if a slug corresponds to a subpage. - */ - isSubpageSlug(slug: string): boolean { - return this.config.subpages.some((sp) => sp.slug === slug); - } - - /** - * Get asset info including EXIF data. - */ - async getAssetInfo(assetId: string): Promise { - const cacheKey = `asset-${assetId}`; - const cached = cache.get(cacheKey); - if (cached) return cached; - - const asset = await this.request(`/assets/${encodeURIComponent(assetId)}`); - if (!asset) return null; - - cache.set(cacheKey, asset, this.config.cacheTtl); - return asset; - } - - /** - * Check if the Immich server is reachable. - */ - async ping(): Promise { - const res = await this.request<{ res: string }>('/server/ping'); - return !!res; - } + /** + * Check if the Immich server is reachable. + */ + async ping(): Promise { + const res = await this.request<{ res: string }>('/server/ping'); + return !!res; + } } export const immich = new ImmichClient(); diff --git a/lib/immichLoader.ts b/lib/immichLoader.ts index fe83daa..0d3a8c7 100644 --- a/lib/immichLoader.ts +++ b/lib/immichLoader.ts @@ -13,12 +13,12 @@ 'use client'; interface ImageLoaderParams { - src: string; - width: number; - quality?: number; + src: string; + width: number; + quality?: number; } export default function immichLoader({ src, width, quality }: ImageLoaderParams): string { - const separator = src.includes('?') ? '&' : '?'; - return `${src}${separator}w=${width}&q=${quality || 75}`; + const separator = src.includes('?') ? '&' : '?'; + return `${src}${separator}w=${width}&q=${quality || 75}`; } diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 48ebd83..5386f80 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -5,8 +5,8 @@ */ interface RateLimitEntry { - count: number; - expiresAt: number; + count: number; + expiresAt: number; } const store = new Map(); @@ -16,12 +16,12 @@ let lastCleanup = Date.now(); const CLEANUP_INTERVAL = 60_000; function cleanup() { - const now = Date.now(); - if (now - lastCleanup < CLEANUP_INTERVAL) return; - lastCleanup = now; - for (const [key, entry] of store) { - if (now > entry.expiresAt) store.delete(key); - } + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL) return; + lastCleanup = now; + for (const [key, entry] of store) { + if (now > entry.expiresAt) store.delete(key); + } } /** @@ -31,29 +31,29 @@ function cleanup() { * @returns { success, remaining, resetAt (epoch ms) } */ export function checkRateLimit( - ip: string, - maxRpm: number, + ip: string, + maxRpm: number, ): { success: boolean; remaining: number; resetAt: number } { - cleanup(); + cleanup(); - const now = Date.now(); - const windowMs = 60_000; - const key = `rl:${ip}`; + const now = Date.now(); + const windowMs = 60_000; + const key = `rl:${ip}`; - const entry = store.get(key); + const entry = store.get(key); - // New window or expired - if (!entry || now > entry.expiresAt) { - const resetAt = now + windowMs; - store.set(key, { count: 1, expiresAt: resetAt }); - return { success: true, remaining: maxRpm - 1, resetAt }; - } + // New window or expired + if (!entry || now > entry.expiresAt) { + const resetAt = now + windowMs; + store.set(key, { count: 1, expiresAt: resetAt }); + return { success: true, remaining: maxRpm - 1, resetAt }; + } - // Within window - entry.count++; - if (entry.count > maxRpm) { - return { success: false, remaining: 0, resetAt: entry.expiresAt }; - } + // Within window + entry.count++; + if (entry.count > maxRpm) { + return { success: false, remaining: 0, resetAt: entry.expiresAt }; + } - return { success: true, remaining: maxRpm - entry.count, resetAt: entry.expiresAt }; + return { success: true, remaining: maxRpm - entry.count, resetAt: entry.expiresAt }; } diff --git a/lib/thumbhash.ts b/lib/thumbhash.ts index 2d03c0a..f0c1f09 100644 --- a/lib/thumbhash.ts +++ b/lib/thumbhash.ts @@ -11,8 +11,8 @@ import { thumbHashToRGBA, thumbHashToDataURL } from 'thumbhash'; * suitable for next/image's blurDataURL prop. */ export function thumbHashToBlurDataUrl(base64: string): string { - const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); - return thumbHashToDataURL(bytes); + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + return thumbHashToDataURL(bytes); } /** @@ -20,23 +20,29 @@ export function thumbHashToBlurDataUrl(base64: string): string { * Computes the weighted average of all non-transparent pixels. */ export function thumbHashToDominantHex(base64: string): string { - const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); - const { w, h, rgba } = thumbHashToRGBA(bytes); + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + const { w, h, rgba } = thumbHashToRGBA(bytes); - let r = 0, g = 0, b = 0, total = 0; - for (let i = 0; i < w * h; i++) { - const a = rgba[i * 4 + 3]; - if (a > 0) { - const weight = a / 255; - r += rgba[i * 4] * weight; - g += rgba[i * 4 + 1] * weight; - b += rgba[i * 4 + 2] * weight; - total += weight; - } + let r = 0, + g = 0, + b = 0, + total = 0; + for (let i = 0; i < w * h; i++) { + const a = rgba[i * 4 + 3]; + if (a > 0) { + const weight = a / 255; + r += rgba[i * 4] * weight; + g += rgba[i * 4 + 1] * weight; + b += rgba[i * 4 + 2] * weight; + total += weight; } + } - if (total === 0) return '#888888'; + if (total === 0) return '#888888'; - const avg = (v: number) => Math.round(v / total).toString(16).padStart(2, '0'); - return `#${avg(r)}${avg(g)}${avg(b)}`; + const avg = (v: number) => + Math.round(v / total) + .toString(16) + .padStart(2, '0'); + return `#${avg(r)}${avg(g)}${avg(b)}`; } diff --git a/lib/tokens.ts b/lib/tokens.ts index 1088e70..dec7942 100644 --- a/lib/tokens.ts +++ b/lib/tokens.ts @@ -12,27 +12,24 @@ import { getConfig } from './config'; let _key: Buffer | null = null; function getKey(): Buffer { - if (!_key) { - const apiKey = getConfig().immich.apiKey; - _key = crypto.createHash('sha256').update(apiKey).digest(); - } - return _key; + if (!_key) { + const apiKey = getConfig().immich.apiKey; + _key = crypto.createHash('sha256').update(apiKey).digest(); + } + return _key; } /** * Encode an asset ID into an opaque URL-safe token. */ export function encodeAssetId(assetId: string): string { - const key = getKey(); - // Deterministic IV from asset ID (same input → same token) - const iv = crypto.createHash('md5').update(assetId).digest(); - const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); - const encrypted = Buffer.concat([ - cipher.update(assetId, 'utf8'), - cipher.final(), - ]); - // Compact: base64url(iv + ciphertext) - return Buffer.concat([iv, encrypted]).toString('base64url'); + const key = getKey(); + // Deterministic IV from asset ID (same input → same token) + const iv = crypto.createHash('md5').update(assetId).digest(); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]); + // Compact: base64url(iv + ciphertext) + return Buffer.concat([iv, encrypted]).toString('base64url'); } /** @@ -40,23 +37,22 @@ export function encodeAssetId(assetId: string): string { * Returns null if the token is invalid. */ export function decodeAssetId(token: string): string | null { - try { - const key = getKey(); - const data = Buffer.from(token, 'base64url'); - if (data.length < 17) return null; // iv(16) + at least 1 byte + try { + const key = getKey(); + const data = Buffer.from(token, 'base64url'); + if (data.length < 17) return null; // iv(16) + at least 1 byte - const iv = data.subarray(0, 16); - const encrypted = data.subarray(16); - const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final(), - ]).toString('utf8'); + const iv = data.subarray(0, 16); + const encrypted = data.subarray(16); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString( + 'utf8', + ); - // Validate it looks like a UUID - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - return uuidRegex.test(decrypted) ? decrypted : null; - } catch { - return null; - } + // Validate it looks like a UUID + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(decrypted) ? decrypted : null; + } catch { + return null; + } } diff --git a/lib/urls.ts b/lib/urls.ts index 8d653b1..ef57065 100644 --- a/lib/urls.ts +++ b/lib/urls.ts @@ -10,23 +10,26 @@ import type { ImmichAsset } from './immich'; /** * Generate a public image proxy URL for an asset. */ -export function imageUrl(assetId: string, size: 'thumbnail' | 'preview' | 'original' = 'preview'): string { - return `/api/image/${encodeAssetId(assetId)}?size=${size}`; +export function imageUrl( + assetId: string, + size: 'thumbnail' | 'preview' | 'original' = 'preview', +): string { + return `/api/image/${encodeAssetId(assetId)}?size=${size}`; } /** * Generate a public EXIF API URL for an asset. */ export function exifUrl(assetId: string): string { - return `/api/exif/${encodeAssetId(assetId)}`; + return `/api/exif/${encodeAssetId(assetId)}`; } /** * Placeholder data derived from an asset's ThumbHash. */ export interface PlaceholderData { - blurDataURL: string; - dominantColor: string; + blurDataURL: string; + dominantColor: string; } /** @@ -34,24 +37,24 @@ export interface PlaceholderData { * Returns null if the asset has no ThumbHash. */ export function assetPlaceholder(asset: Pick): PlaceholderData | null { - if (!asset.thumbhash) return null; - try { - return { - blurDataURL: thumbHashToBlurDataUrl(asset.thumbhash), - dominantColor: thumbHashToDominantHex(asset.thumbhash), - }; - } catch { - return null; - } + if (!asset.thumbhash) return null; + try { + return { + blurDataURL: thumbHashToBlurDataUrl(asset.thumbhash), + dominantColor: thumbHashToDominantHex(asset.thumbhash), + }; + } catch { + return null; + } } /** * Compact EXIF summary for hover overlays. */ export interface ExifSummary { - camera?: string; - lens?: string; - focalLength?: string; + camera?: string; + lens?: string; + focalLength?: string; } /** @@ -59,15 +62,15 @@ export interface ExifSummary { * Returns undefined if no relevant EXIF data is available. */ export function assetExifSummary(asset: Pick): ExifSummary | undefined { - const exif = asset.exifInfo; - if (!exif) return undefined; + const exif = asset.exifInfo; + if (!exif) return undefined; - const camera = exif.model || undefined; - const lens = exif.lensModel || undefined; - const focalLength = exif.focalLength ? `${exif.focalLength}mm` : undefined; + const camera = exif.model || undefined; + const lens = exif.lensModel || undefined; + const focalLength = exif.focalLength ? `${exif.focalLength}mm` : undefined; - if (!camera && !lens && !focalLength) return undefined; - return { camera, lens, focalLength }; + if (!camera && !lens && !focalLength) return undefined; + return { camera, lens, focalLength }; } /** @@ -75,8 +78,8 @@ export function assetExifSummary(asset: Pick): ExifSumm * Returns undefined if dimensions are not available. */ export function assetAspectRatio(asset: Pick): number | undefined { - const w = asset.exifInfo?.exifImageWidth; - const h = asset.exifInfo?.exifImageHeight; - if (w && h && h > 0) return w / h; - return undefined; + const w = asset.exifInfo?.exifImageWidth; + const h = asset.exifInfo?.exifImageHeight; + if (w && h && h > 0) return w / h; + return undefined; } diff --git a/next.config.ts b/next.config.ts index 0c77ac1..caa9a2e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,4 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'standalone', diff --git a/package-lock.json b/package-lock.json index 9cd2cc7..922318f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,16 +13,23 @@ "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", - "thumbhash": "^0.1.1" + "thumbhash": "^0.1.1", + "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9", "eslint-config-next": "16.1.6", - "typescript": "^5" + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "prettier": "^3.8.1", + "typescript": "^5", + "vitest": "^4.0.18" } }, "node_modules/@babel/code-frame": { @@ -265,6 +272,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -298,6 +315,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1215,6 +1674,385 @@ "node": ">=12.4.0" } }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1222,6 +2060,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1242,6 +2087,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1838,6 +2701,148 @@ "win32" ] }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2070,6 +3075,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2077,6 +3092,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2277,6 +3311,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2647,6 +3691,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2707,6 +3758,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2830,6 +3923,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -2989,6 +4098,37 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -3157,6 +4297,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3167,6 +4317,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -3186,6 +4346,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -3320,6 +4487,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3655,6 +4837,13 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4145,6 +5334,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -4344,6 +5572,57 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4632,6 +5911,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4740,6 +6030,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4759,6 +6056,52 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -4807,6 +6150,35 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4974,6 +6346,51 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5288,6 +6705,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5310,6 +6734,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5518,12 +6956,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/thumbhash": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz", "integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5572,6 +7043,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5861,6 +7342,232 @@ "punycode": "^2.1.0" } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5966,6 +7673,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6000,7 +7724,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 0444e3c..02bb7c8 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,13 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "vitest", + "test:unit": "vitest run", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test" }, "dependencies": { "gray-matter": "^4.0.3", @@ -14,15 +20,22 @@ "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", - "thumbhash": "^0.1.1" + "thumbhash": "^0.1.1", + "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9", "eslint-config-next": "16.1.6", - "typescript": "^5" + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "prettier": "^3.8.1", + "typescript": "^5", + "vitest": "^4.0.18" } } diff --git a/plans/2026-02-13-immich-lightbox-v1.md b/plans/2026-02-13-immich-lightbox-v1.md index 68bc680..ce9e824 100644 --- a/plans/2026-02-13-immich-lightbox-v1.md +++ b/plans/2026-02-13-immich-lightbox-v1.md @@ -20,6 +20,7 @@ Build a public-facing web gallery that connects to a private Immich server via i The Lightbox Server sits between visitors and Immich. It proxies images, caches API responses, and holds the album selection config. The Immich server stays private and serves album data and original images only to the Lightbox Server. **Stack:** + - **Next.js 15** (App Router) — SSR for fast first paint, API routes for proxying - **TypeScript** — End-to-end type safety - **CSS Modules + CSS custom properties** — Clean, maintainable dark-theme styling diff --git a/plans/2026-02-14-developer-experience-testing-v1.md b/plans/2026-02-14-developer-experience-testing-v1.md index 6c1d217..f1fd81f 100644 --- a/plans/2026-02-14-developer-experience-testing-v1.md +++ b/plans/2026-02-14-developer-experience-testing-v1.md @@ -136,10 +136,10 @@ Upgrade the developer tooling and quality infrastructure for immich-lightbox. Th ## Suggested Implementation Order -| Phase | Feature | Dependency | -|-------|---------|------------| -| 1 | Prettier + ESLint | None — clean baseline first | -| 2 | Vitest unit tests | None — tests existing code | -| 3 | Zod env validation | Unit tests can verify schemas | -| 4 | CSS Modules migration | Lint/format tools catch issues | -| 5 | Playwright e2e tests | All other changes landed first | +| Phase | Feature | Dependency | +| ----- | --------------------- | ------------------------------ | +| 1 | Prettier + ESLint | None — clean baseline first | +| 2 | Vitest unit tests | None — tests existing code | +| 3 | Zod env validation | Unit tests can verify schemas | +| 4 | CSS Modules migration | Lint/format tools catch issues | +| 5 | Playwright e2e tests | All other changes landed first | diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..39ba6d9 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ca04f1b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['lib/__tests__/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['lib/**/*.ts'], + exclude: ['lib/__tests__/**'], + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, +});