feat: visual polish & UX — fade-in, theme toggle, scroll-to-top, footer, cursor, badge

- Add FadeIn component (IntersectionObserver scroll-reveal + load animation)
- Add dark/light theme toggle (useSyncExternalStore + localStorage)
- Add scroll-to-top button (appears after 300px scroll)
- Add configurable footer with social links (gallery.yaml)
- Add crosshair cursor on photo grid items
- Add 'X photos' count badge on subpage cover images
- Add light theme color palette in tokens.css
This commit is contained in:
ralksta
2026-02-14 11:47:26 +01:00
parent e26f091595
commit 52fa16c1c8
57 changed files with 4790 additions and 1984 deletions

View File

@@ -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_URL=http://your-immich-server:2283
# Immich API key (from Admin → API Keys)
IMMICH_API_KEY=your-api-key-here 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 SITE_TITLE=My Photography
# Subtitle displayed below the title (string, default: "")
SITE_SUBTITLE=A visual journal 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 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 # RATE_LIMIT_RPM=120
# ── Album configuration ── # ── Album configuration ──────────────────────────────────────────
# Albums, subpages, hero image, and passwords are defined in content/gallery.yaml # Albums, subpages, hero image, and passwords are defined in content/gallery.yaml
# See content/gallery.yaml.example for the full reference. # See content/gallery.yaml.example for the full reference.

2
.gitignore vendored
View File

@@ -12,6 +12,8 @@
# testing # testing
/coverage /coverage
/test-results/
/playwright-report/
# next.js # next.js
/.next/ /.next/

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.next
build
out
*.yaml
package-lock.json

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}

View File

@@ -27,15 +27,15 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
### Security ### Security
| Concern | Protection | | Concern | Protection |
|---------|-----------| | ----------------------- | ------------------------------------------------------------------------------------------------------- |
| **Server exposure** | Immich URL and port are never sent to the browser — all requests are proxied server-side | | **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 | | **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 | | **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 | | **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 | | **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) | | **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 | | **No direct access** | Visitors interact only with the Next.js app; they have zero knowledge of your Immich server's existence |
### Infrastructure ### Infrastructure
@@ -108,7 +108,7 @@ subpages:
grid: grid:
columns: 4 columns: 4
layout: uniform layout: uniform
aspectRatio: "3/2" aspectRatio: '3/2'
albums: albums:
- uuid-tokyo - uuid-tokyo
- uuid-kyoto - uuid-kyoto
@@ -147,7 +147,7 @@ services:
container_name: immich-folio container_name: immich-folio
restart: unless-stopped restart: unless-stopped
ports: ports:
- "7211:7211" - '7211:7211'
env_file: env_file:
- .env.local - .env.local
volumes: volumes:

View File

@@ -8,125 +8,122 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { Lightbox } from '@/components/Lightbox'; import { Lightbox } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn';
export interface PhotoItem { export interface PhotoItem {
id: string; id: string;
thumbUrl: string; thumbUrl: string;
previewUrl: string; previewUrl: string;
originalUrl: string; originalUrl: string;
exifUrl: string; exifUrl: string;
blurDataURL?: string; blurDataURL?: string;
dominantColor?: string; dominantColor?: string;
/** Compact EXIF summary for hover overlay */ /** Compact EXIF summary for hover overlay */
camera?: string; camera?: string;
lens?: string; lens?: string;
focalLength?: string; focalLength?: string;
/** Natural image aspect ratio (width/height) for masonry layout */ /** Natural image aspect ratio (width/height) for masonry layout */
aspectRatio?: number; aspectRatio?: number;
} }
interface PhotoGridProps { interface PhotoGridProps {
assets: PhotoItem[]; assets: PhotoItem[];
albumId: string; albumId: string;
layout?: 'masonry' | 'uniform'; layout?: 'masonry' | 'uniform';
gridStyle?: React.CSSProperties; gridStyle?: React.CSSProperties;
} }
export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) { export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const openLightbox = useCallback((index: number) => { const openLightbox = useCallback((index: number) => {
setLightboxIndex(index); setLightboxIndex(index);
}, []); }, []);
const closeLightbox = useCallback(() => { const closeLightbox = useCallback(() => {
setLightboxIndex(null); setLightboxIndex(null);
}, []); }, []);
const goNext = useCallback(() => { const goNext = useCallback(() => {
setLightboxIndex((prev) => setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null));
prev !== null ? (prev + 1) % assets.length : null, }, [assets.length]);
);
}, [assets.length]);
const goPrev = useCallback(() => { const goPrev = useCallback(() => {
setLightboxIndex((prev) => setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null));
prev !== null ? (prev - 1 + assets.length) % assets.length : null, }, [assets.length]);
);
}, [assets.length]);
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {
if (lightboxIndex === null) return; if (lightboxIndex === null) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) { switch (e.key) {
case 'Escape': case 'Escape':
closeLightbox(); closeLightbox();
break; break;
case 'ArrowRight': case 'ArrowRight':
goNext(); goNext();
break; break;
case 'ArrowLeft': case 'ArrowLeft':
goPrev(); goPrev();
break; break;
} }
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
// Prevent body scroll when lightbox is open // Prevent body scroll when lightbox is open
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = ''; document.body.style.overflow = '';
}; };
}, [lightboxIndex, closeLightbox, goNext, goPrev]); }, [lightboxIndex, closeLightbox, goNext, goPrev]);
return ( return (
<> <>
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}> <div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
{assets.map((asset, index) => ( {assets.map((asset, index) => (
<div <FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
key={asset.id} <div
className="photo-grid__item" className="photo-grid__item"
onClick={() => openLightbox(index)} onClick={() => openLightbox(index)}
style={{ style={{
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}), ...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
...(layout === 'masonry' && asset.aspectRatio ...(layout === 'masonry' && asset.aspectRatio
? { aspectRatio: `${asset.aspectRatio}` } ? { aspectRatio: `${asset.aspectRatio}` }
: {}), : {}),
}} }}
> >
<Image <Image
src={asset.thumbUrl} src={asset.thumbUrl}
alt="" alt=""
fill fill
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw" sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
loading={index < 6 ? 'eager' : 'lazy'} loading={index < 6 ? 'eager' : 'lazy'}
{...(asset.blurDataURL {...(asset.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL } ? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
: {})} : {})}
/> />
{(asset.camera || asset.lens) && ( {(asset.camera || asset.lens) && (
<div className="photo-grid__item-exif"> <div className="photo-grid__item-exif">
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')} {[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
</div> </div>
)} )}
</div>
))}
</div> </div>
</FadeIn>
))}
</div>
{lightboxIndex !== null && ( {lightboxIndex !== null && (
<Lightbox <Lightbox
assets={assets} assets={assets}
currentIndex={lightboxIndex} currentIndex={lightboxIndex}
onClose={closeLightbox} onClose={closeLightbox}
onNext={goNext} onNext={goNext}
onPrev={goPrev} onPrev={goPrev}
/> />
)} )}
</> </>
); );
} }

View File

@@ -17,7 +17,13 @@ import type { Metadata } from 'next';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { PhotoGrid } from './PhotoGrid'; 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 { getConfig, type GridConfig } from '@/lib/config';
import { isProtected, isAuthenticated } from '@/lib/auth'; import { isProtected, isAuthenticated } from '@/lib/auth';
import PasswordGate from '@/components/PasswordGate'; import PasswordGate from '@/components/PasswordGate';
@@ -26,303 +32,324 @@ import PasswordGate from '@/components/PasswordGate';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
interface PathPageProps { interface PathPageProps {
params: Promise<{ path: string[] }>; params: Promise<{ path: string[] }>;
} }
export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> { export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> {
const { path } = await params; const { path } = await params;
if (!path || path.length === 0) return {}; if (!path || path.length === 0) return {};
const slug = path[0]; const slug = path[0];
let title = slug; let title = slug;
let subtitle = ''; let subtitle = '';
if (path.length === 2) { if (path.length === 2) {
const album = await immich.getAlbumBySlug(path[1], slug); const album = await immich.getAlbumBySlug(path[1], slug);
if (album) { if (album) {
title = album.albumName; title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE').length; const count = album.assets.filter((a) => a.type === 'IMAGE').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`; 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'}`;
}
} }
} 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 { return {
title, title,
openGraph: { title, images: [ogUrl] }, openGraph: { title, images: [ogUrl] },
twitter: { card: 'summary_large_image', title, images: [ogUrl] }, twitter: { card: 'summary_large_image', title, images: [ogUrl] },
}; };
} }
export default async function PathPage({ params }: PathPageProps) { export default async function PathPage({ params }: PathPageProps) {
const { path } = await params; const { path } = await params;
const config = getConfig(); const config = getConfig();
// Build grid CSS custom properties, optionally merging subpage overrides // Build grid CSS custom properties, optionally merging subpage overrides
const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => { const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => {
const g = { ...config.grid, ...overrides }; const g = { ...config.grid, ...overrides };
return { return {
'--grid-columns': g.columns, '--grid-columns': g.columns,
'--grid-gap': `${g.gap}px`, '--grid-gap': `${g.gap}px`,
'--grid-aspect-ratio': g.aspectRatio, '--grid-aspect-ratio': g.aspectRatio,
} as React.CSSProperties; } as React.CSSProperties;
}; };
const resolveLayout = (overrides?: Partial<GridConfig>) => const resolveLayout = (overrides?: Partial<GridConfig>) =>
overrides?.layout ?? config.grid.layout; overrides?.layout ?? config.grid.layout;
if (!path || path.length === 0) { if (!path || path.length === 0) {
notFound(); notFound();
} }
// ── Two segments: subpage/album ────────────────────────────── // ── Two segments: subpage/album ──────────────────────────────
if (path.length === 2) { if (path.length === 2) {
const [subpageSlug, albumSlug] = path; const [subpageSlug, albumSlug] = path;
// Password gate for protected subpages // Password gate for protected subpages
if (isProtected(subpageSlug)) { if (isProtected(subpageSlug)) {
const cookieStore = await cookies(); const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value; const getCookie = (name: string) => cookieStore.get(name)?.value;
if (!isAuthenticated(subpageSlug, getCookie)) { if (!isAuthenticated(subpageSlug, getCookie)) {
const subpageData = await immich.getSubpageAlbums(subpageSlug);
const subpageName = subpageData?.subpage.name ?? subpageSlug;
return <PasswordGate slug={subpageSlug} title={subpageName} />;
}
}
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug);
if (!album) {
notFound();
}
// Look up subpage config for grid overrides and back link
const subpageData = await immich.getSubpageAlbums(subpageSlug); const subpageData = await immich.getSubpageAlbums(subpageSlug);
const spGrid = subpageData?.subpage.grid;
const subpageName = subpageData?.subpage.name ?? subpageSlug; const subpageName = subpageData?.subpage.name ?? subpageSlug;
return <PasswordGate slug={subpageSlug} title={subpageName} />;
const images = album.assets.filter((asset) => asset.type === 'IMAGE'); }
return (
<>
<div className="album-header">
<Link href={`/${subpageSlug}`} className="album-header__back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to {subpageName}
</Link>
<h1 className="album-header__title">{album.albumName}</h1>
<p className="album-header__meta">
{images.length} {images.length === 1 ? 'photo' : 'photos'}
{album.description && ` · ${album.description}`}
</p>
</div>
<PhotoGrid
assets={images.map((a) => {
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 album = await immich.getAlbumBySlug(albumSlug, subpageSlug);
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 <PasswordGate slug={slug} title={subpageName} />;
}
}
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 (
<>
<div className="album-header">
<Link href="/" className="album-header__back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Gallery
</Link>
<h1 className="album-header__title">{album.albumName}</h1>
<p className="album-header__meta">
{images.length} {images.length === 1 ? 'photo' : 'photos'}
{album.description && ` · ${album.description}`}
</p>
</div>
<PhotoGrid
assets={images.map((a) => {
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 (
<div className="subpage-grid">
{albums.map((album, i) => {
const ph = coverPlaceholders[i];
return (
<Link
key={album.id}
href={`/${slug}/${album.slug}`}
className="subpage-grid__item"
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
>
{album.albumThumbnailAssetId ? (
<Image
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
alt={album.albumName}
fill
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
loading="lazy"
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
/>
) : (
<div
className="skeleton"
style={{ width: '100%', height: '100%' }}
/>
)}
<div className="subpage-grid__item-overlay">
<span className="subpage-grid__item-title">{album.albumName}</span>
<span className="subpage-grid__item-count">
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
</span>
</div>
</Link>
);
})}
</div>
);
}
// Otherwise treat as a standalone album slug
const album = await immich.getAlbumBySlug(slug);
if (!album) { 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'); const images = album.assets.filter((asset) => asset.type === 'IMAGE');
return ( return (
<> <>
<div className="album-header"> <div className="album-header">
<Link href="/" className="album-header__back"> <Link href={`/${subpageSlug}`} className="album-header__back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg
<polyline points="15 18 9 12 15 6" /> viewBox="0 0 24 24"
</svg> fill="none"
Back to Gallery stroke="currentColor"
</Link> strokeWidth="2"
<h1 className="album-header__title">{album.albumName}</h1> strokeLinecap="round"
<p className="album-header__meta"> strokeLinejoin="round"
{images.length} {images.length === 1 ? 'photo' : 'photos'} >
{album.description && ` · ${album.description}`} <polyline points="15 18 9 12 15 6" />
</p> </svg>
</div> Back to {subpageName}
<PhotoGrid </Link>
assets={images.map((a) => { <h1 className="album-header__title">{album.albumName}</h1>
const ph = assetPlaceholder(a); <p className="album-header__meta">
const exif = config.exifOnHover ? assetExifSummary(a) : undefined; {images.length} {images.length === 1 ? 'photo' : 'photos'}
return { {album.description && ` · ${album.description}`}
id: a.id, </p>
thumbUrl: imageUrl(a.id, 'preview'), </div>
previewUrl: imageUrl(a.id, 'preview'), <PhotoGrid
originalUrl: imageUrl(a.id, 'original'), assets={images.map((a) => {
exifUrl: exifUrl(a.id), const ph = assetPlaceholder(a);
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), const exif = config.exifOnHover ? assetExifSummary(a) : undefined;
...(exif ?? {}), return {
aspectRatio: assetAspectRatio(a), id: a.id,
}; thumbUrl: imageUrl(a.id, 'preview'),
})} previewUrl: imageUrl(a.id, 'preview'),
albumId={album.id} originalUrl: imageUrl(a.id, 'original'),
layout={resolveLayout()} exifUrl: exifUrl(a.id),
gridStyle={buildGridStyle()} ...(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 <PasswordGate slug={slug} title={subpageName} />;
}
}
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 (
<>
<div className="album-header">
<Link href="/" className="album-header__back">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Gallery
</Link>
<h1 className="album-header__title">{album.albumName}</h1>
<p className="album-header__meta">
{images.length} {images.length === 1 ? 'photo' : 'photos'}
{album.description && ` · ${album.description}`}
</p>
</div>
<PhotoGrid
assets={images.map((a) => {
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 (
<div className="subpage-grid">
{albums.map((album, i) => {
const ph = coverPlaceholders[i];
return (
<Link
key={album.id}
href={`/${slug}/${album.slug}`}
className="subpage-grid__item"
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
>
{album.albumThumbnailAssetId ? (
<Image
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
alt={album.albumName}
fill
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
loading="lazy"
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
/>
) : (
<div className="skeleton" style={{ width: '100%', height: '100%' }} />
)}
<span className="subpage-grid__item-badge">
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
</span>
<div className="subpage-grid__item-overlay">
<span className="subpage-grid__item-title">{album.albumName}</span>
<span className="subpage-grid__item-count">
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
</span>
</div>
</Link>
);
})}
</div>
);
}
// 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 (
<>
<div className="album-header">
<Link href="/" className="album-header__back">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Gallery
</Link>
<h1 className="album-header__title">{album.albumName}</h1>
<p className="album-header__meta">
{images.length} {images.length === 1 ? 'photo' : 'photos'}
{album.description && ` · ${album.description}`}
</p>
</div>
<PhotoGrid
assets={images.map((a) => {
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()}
/>
</>
);
} }

View File

@@ -1,122 +1,122 @@
/* ── About Page — Bauhaus / Leica Minimalist ──────── */ /* ── About Page — Bauhaus / Leica Minimalist ──────── */
.about { .about {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
min-height: calc(100vh - var(--header-height)); min-height: calc(100vh - var(--header-height));
} }
/* ── Portrait Column ─────────────────────────────── */ /* ── Portrait Column ─────────────────────────────── */
.about__portrait-col { .about__portrait-col {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: var(--bg-secondary); background: var(--bg-secondary);
} }
.about__portrait { .about__portrait {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.about__portrait--placeholder { .about__portrait--placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: var(--bg-secondary); background: var(--bg-secondary);
} }
/* ── Text Column ─────────────────────────────────── */ /* ── Text Column ─────────────────────────────────── */
.about__text-col { .about__text-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 80px 64px; padding: 80px 64px;
max-width: 560px; max-width: 560px;
} }
.about__name { .about__name {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 2.4rem; font-size: 2.4rem;
font-weight: 300; font-weight: 300;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 8px; margin-bottom: 8px;
} }
.about__location { .about__location {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 400; font-weight: 400;
letter-spacing: 0.2em; letter-spacing: 0.2em;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-muted); color: var(--text-muted);
} }
/* ── Thin Rule Dividers ──────────────────────────── */ /* ── Thin Rule Dividers ──────────────────────────── */
.about__rule { .about__rule {
border: none; border: none;
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
margin: 40px 0; margin: 40px 0;
width: 60px; width: 60px;
} }
/* ── Bio ─────────────────────────────────────────── */ /* ── Bio ─────────────────────────────────────────── */
.about__bio p { .about__bio p {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 300; font-weight: 300;
line-height: 1.8; line-height: 1.8;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 8px; margin-bottom: 8px;
} }
.about__bio p:last-child { .about__bio p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
/* ── Gear Section ────────────────────────────────── */ /* ── Gear Section ────────────────────────────────── */
.about__section-label { .about__section-label {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 500; font-weight: 500;
letter-spacing: 0.25em; letter-spacing: 0.25em;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 20px; margin-bottom: 20px;
} }
.about__gear { .about__gear {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
} }
.about__gear li { .about__gear li {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 300; font-weight: 300;
color: var(--text-secondary); color: var(--text-secondary);
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
/* ── Responsive ──────────────────────────────────── */ /* ── Responsive ──────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.about { .about {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.about__portrait-col { .about__portrait-col {
max-height: 50vh; max-height: 50vh;
} }
.about__text-col { .about__text-col {
padding: 48px 24px 64px; padding: 48px 24px 64px;
} }
.about__name { .about__name {
font-size: 1.8rem; font-size: 1.8rem;
} }
} }

View File

@@ -12,77 +12,75 @@ import { immich } from '@/lib/immich';
import './about.css'; import './about.css';
interface AboutFrontmatter { interface AboutFrontmatter {
portrait?: string; portrait?: string;
name?: string; name?: string;
location?: string; location?: string;
gear?: string[]; gear?: string[];
} }
function getAboutContent() { function getAboutContent() {
const raw = readFileSync(join(process.cwd(), 'content', 'about.md'), 'utf-8'); const raw = readFileSync(join(process.cwd(), 'content', 'about.md'), 'utf-8');
const { data, content } = matter(raw); const { data, content } = matter(raw);
return { meta: data as AboutFrontmatter, body: content.trim() }; return { meta: data as AboutFrontmatter, body: content.trim() };
} }
export default async function AboutPage() { export default async function AboutPage() {
const { meta, body } = getAboutContent(); const { meta, body } = getAboutContent();
// Fetch ThumbHash for portrait placeholder // Fetch ThumbHash for portrait placeholder
const portraitAsset = meta.portrait const portraitAsset = meta.portrait ? await immich.getAssetInfo(meta.portrait) : null;
? await immich.getAssetInfo(meta.portrait) const portraitPlaceholder = portraitAsset ? assetPlaceholder(portraitAsset) : null;
: null;
const portraitPlaceholder = portraitAsset ? assetPlaceholder(portraitAsset) : null;
return ( return (
<div className="about"> <div className="about">
{/* ── Portrait ─────────────────────────────────── */} {/* ── Portrait ─────────────────────────────────── */}
<div className="about__portrait-col"> <div className="about__portrait-col">
{meta.portrait ? ( {meta.portrait ? (
<Image <Image
src={imageUrl(meta.portrait, 'preview')} src={imageUrl(meta.portrait, 'preview')}
alt={meta.name || 'Portrait'} alt={meta.name || 'Portrait'}
className="about__portrait" className="about__portrait"
fill fill
sizes="(max-width: 768px) 100vw, 40vw" sizes="(max-width: 768px) 100vw, 40vw"
{...(portraitPlaceholder {...(portraitPlaceholder
? { placeholder: 'blur' as const, blurDataURL: portraitPlaceholder.blurDataURL } ? { placeholder: 'blur' as const, blurDataURL: portraitPlaceholder.blurDataURL }
: {})} : {})}
/> />
) : ( ) : (
<div className="about__portrait about__portrait--placeholder" /> <div className="about__portrait about__portrait--placeholder" />
)} )}
</div> </div>
{/* ── Text ─────────────────────────────────────── */} {/* ── Text ─────────────────────────────────────── */}
<div className="about__text-col"> <div className="about__text-col">
{meta.name && <h1 className="about__name">{meta.name}</h1>} {meta.name && <h1 className="about__name">{meta.name}</h1>}
{meta.location && ( {meta.location && <p className="about__location">{meta.location}</p>}
<p className="about__location">{meta.location}</p>
)}
<hr className="about__rule" /> <hr className="about__rule" />
{body && ( {body && (
<div className="about__bio"> <div className="about__bio">
{body.split('\n').filter(Boolean).map((line, i) => ( {body
<p key={i}>{line}</p> .split('\n')
))} .filter(Boolean)
</div> .map((line, i) => (
)} <p key={i}>{line}</p>
))}
</div>
)}
{meta.gear && meta.gear.length > 0 && ( {meta.gear && meta.gear.length > 0 && (
<> <>
<hr className="about__rule" /> <hr className="about__rule" />
<h2 className="about__section-label">Gear</h2> <h2 className="about__section-label">Gear</h2>
<ul className="about__gear"> <ul className="about__gear">
{meta.gear.map((item) => ( {meta.gear.map((item) => (
<li key={item}>{item}</li> <li key={item}>{item}</li>
))} ))}
</ul> </ul>
</> </>
)} )}
</div> </div>
</div> </div>
); );
} }

View File

@@ -7,40 +7,25 @@ import { NextRequest, NextResponse } from 'next/server';
import { authenticate, isProtected } from '@/lib/auth'; import { authenticate, isProtected } from '@/lib/auth';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { slug, password } = body; const { slug, password } = body;
if (!slug || !password) { if (!slug || !password) {
return NextResponse.json( return NextResponse.json({ error: 'Missing slug or password' }, { status: 400 });
{ 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 (!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 });
}
} }

View File

@@ -9,33 +9,30 @@ import { NextResponse } from 'next/server';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens'; import { decodeAssetId } from '@/lib/tokens';
export async function GET( export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
_request: Request, const { id: token } = await params;
{ params }: { params: Promise<{ id: string }> },
) {
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID // Decode the opaque token back to an Immich asset ID
const assetId = decodeAssetId(token); const assetId = decodeAssetId(token);
if (!assetId) { if (!assetId) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
} }
const asset = await immich.getAssetInfo(assetId); const asset = await immich.getAssetInfo(assetId);
if (!asset?.exifInfo) { if (!asset?.exifInfo) {
return NextResponse.json({ error: 'No EXIF data' }, { status: 404 }); return NextResponse.json({ error: 'No EXIF data' }, { status: 404 });
} }
const exif = asset.exifInfo; const exif = asset.exifInfo;
return NextResponse.json({ return NextResponse.json({
make: exif.make, make: exif.make,
model: exif.model, model: exif.model,
lensModel: exif.lensModel, lensModel: exif.lensModel,
focalLength: exif.focalLength, focalLength: exif.focalLength,
fNumber: exif.fNumber, fNumber: exif.fNumber,
exposureTime: exif.exposureTime, exposureTime: exif.exposureTime,
iso: exif.iso, iso: exif.iso,
city: exif.city, city: exif.city,
country: exif.country, country: exif.country,
}); });
} }

View File

@@ -11,16 +11,16 @@ import { immich } from '@/lib/immich';
const startTime = Date.now(); const startTime = Date.now();
export async function GET() { export async function GET() {
const immichOk = await immich.ping(); const immichOk = await immich.ping();
const body = { const body = {
status: immichOk ? 'ok' : 'degraded', status: immichOk ? 'ok' : 'degraded',
immich: immichOk ? 'connected' : 'unreachable', immich: immichOk ? 'connected' : 'unreachable',
uptime: Math.floor((Date.now() - startTime) / 1000), uptime: Math.floor((Date.now() - startTime) / 1000),
}; };
return NextResponse.json(body, { return NextResponse.json(body, {
status: immichOk ? 200 : 503, status: immichOk ? 200 : 503,
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}); });
} }

View File

@@ -19,71 +19,67 @@ const VALID_SIZES: ImageSize[] = ['thumbnail', 'preview', 'original'];
* Map a requested pixel width to the best Immich size tier. * Map a requested pixel width to the best Immich size tier.
*/ */
function widthToSize(w: number): ImageSize { function widthToSize(w: number): ImageSize {
if (w <= 250) return 'thumbnail'; if (w <= 250) return 'thumbnail';
if (w <= 1440) return 'preview'; if (w <= 1440) return 'preview';
return 'original'; return 'original';
} }
export async function GET( export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
request: NextRequest, // ── Rate limiting ──────────────────────────────────
{ params }: { params: Promise<{ id: string }> }, const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown';
) { const { success, remaining, resetAt } = checkRateLimit(ip, getConfig().rateLimitRpm);
// ── Rate limiting ──────────────────────────────────
const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown';
const { success, remaining, resetAt } = checkRateLimit(ip, getConfig().rateLimitRpm);
if (!success) { if (!success) {
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
return NextResponse.json( return NextResponse.json(
{ error: 'Too many requests' }, { error: 'Too many requests' },
{ {
status: 429, status: 429,
headers: { headers: {
'Retry-After': String(retryAfter), 'Retry-After': String(retryAfter),
'X-RateLimit-Remaining': '0', '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 headers: Record<string, string> = {
const assetId = decodeAssetId(token); 'Content-Type': result.contentType,
if (!assetId) { // Images are immutable once uploaded to Immich — cache aggressively
return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); 'Cache-Control': 'public, max-age=31536000, immutable',
} 'X-RateLimit-Remaining': String(remaining),
};
// Determine size: prefer explicit ?size=, then infer from ?w= if (result.contentLength) {
const sizeParam = request.nextUrl.searchParams.get('size'); headers['Content-Length'] = result.contentLength;
const widthParam = request.nextUrl.searchParams.get('w'); }
let size: ImageSize = 'preview'; return new NextResponse(result.stream, { headers });
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<string, string> = {
'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 });
} }

View File

@@ -11,83 +11,81 @@ import { NextRequest } from 'next/server';
export const runtime = 'edge'; export const runtime = 'edge';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl; const { searchParams } = request.nextUrl;
const title = searchParams.get('title') || 'Gallery'; const title = searchParams.get('title') || 'Gallery';
const subtitle = searchParams.get('subtitle') || ''; const subtitle = searchParams.get('subtitle') || '';
return new ImageResponse( return new ImageResponse(
( <div
<div style={{
style={{ width: '100%',
width: '100%', height: '100%',
height: '100%', display: 'flex',
display: 'flex', flexDirection: 'column',
flexDirection: 'column', alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', backgroundColor: '#0a0a0a',
backgroundColor: '#0a0a0a', fontFamily: 'system-ui, -apple-system, sans-serif',
fontFamily: 'system-ui, -apple-system, sans-serif', padding: '60px',
padding: '60px', }}
}} >
> {/* Decorative top line */}
{/* Decorative top line */} <div
<div style={{
style={{ width: '60px',
width: '60px', height: '2px',
height: '2px', backgroundColor: '#d4af37',
backgroundColor: '#d4af37', marginBottom: '40px',
marginBottom: '40px', }}
}} />
/>
{/* Title */} {/* Title */}
<div <div
style={{ style={{
fontSize: '64px', fontSize: '64px',
fontWeight: 600, fontWeight: 600,
color: '#f0f0f0', color: '#f0f0f0',
letterSpacing: '0.02em', letterSpacing: '0.02em',
textAlign: 'center', textAlign: 'center',
lineHeight: 1.2, lineHeight: 1.2,
maxWidth: '900px', maxWidth: '900px',
}} }}
> >
{title} {title}
</div> </div>
{/* Subtitle */} {/* Subtitle */}
{subtitle && ( {subtitle && (
<div <div
style={{ style={{
fontSize: '24px', fontSize: '24px',
color: '#999999', color: '#999999',
fontWeight: 300, fontWeight: 300,
marginTop: '16px', marginTop: '16px',
letterSpacing: '0.08em', letterSpacing: '0.08em',
textTransform: 'lowercase', textTransform: 'lowercase',
}} }}
> >
{subtitle} {subtitle}
</div> </div>
)} )}
{/* Bottom decorative line */} {/* Bottom decorative line */}
<div <div
style={{ style={{
width: '60px', width: '60px',
height: '2px', height: '2px',
backgroundColor: '#d4af37', backgroundColor: '#d4af37',
marginTop: '40px', marginTop: '40px',
}} }}
/> />
</div> </div>,
), {
{ width: 1200,
width: 1200, height: 630,
height: 630, headers: {
headers: { 'Cache-Control': 'public, max-age=86400, s-maxage=86400',
'Cache-Control': 'public, max-age=86400, s-maxage=86400', },
}, },
}, );
);
} }

View File

@@ -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'); @import url('./tokens.css');
/* ── 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%;
}
/* ── Header / Navigation ──────────────────────────── */ /* ── Header / Navigation ──────────────────────────── */
.header { .header {
@@ -77,6 +12,10 @@ img {
padding: 0 24px; padding: 0 24px;
} }
[data-theme='light'] .header {
background: rgba(250, 250, 250, 0.9);
}
.header__nav { .header__nav {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -180,7 +119,9 @@ img {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
text-decoration-color: rgba(255, 255, 255, 0.2); 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; padding: 4px 0;
} }
@@ -252,7 +193,9 @@ img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; 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 { .subpage-grid__item:hover img {
@@ -267,9 +210,7 @@ img {
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
padding: 20px; padding: 20px;
background: linear-gradient(to top, background: linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, transparent 50%);
rgba(0, 0, 0, 0.6) 0%,
transparent 50%);
opacity: 0; opacity: 0;
transition: opacity var(--transition-normal); transition: opacity var(--transition-normal);
} }
@@ -307,7 +248,9 @@ img {
cursor: pointer; cursor: pointer;
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-subtle); 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 { .album-card:hover {
@@ -319,7 +262,9 @@ img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; 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 { .album-card:hover .album-card__image {
@@ -430,7 +375,9 @@ img {
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; 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 { .photo-grid__item:hover img {
@@ -463,218 +410,12 @@ img {
opacity: 1; 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 ───────────────────────────────── */ /* ── Loading States ───────────────────────────────── */
.skeleton { .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%; background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite; animation: skeleton-shimmer 1.5s infinite;
border-radius: var(--radius-sm); 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 ─────────────────────────── */ /* ── Error / Empty States ─────────────────────────── */
.empty-state { .empty-state {
text-align: center; text-align: center;
@@ -708,97 +598,6 @@ img {
font-weight: 300; 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 ───────────────────────────────────── */ /* ── Responsive ───────────────────────────────────── */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.photo-grid { .photo-grid {
@@ -860,21 +659,4 @@ img {
.subpage-grid__item-overlay { .subpage-grid__item-overlay {
opacity: 1; opacity: 1;
} }
.lightbox__nav {
width: 40px;
height: 40px;
}
.lightbox__nav--prev {
left: 8px;
}
.lightbox__nav--next {
right: 8px;
}
.password-gate__card {
padding: 36px 24px;
}
} }

View File

@@ -7,6 +7,9 @@ import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import './globals.css'; import './globals.css';
import { SubpageNav } from '@/components/SubpageNav'; 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 siteTitle = process.env.SITE_TITLE || 'Gallery';
const siteDescription = 'A curated photography portfolio'; const siteDescription = 'A curated photography portfolio';
@@ -28,13 +31,9 @@ export const metadata: Metadata = {
}, },
}; };
export default function RootLayout({ export default function RootLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<body> <body>
<header className="header"> <header className="header">
<nav className="header__nav"> <nav className="header__nav">
@@ -45,9 +44,12 @@ export default function RootLayout({
<Link href="/about" className="header__nav-link"> <Link href="/about" className="header__nav-link">
About About
</Link> </Link>
<ThemeToggle />
</nav> </nav>
</header> </header>
<main className="main">{children}</main> <main className="main">{children}</main>
<Footer />
<ScrollToTop />
</body> </body>
</html> </html>
); );

View File

@@ -9,6 +9,7 @@ import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { imageUrl, assetPlaceholder } from '@/lib/urls'; import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { HeroCarousel } from '@/components/HeroCarousel'; import { HeroCarousel } from '@/components/HeroCarousel';
import { FadeIn } from '@/components/FadeIn';
// Render at request time — requires live Immich connection // Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -37,31 +38,29 @@ export default async function HomePage() {
{/* ── Left Panel ──────────────────────────────── */} {/* ── Left Panel ──────────────────────────────── */}
<div className="hero__left"> <div className="hero__left">
<div className="hero__content"> <div className="hero__content">
<h1 className="hero__title">{config.siteTitle}</h1> <FadeIn delay={0}>
<h1 className="hero__title">{config.siteTitle}</h1>
</FadeIn>
{config.siteSubtitle && ( {config.siteSubtitle && (
<p className="hero__subtitle">{config.siteSubtitle}</p> <FadeIn delay={100}>
<p className="hero__subtitle">{config.siteSubtitle}</p>
</FadeIn>
)} )}
<nav className="hero__nav"> <FadeIn delay={200}>
{subpages.map((sp) => ( <nav className="hero__nav">
<Link {subpages.map((sp) => (
key={sp.slug} <Link key={sp.slug} href={`/${sp.slug}`} className="hero__nav-link">
href={`/${sp.slug}`} {sp.name}
className="hero__nav-link" </Link>
> ))}
{sp.name} {albums.map((album) => (
</Link> <Link key={album.id} href={`/${album.slug}`} className="hero__nav-link">
))} {album.albumName}
{albums.map((album) => ( </Link>
<Link ))}
key={album.id} </nav>
href={`/${album.slug}`} </FadeIn>
className="hero__nav-link"
>
{album.albumName}
</Link>
))}
</nav>
</div> </div>
</div> </div>
@@ -72,4 +71,3 @@ export default async function HomePage() {
</div> </div>
); );
} }

85
app/tokens.css Normal file
View File

@@ -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%;
}

63
components/FadeIn.tsx Normal file
View File

@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
className={`fade-in ${direction === 'up' ? 'fade-in--up' : ''} ${className ?? ''}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}

92
components/Footer.tsx Normal file
View File

@@ -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 (
<footer className="footer">
<div className="footer__inner">
{footer.name && <span className="footer__name">{footer.name}</span>}
<div className="footer__links">
{footer.instagram && (
<a
href={`https://instagram.com/${footer.instagram.replace('@', '')}`}
target="_blank"
rel="noopener noreferrer"
className="footer__link"
aria-label="Instagram"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
</svg>
</a>
)}
{footer.email && (
<a href={`mailto:${footer.email}`} className="footer__link" aria-label="Email">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
</a>
)}
{footer.website && (
<a
href={footer.website}
target="_blank"
rel="noopener noreferrer"
className="footer__link"
aria-label="Website"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</a>
)}
</div>
</div>
</footer>
);
}

View File

@@ -11,67 +11,65 @@ import { useState, useEffect, useCallback } from 'react';
import Image from 'next/image'; import Image from 'next/image';
interface HeroImage { interface HeroImage {
src: string; src: string;
blurDataURL?: string; blurDataURL?: string;
} }
interface HeroCarouselProps { interface HeroCarouselProps {
images: HeroImage[]; images: HeroImage[];
} }
const INTERVAL_MS = 6000; const INTERVAL_MS = 6000;
export function HeroCarousel({ images }: HeroCarouselProps) { export function HeroCarousel({ images }: HeroCarouselProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const advance = useCallback(() => { const advance = useCallback(() => {
setCurrentIndex((prev) => (prev + 1) % images.length); setCurrentIndex((prev) => (prev + 1) % images.length);
}, [images.length]); }, [images.length]);
useEffect(() => { useEffect(() => {
if (images.length <= 1) return; if (images.length <= 1) return;
const timer = setInterval(advance, INTERVAL_MS); const timer = setInterval(advance, INTERVAL_MS);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [images.length, advance]); }, [images.length, advance]);
if (images.length === 0) { if (images.length === 0) {
return <div className="hero__image-placeholder" />; return <div className="hero__image-placeholder" />;
} }
if (images.length === 1) {
const img = images[0];
return (
<Image
src={img.src}
alt=""
className="hero__image"
fill
sizes="50vw"
priority
{...(img.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
: {})}
/>
);
}
if (images.length === 1) {
const img = images[0];
return ( return (
<> <Image
{images.map((img, i) => ( src={img.src}
<Image alt=""
key={i} className="hero__image"
src={img.src} fill
alt="" sizes="50vw"
className={`hero__carousel-image${i === currentIndex ? ' hero__carousel-image--active' : ''}`} priority
fill {...(img.blurDataURL ? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL } : {})}
sizes="50vw" />
priority={i === 0}
{...(img.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
: {})}
/>
))}
</>
); );
}
return (
<>
{images.map((img, i) => (
<Image
key={i}
src={img.src}
alt=""
className={`hero__carousel-image${i === currentIndex ? ' hero__carousel-image--active' : ''}`}
fill
sizes="50vw"
priority={i === 0}
{...(img.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
: {})}
/>
))}
</>
);
} }

View File

@@ -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;
}
}

View File

@@ -13,250 +13,263 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid'; import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import styles from './Lightbox.module.css';
interface ExifData { interface ExifData {
make?: string | null; make?: string | null;
model?: string | null; model?: string | null;
lensModel?: string | null; lensModel?: string | null;
focalLength?: number | null; focalLength?: number | null;
fNumber?: number | null; fNumber?: number | null;
exposureTime?: string | null; exposureTime?: string | null;
iso?: number | null; iso?: number | null;
city?: string | null; city?: string | null;
country?: string | null; country?: string | null;
} }
interface LightboxProps { interface LightboxProps {
assets: PhotoItem[]; assets: PhotoItem[];
currentIndex: number; currentIndex: number;
onClose: () => void; onClose: () => void;
onNext: () => void; onNext: () => void;
onPrev: () => void; onPrev: () => void;
} }
export function Lightbox({ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: LightboxProps) {
assets, const [showExif, setShowExif] = useState(false);
currentIndex, const [exifData, setExifData] = useState<ExifData | null>(null);
onClose, const [exifLoading, setExifLoading] = useState(false);
onNext, const [imageLoaded, setImageLoaded] = useState(false);
onPrev, const overlayRef = useRef<HTMLDivElement>(null);
}: LightboxProps) { const touchStartX = useRef<number>(0);
const [showExif, setShowExif] = useState(false); const touchStartY = useRef<number>(0);
const [exifData, setExifData] = useState<ExifData | null>(null);
const [exifLoading, setExifLoading] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef<number>(0);
const touchStartY = useRef<number>(0);
const current = assets[currentIndex]; const current = assets[currentIndex];
// Fetch EXIF data on demand // Fetch EXIF data on demand
const fetchExif = useCallback((url: string) => { const fetchExif = useCallback((url: string) => {
setExifLoading(true); setExifLoading(true);
setExifData(null); setExifData(null);
fetch(url) fetch(url)
.then((res) => (res.ok ? res.json() : null)) .then((res) => (res.ok ? res.json() : null))
.then((data) => setExifData(data)) .then((data) => setExifData(data))
.catch(() => setExifData(null)) .catch(() => setExifData(null))
.finally(() => setExifLoading(false)); .finally(() => setExifLoading(false));
}, []); }, []);
// Reset EXIF data when switching images; refetch if panel is open // Reset EXIF data when switching images; refetch if panel is open
useEffect(() => { useEffect(() => {
setExifData(null); setExifData(null);
setImageLoaded(false); setImageLoaded(false);
if (showExif && current) { if (showExif && current) {
fetchExif(current.exifUrl); fetchExif(current.exifUrl);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentIndex]); }, [currentIndex]);
// When the toggle is turned on or the image changes while open, fetch // When the toggle is turned on or the image changes while open, fetch
const handleExifToggle = useCallback(() => { const handleExifToggle = useCallback(() => {
const next = !showExif; const next = !showExif;
setShowExif(next); setShowExif(next);
if (next && current) { if (next && current) {
fetchExif(current.exifUrl); fetchExif(current.exifUrl);
} }
}, [showExif, current, fetchExif]); }, [showExif, current, fetchExif]);
// Preload adjacent images // Preload adjacent images
useEffect(() => { useEffect(() => {
const preload = (index: number) => { const preload = (index: number) => {
if (index >= 0 && index < assets.length) { if (index >= 0 && index < assets.length) {
const img = new Image(); const img = new Image();
img.src = assets[index].previewUrl; img.src = assets[index].previewUrl;
} }
}; };
preload(currentIndex + 1); preload(currentIndex + 1);
preload(currentIndex - 1); preload(currentIndex - 1);
}, [currentIndex, assets]); }, [currentIndex, assets]);
// Touch/swipe support // Touch/swipe support
const handleTouchStart = useCallback((e: React.TouchEvent) => { const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX; touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY; touchStartY.current = e.touches[0].clientY;
}, []); }, []);
const handleTouchEnd = useCallback( const handleTouchEnd = useCallback(
(e: React.TouchEvent) => { (e: React.TouchEvent) => {
const dx = e.changedTouches[0].clientX - touchStartX.current; const dx = e.changedTouches[0].clientX - touchStartX.current;
const dy = e.changedTouches[0].clientY - touchStartY.current; const dy = e.changedTouches[0].clientY - touchStartY.current;
// Only register horizontal swipes (not vertical scrolling) // Only register horizontal swipes (not vertical scrolling)
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) { if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) {
if (dx > 0) onPrev(); if (dx > 0) onPrev();
else onNext(); else onNext();
} }
}, },
[onNext, onPrev], [onNext, onPrev],
); );
// Click on overlay background → close // Click on overlay background → close
const handleOverlayClick = useCallback( const handleOverlayClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (e.target === overlayRef.current) { if (e.target === overlayRef.current) {
onClose(); onClose();
} }
}, },
[onClose], [onClose],
); );
return ( return (
<div <div
className="lightbox-overlay" className={styles.overlay}
ref={overlayRef} ref={overlayRef}
onClick={handleOverlayClick} onClick={handleOverlayClick}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
>
{/* Close button */}
<button className={styles.close} onClick={onClose} aria-label="Close">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
> >
{/* Close button */} <line x1="18" y1="6" x2="6" y2="18" />
<button className="lightbox__close" onClick={onClose} aria-label="Close"> <line x1="6" y1="6" x2="18" y2="18" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> </svg>
<line x1="18" y1="6" x2="6" y2="18" /> </button>
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* Previous button */} {/* Previous button */}
<button <button
className="lightbox__nav lightbox__nav--prev" className={`${styles.nav} ${styles.navPrev}`}
onClick={onPrev} onClick={onPrev}
aria-label="Previous photo" aria-label="Previous photo"
> >
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg
<polyline points="15 18 9 12 15 6" /> viewBox="0 0 24 24"
</svg> fill="none"
</button> stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
{/* Image */} {/* Image */}
<div <div
className="lightbox__image-container" className={styles.imageContainer}
style={{ style={{
backgroundColor: current.dominantColor || '#000', backgroundColor: current.dominantColor || '#000',
backgroundImage: current.blurDataURL ? `url(${current.blurDataURL})` : undefined, backgroundImage: current.blurDataURL ? `url(${current.blurDataURL})` : undefined,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
className={`lightbox__image${imageLoaded ? ' lightbox__image--loaded' : ''}`} className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
src={current.previewUrl} src={current.previewUrl}
alt="" alt=""
draggable={false} draggable={false}
onLoad={() => setImageLoaded(true)} onLoad={() => setImageLoaded(true)}
/> />
</div>
{/* Next button */}
<button
className={`${styles.nav} ${styles.navNext}`}
onClick={onNext}
aria-label="Next photo"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</button>
{/* Counter */}
<div className={styles.counter}>
{currentIndex + 1} / {assets.length}
</div>
{/* EXIF toggle */}
<button className={styles.infoToggle} onClick={handleExifToggle}>
{showExif ? 'Hide Info' : 'Info'}
</button>
{/* EXIF panel */}
{showExif && (
<div className={styles.exifPanel}>
{exifLoading ? (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Loading...</span>
</div> </div>
) : exifData ? (
{/* Next button */} <>
<button {exifData.model && (
className="lightbox__nav lightbox__nav--next" <div className={styles.exifRow}>
onClick={onNext} <span className={styles.exifLabel}>Camera</span>
aria-label="Next photo" <span className={styles.exifValue}>
> {[exifData.make, exifData.model].filter(Boolean).join(' ')}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> </span>
<polyline points="9 6 15 12 9 18" />
</svg>
</button>
{/* Counter */}
<div className="lightbox__counter">
{currentIndex + 1} / {assets.length}
</div>
{/* EXIF toggle */}
<button
className="lightbox__info-toggle"
onClick={handleExifToggle}
>
{showExif ? 'Hide Info' : 'Info'}
</button>
{/* EXIF panel */}
{showExif && (
<div className="exif-panel">
{exifLoading ? (
<div className="exif-panel__row">
<span className="exif-panel__label">Loading...</span>
</div>
) : exifData ? (
<>
{exifData.model && (
<div className="exif-panel__row">
<span className="exif-panel__label">Camera</span>
<span className="exif-panel__value">
{[exifData.make, exifData.model].filter(Boolean).join(' ')}
</span>
</div>
)}
{exifData.lensModel && (
<div className="exif-panel__row">
<span className="exif-panel__label">Lens</span>
<span className="exif-panel__value">{exifData.lensModel}</span>
</div>
)}
{exifData.focalLength && (
<div className="exif-panel__row">
<span className="exif-panel__label">Focal Length</span>
<span className="exif-panel__value">{exifData.focalLength}mm</span>
</div>
)}
{exifData.fNumber && (
<div className="exif-panel__row">
<span className="exif-panel__label">Aperture</span>
<span className="exif-panel__value">ƒ/{exifData.fNumber}</span>
</div>
)}
{exifData.exposureTime && (
<div className="exif-panel__row">
<span className="exif-panel__label">Shutter</span>
<span className="exif-panel__value">{exifData.exposureTime}s</span>
</div>
)}
{exifData.iso && (
<div className="exif-panel__row">
<span className="exif-panel__label">ISO</span>
<span className="exif-panel__value">{exifData.iso}</span>
</div>
)}
{(exifData.city || exifData.country) && (
<div className="exif-panel__row">
<span className="exif-panel__label">Location</span>
<span className="exif-panel__value">
{[exifData.city, exifData.country].filter(Boolean).join(', ')}
</span>
</div>
)}
</>
) : (
<div className="exif-panel__row">
<span className="exif-panel__label">No EXIF data available</span>
</div>
)}
</div> </div>
)} )}
{exifData.lensModel && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Lens</span>
<span className={styles.exifValue}>{exifData.lensModel}</span>
</div>
)}
{exifData.focalLength && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Focal Length</span>
<span className={styles.exifValue}>{exifData.focalLength}mm</span>
</div>
)}
{exifData.fNumber && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Aperture</span>
<span className={styles.exifValue}>ƒ/{exifData.fNumber}</span>
</div>
)}
{exifData.exposureTime && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Shutter</span>
<span className={styles.exifValue}>{exifData.exposureTime}s</span>
</div>
)}
{exifData.iso && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>ISO</span>
<span className={styles.exifValue}>{exifData.iso}</span>
</div>
)}
{(exifData.city || exifData.country) && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Location</span>
<span className={styles.exifValue}>
{[exifData.city, exifData.country].filter(Boolean).join(', ')}
</span>
</div>
)}
</>
) : (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>No EXIF data available</span>
</div>
)}
</div> </div>
); )}
</div>
);
} }

View File

@@ -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;
}
}

View File

@@ -6,71 +6,66 @@
*/ */
import { useState } from 'react'; import { useState } from 'react';
import styles from './PasswordGate.module.css';
interface PasswordGateProps { interface PasswordGateProps {
slug: string; slug: string;
title: string; title: string;
} }
export default function PasswordGate({ slug, title }: PasswordGateProps) { export default function PasswordGate({ slug, title }: PasswordGateProps) {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/auth', { const res = await fetch('/api/auth', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, password }), body: JSON.stringify({ slug, password }),
}); });
if (res.ok) { if (res.ok) {
window.location.reload(); window.location.reload();
} else { } else {
setError('Wrong password'); setError('Wrong password');
setPassword(''); setPassword('');
} }
} catch { } catch {
setError('Something went wrong'); setError('Something went wrong');
} finally { } finally {
setLoading(false); setLoading(false);
}
} }
}
return ( return (
<div className="password-gate"> <div className={styles.gate}>
<div className="password-gate__card"> <div className={styles.card}>
<h2 className="password-gate__title">{title}</h2> <h2 className={styles.title}>{title}</h2>
<p className="password-gate__subtitle"> <p className={styles.subtitle}>This gallery is password-protected.</p>
This gallery is password-protected.
</p>
<form onSubmit={handleSubmit} className="password-gate__form"> <form onSubmit={handleSubmit} className={styles.form}>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password" placeholder="Enter password"
className="password-gate__input" className={styles.input}
autoFocus autoFocus
required required
/> />
<button <button type="submit" className={styles.button} disabled={loading}>
type="submit" {loading ? 'Verifying…' : 'Enter'}
className="password-gate__button" </button>
disabled={loading} </form>
>
{loading ? 'Verifying…' : 'Enter'}
</button>
</form>
{error && <p className="password-gate__error">{error}</p>} {error && <p className={styles.error}>{error}</p>}
</div> </div>
</div> </div>
); );
} }

View File

@@ -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 (
<button
className={`scroll-to-top ${visible ? 'scroll-to-top--visible' : ''}`}
onClick={scrollToTop}
aria-label="Scroll to top"
type="button"
>
↑
</button>
);
}

View File

@@ -7,31 +7,23 @@ import Link from 'next/link';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
export async function SubpageNav() { export async function SubpageNav() {
const [subpages, standaloneAlbums] = await Promise.all([ const [subpages, standaloneAlbums] = await Promise.all([
immich.getSubpages(), immich.getSubpages(),
immich.getStandaloneAlbums(), immich.getStandaloneAlbums(),
]); ]);
return ( return (
<> <>
{subpages.map((sp) => ( {subpages.map((sp) => (
<Link <Link key={sp.slug} href={`/${sp.slug}`} className="header__nav-link">
key={sp.slug} {sp.name}
href={`/${sp.slug}`} </Link>
className="header__nav-link" ))}
> {standaloneAlbums.map((album) => (
{sp.name} <Link key={album.id} href={`/${album.slug}`} className="header__nav-link">
</Link> {album.albumName}
))} </Link>
{standaloneAlbums.map((album) => ( ))}
<Link </>
key={album.id} );
href={`/${album.slug}`}
className="header__nav-link"
>
{album.albumName}
</Link>
))}
</>
);
} }

102
components/ThemeToggle.tsx Normal file
View File

@@ -0,0 +1,102 @@
/**
* ThemeToggle — dark/light mode switch.
*
* Persists preference in localStorage and sets data-theme on <html>.
* 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 (
<button
className="theme-toggle"
onClick={toggle}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
type="button"
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? (
// Sun icon
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
) : (
// Moon icon
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
);
}

View File

@@ -23,3 +23,10 @@ subpages:
password: clientpass123 password: clientpass123
albums: albums:
- 4ad0ed89-55de-4e69-937f-2d9988f63ad1 - 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

View File

@@ -42,3 +42,10 @@ subpages:
password: clientpass123 # visitors must enter this to view password: clientpass123 # visitors must enter this to view
albums: albums:
- 55555555-5555-5555-5555-555555555555 - 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

View File

@@ -4,7 +4,7 @@ services:
container_name: immich-lightbox container_name: immich-lightbox
restart: unless-stopped restart: unless-stopped
ports: ports:
- "7211:7211" - '7211:7211'
env_file: env_file:
- .env.local - .env.local
volumes: volumes:

23
e2e/about.spec.ts Normal file
View File

@@ -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();
});
});

10
e2e/health.spec.ts Normal file
View File

@@ -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');
});
});

38
e2e/homepage.spec.ts Normal file
View File

@@ -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);
});
});

31
e2e/navigation.spec.ts Normal file
View File

@@ -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('/');
});
});

View File

@@ -1,17 +1,38 @@
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from "eslint-config-next/core-web-vitals"; import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from "eslint-config-next/typescript"; import nextTs from 'eslint-config-next/typescript';
import prettier from 'eslint-config-prettier';
import prettierPlugin from 'eslint-plugin-prettier';
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...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. // Override default ignores of eslint-config-next.
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:
".next/**", '.next/**',
"out/**", 'out/**',
"build/**", 'build/**',
"next-env.d.ts", 'next-env.d.ts',
]), ]),
]); ]);

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});
});

162
lib/__tests__/urls.test.ts Normal file
View File

@@ -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();
});
});

View File

@@ -12,52 +12,46 @@ import { getConfig, SubpageConfig } from './config';
const TOKEN_EXPIRY_HOURS = 24; const TOKEN_EXPIRY_HOURS = 24;
function hmac(data: string): string { function hmac(data: string): string {
return crypto return crypto.createHmac('sha256', getConfig().immich.apiKey).update(data).digest('hex');
.createHmac('sha256', getConfig().immich.apiKey)
.update(data)
.digest('hex');
} }
function authToken(slug: string, password: string): string { function authToken(slug: string, password: string): string {
return hmac(`${slug}:${password}`); return hmac(`${slug}:${password}`);
} }
function cookieName(slug: string): string { function cookieName(slug: string): string {
return `lb_auth_${slug}`; return `lb_auth_${slug}`;
} }
/** /**
* Find the SubpageConfig for a given slug. * Find the SubpageConfig for a given slug.
*/ */
export function findSubpageBySlug(slug: string): SubpageConfig | undefined { 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. * Check if a slug corresponds to a password-protected subpage.
*/ */
export function isProtected(slug: string): boolean { export function isProtected(slug: string): boolean {
const sp = findSubpageBySlug(slug); const sp = findSubpageBySlug(slug);
return !!sp?.password; return !!sp?.password;
} }
/** /**
* Validate a password attempt and return a Set-Cookie header value on success. * Validate a password attempt and return a Set-Cookie header value on success.
* Returns null if the password is wrong. * Returns null if the password is wrong.
*/ */
export function authenticate( export function authenticate(slug: string, password: string): string | null {
slug: string, const sp = findSubpageBySlug(slug);
password: string, if (!sp?.password) return null;
): 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 token = authToken(slug, password);
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60; 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. * Works with the cookies() API from Next.js server components.
*/ */
export function isAuthenticated( export function isAuthenticated(
slug: string, slug: string,
getCookie: (name: string) => string | undefined, getCookie: (name: string) => string | undefined,
): boolean { ): boolean {
const sp = findSubpageBySlug(slug); const sp = findSubpageBySlug(slug);
if (!sp?.password) return true; // not protected if (!sp?.password) return true; // not protected
const cookie = getCookie(cookieName(slug)); const cookie = getCookie(cookieName(slug));
if (!cookie) return false; if (!cookie) return false;
const expected = authToken(slug, sp.password); const expected = authToken(slug, sp.password);
// Constant-time comparison to prevent timing attacks // Constant-time comparison to prevent timing attacks
try { try {
return crypto.timingSafeEqual( return crypto.timingSafeEqual(Buffer.from(cookie, 'hex'), Buffer.from(expected, 'hex'));
Buffer.from(cookie, 'hex'), } catch {
Buffer.from(expected, 'hex'), return false;
); }
} catch {
return false;
}
} }

View File

@@ -4,42 +4,42 @@
*/ */
interface CacheEntry<T> { interface CacheEntry<T> {
data: T; data: T;
expiresAt: number; expiresAt: number;
} }
class MemoryCache { class MemoryCache {
private store = new Map<string, CacheEntry<unknown>>(); private store = new Map<string, CacheEntry<unknown>>();
private maxEntries = 200; private maxEntries = 200;
get<T>(key: string): T | null { get<T>(key: string): T | null {
const entry = this.store.get(key); const entry = this.store.get(key);
if (!entry) return null; if (!entry) return null;
if (Date.now() > entry.expiresAt) { if (Date.now() > entry.expiresAt) {
this.store.delete(key); this.store.delete(key);
return null; return null;
}
return entry.data as T;
} }
set<T>(key: string, data: T, ttlMs: number): void { return entry.data as T;
// 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);
}
this.store.set(key, { set<T>(key: string, data: T, ttlMs: number): void {
data, // Evict oldest entries if at capacity
expiresAt: Date.now() + ttlMs, if (this.store.size >= this.maxEntries) {
}); const firstKey = this.store.keys().next().value;
if (firstKey) this.store.delete(firstKey);
} }
clear(): void { this.store.set(key, {
this.store.clear(); data,
} expiresAt: Date.now() + ttlMs,
});
}
clear(): void {
this.store.clear();
}
} }
export const cache = new MemoryCache(); export const cache = new MemoryCache();

View File

@@ -6,16 +6,9 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { env } from './env';
// ── Env helpers ──────────────────────────────────────────────── // ── Env helpers (now handled by lib/env.ts via Zod) ───────────
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
// ── Types ────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────
@@ -30,6 +23,14 @@ export interface SubpageConfig {
grid?: Partial<GridConfig>; grid?: Partial<GridConfig>;
} }
/** Footer configuration for social links. */
export interface FooterConfig {
name?: string;
instagram?: string;
email?: string;
website?: string;
}
/** Raw YAML structure (before validation). */ /** Raw YAML structure (before validation). */
interface GalleryYaml { interface GalleryYaml {
hero?: string | string[]; hero?: string | string[];
@@ -41,6 +42,12 @@ interface GalleryYaml {
aspectRatio?: string; aspectRatio?: string;
layout?: string; layout?: string;
}; };
footer?: {
name?: string;
instagram?: string;
email?: string;
website?: string;
};
subpages?: Array<{ subpages?: Array<{
name: string; name: string;
albums: string[]; albums: string[];
@@ -62,7 +69,7 @@ function loadGalleryYaml(): GalleryYaml {
if (!fs.existsSync(yamlPath)) { if (!fs.existsSync(yamlPath)) {
throw new Error( throw new Error(
`Gallery config not found: ${yamlPath}\n` + `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() .toLowerCase()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // strip diacritics (ü→u, é→e) .replace(/[\u0300-\u036f]/g, '') // strip diacritics (ü→u, é→e)
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens .replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
} }
// ── Config type ──────────────────────────────────────────────── // ── Config type ────────────────────────────────────────────────
@@ -126,6 +133,8 @@ export interface AppConfig {
exifOnHover: boolean; exifOnHover: boolean;
/** Photo grid layout configuration. */ /** Photo grid layout configuration. */
grid: GridConfig; grid: GridConfig;
/** Footer social links config. */
footer: FooterConfig | null;
cacheTtl: number; cacheTtl: number;
rateLimitRpm: number; rateLimitRpm: number;
} }
@@ -137,13 +146,14 @@ let _config: AppConfig | null = null;
export function getConfig(): AppConfig { export function getConfig(): AppConfig {
if (_config) return _config; if (_config) return _config;
const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, ''); const apiUrl = env.IMMICH_API_URL;
const apiKey = requireEnv('IMMICH_API_KEY'); const apiKey = env.IMMICH_API_KEY;
const gallery = loadGalleryYaml(); const gallery = loadGalleryYaml();
// Parse standalone albums // Parse standalone albums
const standaloneAlbumIds = (gallery.albums ?? []) const standaloneAlbumIds = (gallery.albums ?? []).map((id) =>
.map((id) => validateUuid(id, 'gallery.yaml albums')); validateUuid(id, 'gallery.yaml albums'),
);
if (standaloneAlbumIds.length === 0 && (!gallery.subpages || gallery.subpages.length === 0)) { if (standaloneAlbumIds.length === 0 && (!gallery.subpages || gallery.subpages.length === 0)) {
throw new Error('gallery.yaml must define at least one album or subpage'); throw new Error('gallery.yaml must define at least one album or subpage');
@@ -152,21 +162,31 @@ export function getConfig(): AppConfig {
// Parse subpages // Parse subpages
const subpages: SubpageConfig[] = (gallery.subpages ?? []).map((sp) => { const subpages: SubpageConfig[] = (gallery.subpages ?? []).map((sp) => {
if (!sp.name || !sp.albums || sp.albums.length === 0) { 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 { return {
name: sp.name, name: sp.name,
slug: slugify(sp.name), slug: slugify(sp.name),
albumIds: sp.albums.map((id) => validateUuid(id, `subpage "${sp.name}"`)), albumIds: sp.albums.map((id) => validateUuid(id, `subpage "${sp.name}"`)),
password: sp.password, password: sp.password,
...(sp.grid ? { ...(sp.grid
grid: { ? {
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), grid: {
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
...(sp.grid.layout != null ? { layout: (sp.grid.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'] } : {}), ...(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, albums: allAlbumIds,
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)), standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
subpages, subpages,
siteTitle: process.env.SITE_TITLE || 'Gallery', siteTitle: env.SITE_TITLE,
siteSubtitle: process.env.SITE_SUBTITLE || '', siteSubtitle: env.SITE_SUBTITLE,
heroImages: gallery.hero heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]) ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
.map((id) => validateUuid(id, 'gallery.yaml hero')) validateUuid(id, 'gallery.yaml hero'),
)
: [], : [],
exifOnHover: gallery.exifOnHover !== false, exifOnHover: gallery.exifOnHover !== false,
grid: { grid: {
@@ -195,8 +216,16 @@ export function getConfig(): AppConfig {
aspectRatio: gallery.grid?.aspectRatio ?? '1', aspectRatio: gallery.grid?.aspectRatio ?? '1',
layout: (gallery.grid?.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'], layout: (gallery.grid?.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'],
}, },
cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000, footer: gallery.footer
rateLimitRpm: parseInt(process.env.RATE_LIMIT_RPM || '120', 10), ? {
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; return _config;

45
lib/env.ts Normal file
View File

@@ -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<typeof envSchema>;
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();

View File

@@ -13,310 +13,312 @@ import { cache } from './cache';
// ── Types ────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────
export interface ImmichAlbum { export interface ImmichAlbum {
id: string; id: string;
slug: string; slug: string;
albumName: string; albumName: string;
description: string; description: string;
albumThumbnailAssetId: string | null; albumThumbnailAssetId: string | null;
assetCount: number; assetCount: number;
assets: ImmichAsset[]; assets: ImmichAsset[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
order: 'asc' | 'desc'; order: 'asc' | 'desc';
} }
export interface ImmichAsset { export interface ImmichAsset {
id: string; id: string;
type: 'IMAGE' | 'VIDEO'; type: 'IMAGE' | 'VIDEO';
originalFileName: string; originalFileName: string;
originalMimeType: string; originalMimeType: string;
thumbhash: string | null; thumbhash: string | null;
fileCreatedAt: string; fileCreatedAt: string;
exifInfo?: ImmichExifInfo; exifInfo?: ImmichExifInfo;
isTrashed: boolean; isTrashed: boolean;
} }
export interface ImmichExifInfo { export interface ImmichExifInfo {
make: string | null; make: string | null;
model: string | null; model: string | null;
lensModel: string | null; lensModel: string | null;
focalLength: number | null; focalLength: number | null;
fNumber: number | null; fNumber: number | null;
exposureTime: string | null; exposureTime: string | null;
iso: number | null; iso: number | null;
exifImageWidth: number | null; exifImageWidth: number | null;
exifImageHeight: number | null; exifImageHeight: number | null;
city: string | null; city: string | null;
state: string | null; state: string | null;
country: string | null; country: string | null;
dateTimeOriginal: string | null; dateTimeOriginal: string | null;
description: string | null; description: string | null;
} }
export type ImageSize = 'thumbnail' | 'preview' | 'original'; export type ImageSize = 'thumbnail' | 'preview' | 'original';
/** Enriched subpage with album metadata (for rendering cards). */ /** Enriched subpage with album metadata (for rendering cards). */
export interface SubpageSummary { export interface SubpageSummary {
name: string; name: string;
slug: string; slug: string;
albumCount: number; albumCount: number;
totalAssetCount: number; totalAssetCount: number;
coverAssetId: string | null; coverAssetId: string | null;
} }
// ── API Client ───────────────────────────────────────────────── // ── API Client ─────────────────────────────────────────────────
class ImmichClient { class ImmichClient {
private hasLoggedAlbums = false; private hasLoggedAlbums = false;
private get config() { private get config() {
return getConfig(); return getConfig();
}
/**
* Make an authenticated request to the Immich API.
*/
private async request<T>(endpoint: string): Promise<T | null> {
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. * Stream a binary response from Immich (for image proxying).
*/ */
private async request<T>(endpoint: string): Promise<T | null> { async streamAsset(
const url = `${this.config.immich.apiUrl}${endpoint}`; assetId: string,
try { size: ImageSize = 'preview',
const res = await fetch(url, { ): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> {
headers: { const endpoint =
'x-api-key': this.config.immich.apiKey, size === 'original'
Accept: 'application/json', ? `/assets/${encodeURIComponent(assetId)}/original`
}, : `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`;
});
if (!res.ok) { const url = `${this.config.immich.apiUrl}${endpoint}`;
console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`); try {
return null; const res = await fetch(url, {
} headers: {
'x-api-key': this.config.immich.apiKey,
},
});
const contentType = res.headers.get('Content-Type') || ''; if (!res.ok || !res.body) {
if (contentType.includes('application/json')) { console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
return (await res.json()) as T; return null;
} }
return null;
} catch (error) { return {
console.error(`[Immich] Failed to reach ${url}:`, error); stream: res.body,
return null; 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<ImmichAlbum[]> {
const cacheKey = 'albums-list';
const cached = cache.get<ImmichAlbum[]>(cacheKey);
if (cached) return cached;
const all = await this.request<ImmichAlbum[]>('/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}`);
} }
} }
/** // Log subpage groupings
* Stream a binary response from Immich (for image proxying). for (const sp of this.config.subpages) {
*/ const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id));
async streamAsset( console.log(` 📁 ${sp.name} (/${sp.slug}):`);
assetId: string, for (const a of spAlbums) {
size: ImageSize = 'preview', console.log(` 📷 ${a.albumName}`);
): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> { console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`);
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;
} }
}
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');
} }
/** cache.set(cacheKey, filtered, this.config.cacheTtl);
* Get ALL configured albums (filtered by the full allowlist). return filtered;
* Uses ?shared=true to only fetch albums that have been shared in Immich. }
*/
async getAlbums(): Promise<ImmichAlbum[]> {
const cacheKey = 'albums-list';
const cached = cache.get<ImmichAlbum[]>(cacheKey);
if (cached) return cached;
const all = await this.request<ImmichAlbum[]>('/albums?shared=true'); /**
if (!all) return []; * Get only standalone albums (not in any subpage) — for the homepage.
*/
async getStandaloneAlbums(): Promise<ImmichAlbum[]> {
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 * Get enriched subpage summaries for the homepage.
.filter((album) => allowedIds.has(album.id)) */
.map((album) => ({ async getSubpages(): Promise<SubpageSummary[]> {
...album, if (this.config.subpages.length === 0) return [];
slug: slugify(album.albumName),
}));
// Log album summary on first load so admins can see what's published const albums = await this.getAlbums();
if (!this.hasLoggedAlbums) { const albumMap = new Map(albums.map((a) => [a.id, a]));
this.hasLoggedAlbums = true;
console.log('\n[Lightbox] Published albums:');
console.log('─'.repeat(80));
// Log standalone albums return this.config.subpages.map((sp) => {
const standaloneIds = new Set(this.config.standaloneAlbums); const spAlbums = sp.albumIds
const standalone = filtered.filter((a) => standaloneIds.has(a.id)); .map((id) => albumMap.get(id))
if (standalone.length > 0) { .filter((a): a is ImmichAlbum => a !== undefined);
console.log(' Standalone:');
for (const a of standalone) {
console.log(` 📷 ${a.albumName}`);
console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`);
}
}
// Log subpage groupings return {
for (const sp of this.config.subpages) { name: sp.name,
const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id)); slug: sp.slug,
console.log(` 📁 ${sp.name} (/${sp.slug}):`); albumCount: spAlbums.length,
for (const a of spAlbums) { totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0),
console.log(` 📷 ${a.albumName}`); coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null,
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) { * Get albums belonging to a specific subpage.
console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`); */
} async getSubpageAlbums(
console.log('─'.repeat(80) + '\n'); 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); const allAlbums = await this.getAlbums();
return filtered; 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<ImmichAlbum | null> {
// 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}`;
* Get only standalone albums (not in any subpage) — for the homepage. const cached = cache.get<ImmichAlbum>(cacheKey);
*/ if (cached) return cached;
async getStandaloneAlbums(): Promise<ImmichAlbum[]> {
const albums = await this.getAlbums(); const album = await this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`);
const standaloneIds = new Set(this.config.standaloneAlbums); if (!album) return null;
return albums.filter((a) => standaloneIds.has(a.id));
// 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<ImmichAlbum | null> {
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);
* Get enriched subpage summaries for the homepage. if (!match) return null;
*/ return this.getAlbum(match.id);
async getSubpages(): Promise<SubpageSummary[]> { }
if (this.config.subpages.length === 0) return [];
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 * Get asset info including EXIF data.
.map((id) => albumMap.get(id)) */
.filter((a): a is ImmichAlbum => a !== undefined); async getAssetInfo(assetId: string): Promise<ImmichAsset | null> {
const cacheKey = `asset-${assetId}`;
const cached = cache.get<ImmichAsset>(cacheKey);
if (cached) return cached;
return { const asset = await this.request<ImmichAsset>(`/assets/${encodeURIComponent(assetId)}`);
name: sp.name, if (!asset) return null;
slug: sp.slug,
albumCount: spAlbums.length,
totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0),
coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null,
};
});
}
/** cache.set(cacheKey, asset, this.config.cacheTtl);
* Get albums belonging to a specific subpage. return asset;
*/ }
async getSubpageAlbums(subpageSlug: string): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
if (!subpage) return null;
const allAlbums = await this.getAlbums(); /**
const subpageAlbumIds = new Set(subpage.albumIds); * Check if the Immich server is reachable.
const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id)); */
async ping(): Promise<boolean> {
return { subpage, albums }; const res = await this.request<{ res: string }>('/server/ping');
} return !!res;
}
/**
* Get a single album with its assets.
*/
async getAlbum(albumId: string): Promise<ImmichAlbum | null> {
// 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<ImmichAlbum>(cacheKey);
if (cached) return cached;
const album = await this.request<ImmichAlbum>(`/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<ImmichAlbum | null> {
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<ImmichAsset | null> {
const cacheKey = `asset-${assetId}`;
const cached = cache.get<ImmichAsset>(cacheKey);
if (cached) return cached;
const asset = await this.request<ImmichAsset>(`/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<boolean> {
const res = await this.request<{ res: string }>('/server/ping');
return !!res;
}
} }
export const immich = new ImmichClient(); export const immich = new ImmichClient();

View File

@@ -13,12 +13,12 @@
'use client'; 'use client';
interface ImageLoaderParams { interface ImageLoaderParams {
src: string; src: string;
width: number; width: number;
quality?: number; quality?: number;
} }
export default function immichLoader({ src, width, quality }: ImageLoaderParams): string { export default function immichLoader({ src, width, quality }: ImageLoaderParams): string {
const separator = src.includes('?') ? '&' : '?'; const separator = src.includes('?') ? '&' : '?';
return `${src}${separator}w=${width}&q=${quality || 75}`; return `${src}${separator}w=${width}&q=${quality || 75}`;
} }

View File

@@ -5,8 +5,8 @@
*/ */
interface RateLimitEntry { interface RateLimitEntry {
count: number; count: number;
expiresAt: number; expiresAt: number;
} }
const store = new Map<string, RateLimitEntry>(); const store = new Map<string, RateLimitEntry>();
@@ -16,12 +16,12 @@ let lastCleanup = Date.now();
const CLEANUP_INTERVAL = 60_000; const CLEANUP_INTERVAL = 60_000;
function cleanup() { function cleanup() {
const now = Date.now(); const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return; if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now; lastCleanup = now;
for (const [key, entry] of store) { for (const [key, entry] of store) {
if (now > entry.expiresAt) store.delete(key); if (now > entry.expiresAt) store.delete(key);
} }
} }
/** /**
@@ -31,29 +31,29 @@ function cleanup() {
* @returns { success, remaining, resetAt (epoch ms) } * @returns { success, remaining, resetAt (epoch ms) }
*/ */
export function checkRateLimit( export function checkRateLimit(
ip: string, ip: string,
maxRpm: number, maxRpm: number,
): { success: boolean; remaining: number; resetAt: number } { ): { success: boolean; remaining: number; resetAt: number } {
cleanup(); cleanup();
const now = Date.now(); const now = Date.now();
const windowMs = 60_000; const windowMs = 60_000;
const key = `rl:${ip}`; const key = `rl:${ip}`;
const entry = store.get(key); const entry = store.get(key);
// New window or expired // New window or expired
if (!entry || now > entry.expiresAt) { if (!entry || now > entry.expiresAt) {
const resetAt = now + windowMs; const resetAt = now + windowMs;
store.set(key, { count: 1, expiresAt: resetAt }); store.set(key, { count: 1, expiresAt: resetAt });
return { success: true, remaining: maxRpm - 1, resetAt }; return { success: true, remaining: maxRpm - 1, resetAt };
} }
// Within window // Within window
entry.count++; entry.count++;
if (entry.count > maxRpm) { if (entry.count > maxRpm) {
return { success: false, remaining: 0, resetAt: entry.expiresAt }; 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 };
} }

View File

@@ -11,8 +11,8 @@ import { thumbHashToRGBA, thumbHashToDataURL } from 'thumbhash';
* suitable for next/image's blurDataURL prop. * suitable for next/image's blurDataURL prop.
*/ */
export function thumbHashToBlurDataUrl(base64: string): string { export function thumbHashToBlurDataUrl(base64: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return thumbHashToDataURL(bytes); return thumbHashToDataURL(bytes);
} }
/** /**
@@ -20,23 +20,29 @@ export function thumbHashToBlurDataUrl(base64: string): string {
* Computes the weighted average of all non-transparent pixels. * Computes the weighted average of all non-transparent pixels.
*/ */
export function thumbHashToDominantHex(base64: string): string { export function thumbHashToDominantHex(base64: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const { w, h, rgba } = thumbHashToRGBA(bytes); const { w, h, rgba } = thumbHashToRGBA(bytes);
let r = 0, g = 0, b = 0, total = 0; let r = 0,
for (let i = 0; i < w * h; i++) { g = 0,
const a = rgba[i * 4 + 3]; b = 0,
if (a > 0) { total = 0;
const weight = a / 255; for (let i = 0; i < w * h; i++) {
r += rgba[i * 4] * weight; const a = rgba[i * 4 + 3];
g += rgba[i * 4 + 1] * weight; if (a > 0) {
b += rgba[i * 4 + 2] * weight; const weight = a / 255;
total += weight; 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'); const avg = (v: number) =>
return `#${avg(r)}${avg(g)}${avg(b)}`; Math.round(v / total)
.toString(16)
.padStart(2, '0');
return `#${avg(r)}${avg(g)}${avg(b)}`;
} }

View File

@@ -12,27 +12,24 @@ import { getConfig } from './config';
let _key: Buffer | null = null; let _key: Buffer | null = null;
function getKey(): Buffer { function getKey(): Buffer {
if (!_key) { if (!_key) {
const apiKey = getConfig().immich.apiKey; const apiKey = getConfig().immich.apiKey;
_key = crypto.createHash('sha256').update(apiKey).digest(); _key = crypto.createHash('sha256').update(apiKey).digest();
} }
return _key; return _key;
} }
/** /**
* Encode an asset ID into an opaque URL-safe token. * Encode an asset ID into an opaque URL-safe token.
*/ */
export function encodeAssetId(assetId: string): string { export function encodeAssetId(assetId: string): string {
const key = getKey(); const key = getKey();
// Deterministic IV from asset ID (same input → same token) // Deterministic IV from asset ID (same input → same token)
const iv = crypto.createHash('md5').update(assetId).digest(); const iv = crypto.createHash('md5').update(assetId).digest();
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([ const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]);
cipher.update(assetId, 'utf8'), // Compact: base64url(iv + ciphertext)
cipher.final(), return Buffer.concat([iv, encrypted]).toString('base64url');
]);
// 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. * Returns null if the token is invalid.
*/ */
export function decodeAssetId(token: string): string | null { export function decodeAssetId(token: string): string | null {
try { try {
const key = getKey(); const key = getKey();
const data = Buffer.from(token, 'base64url'); const data = Buffer.from(token, 'base64url');
if (data.length < 17) return null; // iv(16) + at least 1 byte if (data.length < 17) return null; // iv(16) + at least 1 byte
const iv = data.subarray(0, 16); const iv = data.subarray(0, 16);
const encrypted = data.subarray(16); const encrypted = data.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const decrypted = Buffer.concat([ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(
decipher.update(encrypted), 'utf8',
decipher.final(), );
]).toString('utf8');
// Validate it looks like a UUID // 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; 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; return uuidRegex.test(decrypted) ? decrypted : null;
} catch { } catch {
return null; return null;
} }
} }

View File

@@ -10,23 +10,26 @@ import type { ImmichAsset } from './immich';
/** /**
* Generate a public image proxy URL for an asset. * Generate a public image proxy URL for an asset.
*/ */
export function imageUrl(assetId: string, size: 'thumbnail' | 'preview' | 'original' = 'preview'): string { export function imageUrl(
return `/api/image/${encodeAssetId(assetId)}?size=${size}`; assetId: string,
size: 'thumbnail' | 'preview' | 'original' = 'preview',
): string {
return `/api/image/${encodeAssetId(assetId)}?size=${size}`;
} }
/** /**
* Generate a public EXIF API URL for an asset. * Generate a public EXIF API URL for an asset.
*/ */
export function exifUrl(assetId: string): string { export function exifUrl(assetId: string): string {
return `/api/exif/${encodeAssetId(assetId)}`; return `/api/exif/${encodeAssetId(assetId)}`;
} }
/** /**
* Placeholder data derived from an asset's ThumbHash. * Placeholder data derived from an asset's ThumbHash.
*/ */
export interface PlaceholderData { export interface PlaceholderData {
blurDataURL: string; blurDataURL: string;
dominantColor: string; dominantColor: string;
} }
/** /**
@@ -34,24 +37,24 @@ export interface PlaceholderData {
* Returns null if the asset has no ThumbHash. * Returns null if the asset has no ThumbHash.
*/ */
export function assetPlaceholder(asset: Pick<ImmichAsset, 'thumbhash'>): PlaceholderData | null { export function assetPlaceholder(asset: Pick<ImmichAsset, 'thumbhash'>): PlaceholderData | null {
if (!asset.thumbhash) return null; if (!asset.thumbhash) return null;
try { try {
return { return {
blurDataURL: thumbHashToBlurDataUrl(asset.thumbhash), blurDataURL: thumbHashToBlurDataUrl(asset.thumbhash),
dominantColor: thumbHashToDominantHex(asset.thumbhash), dominantColor: thumbHashToDominantHex(asset.thumbhash),
}; };
} catch { } catch {
return null; return null;
} }
} }
/** /**
* Compact EXIF summary for hover overlays. * Compact EXIF summary for hover overlays.
*/ */
export interface ExifSummary { export interface ExifSummary {
camera?: string; camera?: string;
lens?: string; lens?: string;
focalLength?: string; focalLength?: string;
} }
/** /**
@@ -59,15 +62,15 @@ export interface ExifSummary {
* Returns undefined if no relevant EXIF data is available. * Returns undefined if no relevant EXIF data is available.
*/ */
export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSummary | undefined { export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSummary | undefined {
const exif = asset.exifInfo; const exif = asset.exifInfo;
if (!exif) return undefined; if (!exif) return undefined;
const camera = exif.model || undefined; const camera = exif.model || undefined;
const lens = exif.lensModel || undefined; const lens = exif.lensModel || undefined;
const focalLength = exif.focalLength ? `${exif.focalLength}mm` : undefined; const focalLength = exif.focalLength ? `${exif.focalLength}mm` : undefined;
if (!camera && !lens && !focalLength) return undefined; if (!camera && !lens && !focalLength) return undefined;
return { camera, lens, focalLength }; return { camera, lens, focalLength };
} }
/** /**
@@ -75,8 +78,8 @@ export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSumm
* Returns undefined if dimensions are not available. * Returns undefined if dimensions are not available.
*/ */
export function assetAspectRatio(asset: Pick<ImmichAsset, 'exifInfo'>): number | undefined { export function assetAspectRatio(asset: Pick<ImmichAsset, 'exifInfo'>): number | undefined {
const w = asset.exifInfo?.exifImageWidth; const w = asset.exifInfo?.exifImageWidth;
const h = asset.exifInfo?.exifImageHeight; const h = asset.exifInfo?.exifImageHeight;
if (w && h && h > 0) return w / h; if (w && h && h > 0) return w / h;
return undefined; return undefined;
} }

View File

@@ -1,4 +1,4 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',

1729
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,13 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "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": { "dependencies": {
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -14,15 +20,22 @@
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"thumbhash": "^0.1.1" "thumbhash": "^0.1.1",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "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"
} }
} }

View File

@@ -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. 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:** **Stack:**
- **Next.js 15** (App Router) — SSR for fast first paint, API routes for proxying - **Next.js 15** (App Router) — SSR for fast first paint, API routes for proxying
- **TypeScript** — End-to-end type safety - **TypeScript** — End-to-end type safety
- **CSS Modules + CSS custom properties** — Clean, maintainable dark-theme styling - **CSS Modules + CSS custom properties** — Clean, maintainable dark-theme styling

View File

@@ -136,10 +136,10 @@ Upgrade the developer tooling and quality infrastructure for immich-lightbox. Th
## Suggested Implementation Order ## Suggested Implementation Order
| Phase | Feature | Dependency | | Phase | Feature | Dependency |
|-------|---------|------------| | ----- | --------------------- | ------------------------------ |
| 1 | Prettier + ESLint | None — clean baseline first | | 1 | Prettier + ESLint | None — clean baseline first |
| 2 | Vitest unit tests | None — tests existing code | | 2 | Vitest unit tests | None — tests existing code |
| 3 | Zod env validation | Unit tests can verify schemas | | 3 | Zod env validation | Unit tests can verify schemas |
| 4 | CSS Modules migration | Lint/format tools catch issues | | 4 | CSS Modules migration | Lint/format tools catch issues |
| 5 | Playwright e2e tests | All other changes landed first | | 5 | Playwright e2e tests | All other changes landed first |

27
playwright.config.ts Normal file
View File

@@ -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,
},
});

19
vitest.config.ts Normal file
View File

@@ -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, '.'),
},
},
});