feat: photography features & image optimizations
- Hero carousel: gallery.yaml hero accepts single UUID or array, crossfade component - Full-bleed single albums: subpages with 1 album skip grid, render photos directly - EXIF on hover: camera/lens/focal length overlay on grid items (toggle via exifOnHover) - Customizable grid: columns/gap/aspectRatio/layout (masonry|uniform) via gallery.yaml - Per-subpage grid overrides: each subpage can override global grid config - True masonry: per-image aspect ratios from EXIF dimensions - next/image migration: homepage hero, album covers, grid thumbnails, about portrait - ThumbHash blur placeholders and dominant color backgrounds - Custom next/image loader via image proxy with width/quality params - Lightbox fade-in transition with dominant color background - Password-protected subpages, health check, rate limiting, OG images
This commit is contained in:
@@ -36,6 +36,6 @@ ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Lightbox } from '@/components/Lightbox';
|
||||
|
||||
export interface PhotoItem {
|
||||
@@ -14,14 +15,24 @@ export interface PhotoItem {
|
||||
previewUrl: string;
|
||||
originalUrl: string;
|
||||
exifUrl: string;
|
||||
blurDataURL?: string;
|
||||
dominantColor?: string;
|
||||
/** Compact EXIF summary for hover overlay */
|
||||
camera?: string;
|
||||
lens?: string;
|
||||
focalLength?: string;
|
||||
/** Natural image aspect ratio (width/height) for masonry layout */
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
interface PhotoGridProps {
|
||||
assets: PhotoItem[];
|
||||
albumId: string;
|
||||
layout?: 'masonry' | 'uniform';
|
||||
gridStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function PhotoGrid({ assets }: PhotoGridProps) {
|
||||
export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) {
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
const openLightbox = useCallback((index: number) => {
|
||||
@@ -74,18 +85,34 @@ export function PhotoGrid({ assets }: PhotoGridProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="photo-grid">
|
||||
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
|
||||
{assets.map((asset, index) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="photo-grid__item"
|
||||
onClick={() => openLightbox(index)}
|
||||
style={{
|
||||
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
|
||||
...(layout === 'masonry' && asset.aspectRatio
|
||||
? { aspectRatio: `${asset.aspectRatio}` }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src={asset.thumbUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
|
||||
loading={index < 6 ? 'eager' : 'lazy'}
|
||||
{...(asset.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
{(asset.camera || asset.lens) && (
|
||||
<div className="photo-grid__item-exif">
|
||||
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -102,3 +129,4 @@ export function PhotoGrid({ assets }: PhotoGridProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,16 @@
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { Metadata } from 'next';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { imageUrl, exifUrl } from '@/lib/urls';
|
||||
import { imageUrl, exifUrl, assetPlaceholder, assetExifSummary, assetAspectRatio } from '@/lib/urls';
|
||||
import { getConfig, type GridConfig } from '@/lib/config';
|
||||
import { isProtected, isAuthenticated } from '@/lib/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
|
||||
// Render at request time — requires live Immich connection
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -23,8 +29,69 @@ interface PathPageProps {
|
||||
params: Promise<{ path: string[] }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> {
|
||||
const { path } = await params;
|
||||
if (!path || path.length === 0) return {};
|
||||
|
||||
const slug = path[0];
|
||||
let title = slug;
|
||||
let subtitle = '';
|
||||
|
||||
if (path.length === 2) {
|
||||
const album = await immich.getAlbumBySlug(path[1], slug);
|
||||
if (album) {
|
||||
title = album.albumName;
|
||||
const count = album.assets.filter((a) => a.type === 'IMAGE').length;
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
} else if (immich.isSubpageSlug(slug)) {
|
||||
const result = await immich.getSubpageAlbums(slug);
|
||||
if (result) {
|
||||
// Single album → use album name; multiple → use subpage name
|
||||
if (result.albums.length === 1) {
|
||||
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
|
||||
if (album) {
|
||||
title = album.albumName;
|
||||
const count = album.assets.filter((a) => a.type === 'IMAGE').length;
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
} else {
|
||||
title = result.subpage.name;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const album = await immich.getAlbumBySlug(slug);
|
||||
if (album) {
|
||||
title = album.albumName;
|
||||
const count = album.assets.filter((a) => a.type === 'IMAGE').length;
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
|
||||
const ogUrl = `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: { title, images: [ogUrl] },
|
||||
twitter: { card: 'summary_large_image', title, images: [ogUrl] },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PathPage({ params }: PathPageProps) {
|
||||
const { path } = await params;
|
||||
const config = getConfig();
|
||||
|
||||
// Build grid CSS custom properties, optionally merging subpage overrides
|
||||
const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => {
|
||||
const g = { ...config.grid, ...overrides };
|
||||
return {
|
||||
'--grid-columns': g.columns,
|
||||
'--grid-gap': `${g.gap}px`,
|
||||
'--grid-aspect-ratio': g.aspectRatio,
|
||||
} as React.CSSProperties;
|
||||
};
|
||||
const resolveLayout = (overrides?: Partial<GridConfig>) =>
|
||||
overrides?.layout ?? config.grid.layout;
|
||||
|
||||
if (!path || path.length === 0) {
|
||||
notFound();
|
||||
@@ -33,14 +100,27 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
// ── Two segments: subpage/album ──────────────────────────────
|
||||
if (path.length === 2) {
|
||||
const [subpageSlug, albumSlug] = path;
|
||||
|
||||
// Password gate for protected subpages
|
||||
if (isProtected(subpageSlug)) {
|
||||
const cookieStore = await cookies();
|
||||
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
||||
if (!isAuthenticated(subpageSlug, getCookie)) {
|
||||
const subpageData = await immich.getSubpageAlbums(subpageSlug);
|
||||
const subpageName = subpageData?.subpage.name ?? subpageSlug;
|
||||
return <PasswordGate slug={subpageSlug} title={subpageName} />;
|
||||
}
|
||||
}
|
||||
|
||||
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug);
|
||||
|
||||
if (!album) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Get subpage name for the back link
|
||||
// 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');
|
||||
@@ -61,14 +141,23 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
<PhotoGrid
|
||||
assets={images.map((a) => ({
|
||||
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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -79,6 +168,17 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
// 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();
|
||||
@@ -86,19 +186,81 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
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) => (
|
||||
{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 ? (
|
||||
<img
|
||||
<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
|
||||
@@ -113,7 +275,8 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -142,14 +305,23 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
<PhotoGrid
|
||||
assets={images.map((a) => ({
|
||||
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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
122
app/about/about.css
Normal file
122
app/about/about.css
Normal file
@@ -0,0 +1,122 @@
|
||||
/* ── About Page — Bauhaus / Leica Minimalist ──────── */
|
||||
|
||||
.about {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
}
|
||||
|
||||
/* ── Portrait Column ─────────────────────────────── */
|
||||
.about__portrait-col {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.about__portrait {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.about__portrait--placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── Text Column ─────────────────────────────────── */
|
||||
.about__text-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 80px 64px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.about__name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 2.4rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.about__location {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Thin Rule Dividers ──────────────────────────── */
|
||||
.about__rule {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin: 40px 0;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
/* ── Bio ─────────────────────────────────────────── */
|
||||
.about__bio p {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.8;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.about__bio p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Gear Section ────────────────────────────────── */
|
||||
.about__section-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.about__gear {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.about__gear li {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 300;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.about {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.about__portrait-col {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.about__text-col {
|
||||
padding: 48px 24px 64px;
|
||||
}
|
||||
|
||||
.about__name {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
88
app/about/page.tsx
Normal file
88
app/about/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* About page — Bauhaus / Leica minimalist layout.
|
||||
* Reads content from content/about.md (frontmatter + markdown body).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import Image from 'next/image';
|
||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||
import { immich } from '@/lib/immich';
|
||||
import './about.css';
|
||||
|
||||
interface AboutFrontmatter {
|
||||
portrait?: string;
|
||||
name?: string;
|
||||
location?: string;
|
||||
gear?: string[];
|
||||
}
|
||||
|
||||
function getAboutContent() {
|
||||
const raw = readFileSync(join(process.cwd(), 'content', 'about.md'), 'utf-8');
|
||||
const { data, content } = matter(raw);
|
||||
return { meta: data as AboutFrontmatter, body: content.trim() };
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const { meta, body } = getAboutContent();
|
||||
|
||||
// Fetch ThumbHash for portrait placeholder
|
||||
const portraitAsset = meta.portrait
|
||||
? await immich.getAssetInfo(meta.portrait)
|
||||
: null;
|
||||
const portraitPlaceholder = portraitAsset ? assetPlaceholder(portraitAsset) : null;
|
||||
|
||||
return (
|
||||
<div className="about">
|
||||
{/* ── Portrait ─────────────────────────────────── */}
|
||||
<div className="about__portrait-col">
|
||||
{meta.portrait ? (
|
||||
<Image
|
||||
src={imageUrl(meta.portrait, 'preview')}
|
||||
alt={meta.name || 'Portrait'}
|
||||
className="about__portrait"
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 40vw"
|
||||
{...(portraitPlaceholder
|
||||
? { placeholder: 'blur' as const, blurDataURL: portraitPlaceholder.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
) : (
|
||||
<div className="about__portrait about__portrait--placeholder" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Text ─────────────────────────────────────── */}
|
||||
<div className="about__text-col">
|
||||
{meta.name && <h1 className="about__name">{meta.name}</h1>}
|
||||
{meta.location && (
|
||||
<p className="about__location">{meta.location}</p>
|
||||
)}
|
||||
|
||||
<hr className="about__rule" />
|
||||
|
||||
{body && (
|
||||
<div className="about__bio">
|
||||
{body.split('\n').filter(Boolean).map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.gear && meta.gear.length > 0 && (
|
||||
<>
|
||||
<hr className="about__rule" />
|
||||
<h2 className="about__section-label">Gear</h2>
|
||||
<ul className="about__gear">
|
||||
{meta.gear.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
46
app/api/auth/route.ts
Normal file
46
app/api/auth/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Auth API — validates album passwords and sets auth cookies.
|
||||
* POST /api/auth { slug, password }
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { authenticate, isProtected } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { slug, password } = body;
|
||||
|
||||
if (!slug || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing slug or password' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isProtected(slug)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Album is not password-protected' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const setCookie = authenticate(slug, password);
|
||||
if (!setCookie) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid password' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true },
|
||||
{ headers: { 'Set-Cookie': setCookie } },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
26
app/api/health/route.ts
Normal file
26
app/api/health/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Health check endpoint — reports app and Immich connectivity status.
|
||||
* Used by Docker HEALTHCHECK, monitoring tools, and load balancers.
|
||||
*
|
||||
* GET /api/health → { status, immich, uptime }
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { immich } from '@/lib/immich';
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
export async function GET() {
|
||||
const immichOk = await immich.ping();
|
||||
|
||||
const body = {
|
||||
status: immichOk ? 'ok' : 'degraded',
|
||||
immich: immichOk ? 'connected' : 'unreachable',
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
};
|
||||
|
||||
return NextResponse.json(body, {
|
||||
status: immichOk ? 200 : 503,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
@@ -3,19 +3,49 @@
|
||||
* without exposing the Immich server or API key.
|
||||
*
|
||||
* Usage: GET /api/image/:token?size=thumbnail|preview|original
|
||||
* GET /api/image/:token?w=640&q=75 (for next/image loader)
|
||||
* The :token is an encoded asset ID (not a raw UUID).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { immich, ImageSize } from '@/lib/immich';
|
||||
import { decodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit } from '@/lib/rate-limit';
|
||||
|
||||
const VALID_SIZES: ImageSize[] = ['thumbnail', 'preview', 'original'];
|
||||
|
||||
/**
|
||||
* Map a requested pixel width to the best Immich size tier.
|
||||
*/
|
||||
function widthToSize(w: number): ImageSize {
|
||||
if (w <= 250) return 'thumbnail';
|
||||
if (w <= 1440) return 'preview';
|
||||
return 'original';
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
// ── Rate limiting ──────────────────────────────────
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown';
|
||||
const { success, remaining, resetAt } = checkRateLimit(ip, getConfig().rateLimitRpm);
|
||||
|
||||
if (!success) {
|
||||
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(retryAfter),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { id: token } = await params;
|
||||
|
||||
// Decode the opaque token back to an Immich asset ID
|
||||
@@ -24,11 +54,19 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Parse and validate size parameter
|
||||
const sizeParam = request.nextUrl.searchParams.get('size') || 'preview';
|
||||
const size: ImageSize = VALID_SIZES.includes(sizeParam as ImageSize)
|
||||
? (sizeParam as ImageSize)
|
||||
: 'preview';
|
||||
// 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 result = await immich.streamAsset(assetId, size);
|
||||
if (!result) {
|
||||
@@ -39,6 +77,7 @@ export async function GET(
|
||||
'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) {
|
||||
@@ -47,3 +86,4 @@ export async function GET(
|
||||
|
||||
return new NextResponse(result.stream, { headers });
|
||||
}
|
||||
|
||||
|
||||
93
app/api/og/route.tsx
Normal file
93
app/api/og/route.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Dynamic OG image generator — renders social share previews.
|
||||
* Uses next/og (ImageResponse) to create 1200×630 cards.
|
||||
*
|
||||
* GET /api/og?title=Album+Name&subtitle=12+photos
|
||||
*/
|
||||
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const title = searchParams.get('title') || 'Gallery';
|
||||
const subtitle = searchParams.get('subtitle') || '';
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#0a0a0a',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
padding: '60px',
|
||||
}}
|
||||
>
|
||||
{/* Decorative top line */}
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '2px',
|
||||
backgroundColor: '#d4af37',
|
||||
marginBottom: '40px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
fontWeight: 600,
|
||||
color: '#f0f0f0',
|
||||
letterSpacing: '0.02em',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
maxWidth: '900px',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
{subtitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
color: '#999999',
|
||||
fontWeight: 300,
|
||||
marginTop: '16px',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'lowercase',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom decorative line */}
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '2px',
|
||||
backgroundColor: '#d4af37',
|
||||
marginTop: '40px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
170
app/globals.css
170
app/globals.css
@@ -119,8 +119,9 @@ img {
|
||||
padding: 48px 24px 96px;
|
||||
}
|
||||
|
||||
/* Homepage hero uses full bleed — override main padding */
|
||||
.main:has(.hero) {
|
||||
/* Homepage hero + About page use full bleed — override main padding */
|
||||
.main:has(.hero),
|
||||
.main:has(.about) {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -205,6 +206,17 @@ img {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── Hero Carousel Crossfade ────────────────────── */
|
||||
.hero__carousel-image {
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 1.2s ease-in-out;
|
||||
}
|
||||
|
||||
.hero__carousel-image--active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Section Labels ──────────────────────────────── */
|
||||
.section-label {
|
||||
font-family: var(--font-sans);
|
||||
@@ -387,22 +399,36 @@ img {
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
column-count: 3;
|
||||
column-gap: 12px;
|
||||
column-count: var(--grid-columns, 3);
|
||||
column-gap: var(--grid-gap, 12px);
|
||||
}
|
||||
|
||||
/* Uniform layout: CSS Grid with fixed aspect ratio */
|
||||
.photo-grid--uniform {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grid-columns, 3), 1fr);
|
||||
gap: var(--grid-gap, 12px);
|
||||
column-count: unset;
|
||||
}
|
||||
|
||||
.photo-grid__item {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: var(--grid-gap, 12px);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
aspect-ratio: var(--grid-aspect-ratio, 1);
|
||||
}
|
||||
|
||||
.photo-grid--uniform .photo-grid__item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.photo-grid__item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: filter var(--transition-normal), transform var(--transition-slow);
|
||||
}
|
||||
@@ -412,6 +438,31 @@ img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* EXIF hover overlay */
|
||||
.photo-grid__item-exif {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20px 10px 8px;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.photo-grid__item:hover .photo-grid__item-exif {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Lightbox ─────────────────────────────────────── */
|
||||
.lightbox-overlay {
|
||||
position: fixed;
|
||||
@@ -455,6 +506,8 @@ img {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lightbox__image {
|
||||
@@ -464,6 +517,12 @@ img {
|
||||
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 {
|
||||
@@ -649,10 +708,101 @@ img {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* ── Password Gate ────────────────────────────────── */
|
||||
.password-gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.password-gate__card {
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.password-gate__title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.password-gate__subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 300;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.password-gate__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.password-gate__input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.password-gate__input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.password-gate__input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.password-gate__button {
|
||||
padding: 12px 24px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.password-gate__button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.password-gate__button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-gate__error {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: #e74c3c;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────── */
|
||||
@media (max-width: 1024px) {
|
||||
.photo-grid {
|
||||
column-count: 2;
|
||||
--grid-columns: 2;
|
||||
}
|
||||
|
||||
.album-grid {
|
||||
@@ -679,7 +829,7 @@ img {
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
column-count: 1;
|
||||
--grid-columns: 1;
|
||||
}
|
||||
|
||||
.album-grid {
|
||||
@@ -723,4 +873,8 @@ img {
|
||||
.lightbox__nav--next {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.password-gate__card {
|
||||
padding: 36px 24px;
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,24 @@ import Link from 'next/link';
|
||||
import './globals.css';
|
||||
import { SubpageNav } from '@/components/SubpageNav';
|
||||
|
||||
const siteTitle = process.env.SITE_TITLE || 'Gallery';
|
||||
const siteDescription = 'A curated photography portfolio';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: process.env.SITE_TITLE || 'Gallery',
|
||||
description: 'A curated photography portfolio',
|
||||
title: siteTitle,
|
||||
description: siteDescription,
|
||||
openGraph: {
|
||||
title: siteTitle,
|
||||
description: siteDescription,
|
||||
type: 'website',
|
||||
images: [`/api/og?title=${encodeURIComponent(siteTitle)}`],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: siteTitle,
|
||||
description: siteDescription,
|
||||
images: [`/api/og?title=${encodeURIComponent(siteTitle)}`],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -27,6 +42,9 @@ export default function RootLayout({
|
||||
Home
|
||||
</Link>
|
||||
<SubpageNav />
|
||||
<Link href="/about" className="header__nav-link">
|
||||
About
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="main">{children}</main>
|
||||
|
||||
30
app/page.tsx
30
app/page.tsx
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Homepage — Split hero layout.
|
||||
* Left panel: site title, subtitle, navigation links.
|
||||
* Right panel: hero image from Immich.
|
||||
* Right panel: hero image carousel from Immich.
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { imageUrl } from '@/lib/urls';
|
||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||
import { HeroCarousel } from '@/components/HeroCarousel';
|
||||
|
||||
// Render at request time — requires live Immich connection
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -19,6 +20,18 @@ export default async function HomePage() {
|
||||
immich.getStandaloneAlbums(),
|
||||
]);
|
||||
|
||||
// Fetch ThumbHash for all hero images
|
||||
const heroData = await Promise.all(
|
||||
config.heroImages.map(async (id) => {
|
||||
const asset = await immich.getAssetInfo(id);
|
||||
const ph = asset ? assetPlaceholder(asset) : null;
|
||||
return {
|
||||
src: imageUrl(id, 'preview'),
|
||||
...(ph ? { blurDataURL: ph.blurDataURL } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="hero">
|
||||
{/* ── Left Panel ──────────────────────────────── */}
|
||||
@@ -52,18 +65,11 @@ export default async function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right Panel (Hero Image) ────────────────── */}
|
||||
{/* ── Right Panel (Hero Carousel) ─────────────── */}
|
||||
<div className="hero__right">
|
||||
{config.heroImage ? (
|
||||
<img
|
||||
src={imageUrl(config.heroImage, 'preview')}
|
||||
alt=""
|
||||
className="hero__image"
|
||||
/>
|
||||
) : (
|
||||
<div className="hero__image-placeholder" />
|
||||
)}
|
||||
<HeroCarousel images={heroData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
77
components/HeroCarousel.tsx
Normal file
77
components/HeroCarousel.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* HeroCarousel — crossfade between hero images on a timer.
|
||||
*
|
||||
* If only one image is provided, renders a static image with no timer.
|
||||
* When multiple images are provided, crossfades between them every 6 seconds.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface HeroImage {
|
||||
src: string;
|
||||
blurDataURL?: string;
|
||||
}
|
||||
|
||||
interface HeroCarouselProps {
|
||||
images: HeroImage[];
|
||||
}
|
||||
|
||||
const INTERVAL_MS = 6000;
|
||||
|
||||
export function HeroCarousel({ images }: HeroCarouselProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % images.length);
|
||||
}, [images.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (images.length <= 1) return;
|
||||
|
||||
const timer = setInterval(advance, INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [images.length, advance]);
|
||||
|
||||
if (images.length === 0) {
|
||||
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 }
|
||||
: {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export function Lightbox({
|
||||
const [showExif, setShowExif] = useState(false);
|
||||
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);
|
||||
@@ -64,6 +65,7 @@ export function Lightbox({
|
||||
// Reset EXIF data when switching images; refetch if panel is open
|
||||
useEffect(() => {
|
||||
setExifData(null);
|
||||
setImageLoaded(false);
|
||||
if (showExif && current) {
|
||||
fetchExif(current.exifUrl);
|
||||
}
|
||||
@@ -149,12 +151,22 @@ export function Lightbox({
|
||||
</button>
|
||||
|
||||
{/* Image */}
|
||||
<div className="lightbox__image-container">
|
||||
<div
|
||||
className="lightbox__image-container"
|
||||
style={{
|
||||
backgroundColor: current.dominantColor || '#000',
|
||||
backgroundImage: current.blurDataURL ? `url(${current.blurDataURL})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="lightbox__image"
|
||||
className={`lightbox__image${imageLoaded ? ' lightbox__image--loaded' : ''}`}
|
||||
src={current.previewUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
76
components/PasswordGate.tsx
Normal file
76
components/PasswordGate.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Password gate — shown when accessing a protected album/subpage.
|
||||
* Submits to /api/auth and refreshes on success.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface PasswordGateProps {
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function PasswordGate({ slug, title }: PasswordGateProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
setError('Wrong password');
|
||||
setPassword('');
|
||||
}
|
||||
} catch {
|
||||
setError('Something went wrong');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="password-gate">
|
||||
<div className="password-gate__card">
|
||||
<h2 className="password-gate__title">{title}</h2>
|
||||
<p className="password-gate__subtitle">
|
||||
This gallery is password-protected.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="password-gate__form">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="password-gate__input"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="password-gate__button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Verifying…' : 'Enter'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className="password-gate__error">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
content/gallery.yaml
Normal file
25
content/gallery.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# ── Gallery Configuration ──────────────────────────────────────
|
||||
# Album and subpage definitions for your public gallery.
|
||||
# Secrets (API key, server URL) stay in .env.local.
|
||||
|
||||
hero: 03c0df0e-01d7-4cc7-8352-cf30d2289ad6
|
||||
|
||||
# Standalone albums shown directly on the homepage
|
||||
albums:
|
||||
- a89c6085-a105-46c2-baa4-338bc69cab93
|
||||
- 9aee9367-a239-4651-980d-3368bc70b7b6
|
||||
|
||||
# Subpages group multiple albums under one URL path
|
||||
subpages:
|
||||
- name: Deutschland
|
||||
albums:
|
||||
- 9aee9367-a239-4651-980d-3368bc70b7b6
|
||||
|
||||
- name: Dubai
|
||||
albums:
|
||||
- a89c6085-a105-46c2-baa4-338bc69cab93
|
||||
|
||||
- name: Polen
|
||||
password: clientpass123
|
||||
albums:
|
||||
- 4ad0ed89-55de-4e69-937f-2d9988f63ad1
|
||||
44
content/gallery.yaml.example
Normal file
44
content/gallery.yaml.example
Normal file
@@ -0,0 +1,44 @@
|
||||
# ── Gallery Configuration ──────────────────────────────────────
|
||||
# Define which Immich albums to publish and how to organize them.
|
||||
#
|
||||
# Find album UUIDs in Immich: Albums → click album → UUID in the URL bar.
|
||||
# Find asset UUIDs: click a photo → UUID in the URL bar.
|
||||
|
||||
# Hero image(s) shown on the homepage (Immich asset UUIDs).
|
||||
# Use a single UUID for a static image, or a list for a crossfade carousel.
|
||||
hero: 00000000-0000-0000-0000-000000000000
|
||||
# hero:
|
||||
# - 00000000-0000-0000-0000-000000000000
|
||||
# - 11111111-1111-1111-1111-111111111111
|
||||
# - 22222222-2222-2222-2222-222222222222
|
||||
|
||||
# Standalone albums — shown directly on the homepage as cards
|
||||
albums:
|
||||
- 11111111-1111-1111-1111-111111111111
|
||||
- 22222222-2222-2222-2222-222222222222
|
||||
|
||||
# Show camera/lens EXIF on photo hover (default: true)
|
||||
# exifOnHover: false
|
||||
|
||||
# Photo grid layout — customize columns, gap, aspect ratio, and mode.
|
||||
# grid:
|
||||
# columns: 3 # default: 3
|
||||
# gap: 12 # pixels, default: 12
|
||||
# aspectRatio: "1" # "1", "3/2", "auto", etc. default: "1"
|
||||
# layout: masonry # "masonry" or "uniform", default: "masonry"
|
||||
|
||||
# Subpages — group albums into collections at /subpage-slug/album-slug
|
||||
subpages:
|
||||
- name: Japan
|
||||
grid: # override global grid for this subpage
|
||||
columns: 4
|
||||
layout: uniform
|
||||
aspectRatio: "3/2"
|
||||
albums:
|
||||
- 33333333-3333-3333-3333-333333333333 # Tokyo
|
||||
- 44444444-4444-4444-4444-444444444444 # Kyoto
|
||||
|
||||
- name: Wedding – Smith
|
||||
password: clientpass123 # visitors must enter this to view
|
||||
albums:
|
||||
- 55555555-5555-5555-5555-555555555555
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
container_name: immich-lightbox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "7211:7211"
|
||||
environment:
|
||||
- IMMICH_API_URL=${IMMICH_API_URL}
|
||||
- IMMICH_API_KEY=${IMMICH_API_KEY}
|
||||
|
||||
87
lib/auth.ts
Normal file
87
lib/auth.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Album authentication helpers.
|
||||
* Uses HMAC tokens stored in HttpOnly cookies — no database needed.
|
||||
*
|
||||
* Token = HMAC-SHA256(slug + password, apiKey)
|
||||
* Cookie = lb_auth_<slug> = <token>
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { getConfig, SubpageConfig } from './config';
|
||||
|
||||
const TOKEN_EXPIRY_HOURS = 24;
|
||||
|
||||
function hmac(data: string): string {
|
||||
return crypto
|
||||
.createHmac('sha256', getConfig().immich.apiKey)
|
||||
.update(data)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function authToken(slug: string, password: string): string {
|
||||
return hmac(`${slug}:${password}`);
|
||||
}
|
||||
|
||||
function cookieName(slug: string): string {
|
||||
return `lb_auth_${slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the SubpageConfig for a given slug.
|
||||
*/
|
||||
export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
|
||||
return getConfig().subpages.find((sp) => sp.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a slug corresponds to a password-protected subpage.
|
||||
*/
|
||||
export function isProtected(slug: string): boolean {
|
||||
const sp = findSubpageBySlug(slug);
|
||||
return !!sp?.password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a password attempt and return a Set-Cookie header value on success.
|
||||
* Returns null if the password is wrong.
|
||||
*/
|
||||
export function authenticate(
|
||||
slug: string,
|
||||
password: string,
|
||||
): string | null {
|
||||
const sp = findSubpageBySlug(slug);
|
||||
if (!sp?.password) return null;
|
||||
|
||||
if (password !== sp.password) return null;
|
||||
|
||||
const token = authToken(slug, password);
|
||||
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
|
||||
|
||||
return `${cookieName(slug)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request has a valid auth cookie for the given slug.
|
||||
* Works with the cookies() API from Next.js server components.
|
||||
*/
|
||||
export function isAuthenticated(
|
||||
slug: string,
|
||||
getCookie: (name: string) => string | undefined,
|
||||
): boolean {
|
||||
const sp = findSubpageBySlug(slug);
|
||||
if (!sp?.password) return true; // not protected
|
||||
|
||||
const cookie = getCookie(cookieName(slug));
|
||||
if (!cookie) return false;
|
||||
|
||||
const expected = authToken(slug, sp.password);
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(cookie, 'hex'),
|
||||
Buffer.from(expected, 'hex'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
258
lib/config.ts
258
lib/config.ts
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Typed environment configuration with validation.
|
||||
* Fails fast at startup if required variables are missing.
|
||||
* Typed configuration loader.
|
||||
* Secrets come from env vars; gallery structure comes from content/gallery.yaml.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
// ── Env helpers ────────────────────────────────────────────────
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
@@ -11,102 +17,79 @@ function requireEnv(name: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
/** A subpage groups multiple Immich albums under one URL path. */
|
||||
export interface SubpageConfig {
|
||||
name: string;
|
||||
slug: string;
|
||||
albumIds: string[];
|
||||
/** Optional password — if set, visitors must authenticate to view. */
|
||||
password?: string;
|
||||
/** Per-subpage grid overrides (merged with global grid config). */
|
||||
grid?: Partial<GridConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a UUID from a string that may be "Label:uuid" or just "uuid".
|
||||
*/
|
||||
function extractUuid(entry: string): string | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return null;
|
||||
const uuidMatch = trimmed.match(
|
||||
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
|
||||
);
|
||||
return uuidMatch ? uuidMatch[1] : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LIGHTBOX_SUBPAGES env var.
|
||||
* Format: "Japan > uuid1, uuid2; Italy > uuid3, uuid4"
|
||||
* Each subpage separated by ";", name separated from album IDs by ">".
|
||||
*/
|
||||
function parseSubpages(raw: string | undefined): SubpageConfig[] {
|
||||
if (!raw || !raw.trim()) return [];
|
||||
|
||||
// Split on semicolons OR newlines — supports both single-line and multiline formats
|
||||
return raw
|
||||
.split(/[;\n]/)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.map((segment) => {
|
||||
const parts = segment.split('>');
|
||||
if (parts.length < 2) {
|
||||
console.warn(`[Config] Skipping malformed subpage entry: "${segment}"`);
|
||||
return null;
|
||||
}
|
||||
const name = parts[0].trim();
|
||||
const albumIds = parts
|
||||
.slice(1)
|
||||
.join('>') // rejoin in case name contained ">"
|
||||
.split(',')
|
||||
.map((id) => extractUuid(id))
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
if (!name || albumIds.length === 0) {
|
||||
console.warn(`[Config] Skipping subpage with missing name or albums: "${segment}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name, slug: slugify(name), albumIds };
|
||||
})
|
||||
.filter((sp): sp is SubpageConfig => sp !== null);
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, '');
|
||||
const apiKey = requireEnv('IMMICH_API_KEY');
|
||||
|
||||
// Parse standalone albums from LIGHTBOX_ALBUMS
|
||||
const standaloneAlbumIds = requireEnv('LIGHTBOX_ALBUMS')
|
||||
.split(',')
|
||||
.map((entry) => extractUuid(entry))
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
if (standaloneAlbumIds.length === 0) {
|
||||
throw new Error('LIGHTBOX_ALBUMS must contain at least one album ID');
|
||||
}
|
||||
|
||||
// Parse subpages (optional)
|
||||
const subpages = parseSubpages(process.env.LIGHTBOX_SUBPAGES);
|
||||
|
||||
// Collect ALL album IDs (standalone + subpage) — deduplicated
|
||||
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
|
||||
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
|
||||
|
||||
return {
|
||||
immich: {
|
||||
apiUrl: `${apiUrl}/api`,
|
||||
apiKey,
|
||||
},
|
||||
/** All album IDs (standalone + subpage) for allowlist validation */
|
||||
albums: allAlbumIds,
|
||||
/** Album IDs that appear only in LIGHTBOX_ALBUMS and NOT in any subpage */
|
||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||
/** Subpage definitions */
|
||||
subpages,
|
||||
siteTitle: process.env.SITE_TITLE || 'Gallery',
|
||||
siteSubtitle: process.env.SITE_SUBTITLE || '',
|
||||
/** Immich asset ID for the homepage hero image */
|
||||
heroImage: process.env.HERO_IMAGE || '',
|
||||
cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000, // convert to ms
|
||||
/** Raw YAML structure (before validation). */
|
||||
interface GalleryYaml {
|
||||
hero?: string | string[];
|
||||
albums?: string[];
|
||||
exifOnHover?: boolean;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
subpages?: Array<{
|
||||
name: string;
|
||||
albums: string[];
|
||||
password?: string;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── YAML loading ───────────────────────────────────────────────
|
||||
|
||||
function loadGalleryYaml(): GalleryYaml {
|
||||
const yamlPath = path.join(process.cwd(), 'content', 'gallery.yaml');
|
||||
|
||||
if (!fs.existsSync(yamlPath)) {
|
||||
throw new Error(
|
||||
`Gallery config not found: ${yamlPath}\n` +
|
||||
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
|
||||
);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(yamlPath, 'utf8');
|
||||
const data = yaml.load(raw) as GalleryYaml;
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('gallery.yaml is empty or invalid');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── UUID validation ────────────────────────────────────────────
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function validateUuid(id: string, context: string): string {
|
||||
const trimmed = id.trim();
|
||||
if (!UUID_REGEX.test(trimmed)) {
|
||||
throw new Error(`Invalid UUID in ${context}: "${trimmed}"`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// ── Slugify ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert an album name into a URL-friendly slug.
|
||||
* "Kloster Chorin" → "kloster-chorin"
|
||||
@@ -120,4 +103,101 @@ export function slugify(name: string): string {
|
||||
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
||||
}
|
||||
|
||||
export type AppConfig = ReturnType<typeof getConfig>;
|
||||
// ── Config type ────────────────────────────────────────────────
|
||||
|
||||
/** Grid layout configuration. */
|
||||
export interface GridConfig {
|
||||
columns: number;
|
||||
gap: number;
|
||||
aspectRatio: string;
|
||||
layout: 'masonry' | 'uniform';
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
immich: { apiUrl: string; apiKey: string };
|
||||
albums: string[];
|
||||
standaloneAlbums: string[];
|
||||
subpages: SubpageConfig[];
|
||||
siteTitle: string;
|
||||
siteSubtitle: string;
|
||||
/** One or more hero image asset IDs for the homepage carousel. */
|
||||
heroImages: string[];
|
||||
/** Show EXIF (camera/lens) on photo grid hover. Default: true. */
|
||||
exifOnHover: boolean;
|
||||
/** Photo grid layout configuration. */
|
||||
grid: GridConfig;
|
||||
cacheTtl: number;
|
||||
rateLimitRpm: number;
|
||||
}
|
||||
|
||||
// ── Config builder ─────────────────────────────────────────────
|
||||
|
||||
let _config: AppConfig | null = null;
|
||||
|
||||
export function getConfig(): AppConfig {
|
||||
if (_config) return _config;
|
||||
|
||||
const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, '');
|
||||
const apiKey = requireEnv('IMMICH_API_KEY');
|
||||
const gallery = loadGalleryYaml();
|
||||
|
||||
// Parse standalone albums
|
||||
const standaloneAlbumIds = (gallery.albums ?? [])
|
||||
.map((id) => validateUuid(id, 'gallery.yaml albums'));
|
||||
|
||||
if (standaloneAlbumIds.length === 0 && (!gallery.subpages || gallery.subpages.length === 0)) {
|
||||
throw new Error('gallery.yaml must define at least one album or subpage');
|
||||
}
|
||||
|
||||
// Parse subpages
|
||||
const subpages: SubpageConfig[] = (gallery.subpages ?? []).map((sp) => {
|
||||
if (!sp.name || !sp.albums || sp.albums.length === 0) {
|
||||
throw new Error(`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`);
|
||||
}
|
||||
return {
|
||||
name: sp.name,
|
||||
slug: slugify(sp.name),
|
||||
albumIds: sp.albums.map((id) => validateUuid(id, `subpage "${sp.name}"`)),
|
||||
password: sp.password,
|
||||
...(sp.grid ? {
|
||||
grid: {
|
||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||
...(sp.grid.layout != null ? { layout: (sp.grid.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'] } : {}),
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
});
|
||||
|
||||
// Collect ALL album IDs (standalone + subpage) — deduplicated
|
||||
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
|
||||
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
|
||||
|
||||
_config = {
|
||||
immich: {
|
||||
apiUrl: `${apiUrl}/api`,
|
||||
apiKey,
|
||||
},
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||
subpages,
|
||||
siteTitle: process.env.SITE_TITLE || 'Gallery',
|
||||
siteSubtitle: process.env.SITE_SUBTITLE || '',
|
||||
heroImages: gallery.hero
|
||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
|
||||
.map((id) => validateUuid(id, 'gallery.yaml hero'))
|
||||
: [],
|
||||
exifOnHover: gallery.exifOnHover !== false,
|
||||
grid: {
|
||||
columns: gallery.grid?.columns ?? 3,
|
||||
gap: gallery.grid?.gap ?? 12,
|
||||
aspectRatio: gallery.grid?.aspectRatio ?? '1',
|
||||
layout: (gallery.grid?.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'],
|
||||
},
|
||||
cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000,
|
||||
rateLimitRpm: parseInt(process.env.RATE_LIMIT_RPM || '120', 10),
|
||||
};
|
||||
|
||||
return _config;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface ImmichExifInfo {
|
||||
fNumber: number | null;
|
||||
exposureTime: string | null;
|
||||
iso: number | null;
|
||||
exifImageWidth: number | null;
|
||||
exifImageHeight: number | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
country: string | null;
|
||||
|
||||
24
lib/immichLoader.ts
Normal file
24
lib/immichLoader.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Custom next/image loader for Immich proxy.
|
||||
*
|
||||
* Referenced by next.config.ts via images.loaderFile.
|
||||
* Generates URLs that point to our /api/image proxy route,
|
||||
* passing width and quality as query parameters.
|
||||
*
|
||||
* The src passed to next/image should already be a full proxy URL
|
||||
* (e.g. /api/image/<token>?size=preview). This loader appends
|
||||
* w and q params for responsive srcset generation.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
interface ImageLoaderParams {
|
||||
src: string;
|
||||
width: number;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export default function immichLoader({ src, width, quality }: ImageLoaderParams): string {
|
||||
const separator = src.includes('?') ? '&' : '?';
|
||||
return `${src}${separator}w=${width}&q=${quality || 75}`;
|
||||
}
|
||||
59
lib/rate-limit.ts
Normal file
59
lib/rate-limit.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* In-memory sliding-window rate limiter.
|
||||
* Tracks request counts per IP per minute bucket.
|
||||
* Auto-evicts expired entries to prevent memory leaks.
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Evict expired entries periodically (every 60s)
|
||||
let lastCleanup = Date.now();
|
||||
const CLEANUP_INTERVAL = 60_000;
|
||||
|
||||
function cleanup() {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||
lastCleanup = now;
|
||||
for (const [key, entry] of store) {
|
||||
if (now > entry.expiresAt) store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request should be allowed.
|
||||
* @param ip - Client IP address
|
||||
* @param maxRpm - Maximum requests per minute
|
||||
* @returns { success, remaining, resetAt (epoch ms) }
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
ip: string,
|
||||
maxRpm: number,
|
||||
): { success: boolean; remaining: number; resetAt: number } {
|
||||
cleanup();
|
||||
|
||||
const now = Date.now();
|
||||
const windowMs = 60_000;
|
||||
const key = `rl:${ip}`;
|
||||
|
||||
const entry = store.get(key);
|
||||
|
||||
// New window or expired
|
||||
if (!entry || now > entry.expiresAt) {
|
||||
const resetAt = now + windowMs;
|
||||
store.set(key, { count: 1, expiresAt: resetAt });
|
||||
return { success: true, remaining: maxRpm - 1, resetAt };
|
||||
}
|
||||
|
||||
// Within window
|
||||
entry.count++;
|
||||
if (entry.count > maxRpm) {
|
||||
return { success: false, remaining: 0, resetAt: entry.expiresAt };
|
||||
}
|
||||
|
||||
return { success: true, remaining: maxRpm - entry.count, resetAt: entry.expiresAt };
|
||||
}
|
||||
42
lib/thumbhash.ts
Normal file
42
lib/thumbhash.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* ThumbHash utilities for generating blur placeholders and dominant colors.
|
||||
* Uses the thumbhash package to decode Immich's base64-encoded ThumbHash strings
|
||||
* into data URLs and hex color values.
|
||||
*/
|
||||
|
||||
import { thumbHashToRGBA, thumbHashToDataURL } from 'thumbhash';
|
||||
|
||||
/**
|
||||
* Convert an Immich base64-encoded ThumbHash into a tiny data URL
|
||||
* suitable for next/image's blurDataURL prop.
|
||||
*/
|
||||
export function thumbHashToBlurDataUrl(base64: string): string {
|
||||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||
return thumbHashToDataURL(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the dominant/average color from a ThumbHash as a hex string.
|
||||
* Computes the weighted average of all non-transparent pixels.
|
||||
*/
|
||||
export function thumbHashToDominantHex(base64: string): string {
|
||||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||
const { w, h, rgba } = thumbHashToRGBA(bytes);
|
||||
|
||||
let r = 0, g = 0, b = 0, total = 0;
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
const a = rgba[i * 4 + 3];
|
||||
if (a > 0) {
|
||||
const weight = a / 255;
|
||||
r += rgba[i * 4] * weight;
|
||||
g += rgba[i * 4 + 1] * weight;
|
||||
b += rgba[i * 4 + 2] * weight;
|
||||
total += weight;
|
||||
}
|
||||
}
|
||||
|
||||
if (total === 0) return '#888888';
|
||||
|
||||
const avg = (v: number) => Math.round(v / total).toString(16).padStart(2, '0');
|
||||
return `#${avg(r)}${avg(g)}${avg(b)}`;
|
||||
}
|
||||
62
lib/urls.ts
62
lib/urls.ts
@@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
import { encodeAssetId } from './tokens';
|
||||
import { thumbHashToBlurDataUrl, thumbHashToDominantHex } from './thumbhash';
|
||||
import type { ImmichAsset } from './immich';
|
||||
|
||||
/**
|
||||
* Generate a public image proxy URL for an asset.
|
||||
@@ -18,3 +20,63 @@ export function imageUrl(assetId: string, size: 'thumbnail' | 'preview' | 'origi
|
||||
export function exifUrl(assetId: string): string {
|
||||
return `/api/exif/${encodeAssetId(assetId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder data derived from an asset's ThumbHash.
|
||||
*/
|
||||
export interface PlaceholderData {
|
||||
blurDataURL: string;
|
||||
dominantColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate blur placeholder and dominant color from an asset's ThumbHash.
|
||||
* Returns null if the asset has no ThumbHash.
|
||||
*/
|
||||
export function assetPlaceholder(asset: Pick<ImmichAsset, 'thumbhash'>): PlaceholderData | null {
|
||||
if (!asset.thumbhash) return null;
|
||||
try {
|
||||
return {
|
||||
blurDataURL: thumbHashToBlurDataUrl(asset.thumbhash),
|
||||
dominantColor: thumbHashToDominantHex(asset.thumbhash),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact EXIF summary for hover overlays.
|
||||
*/
|
||||
export interface ExifSummary {
|
||||
camera?: string;
|
||||
lens?: string;
|
||||
focalLength?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a compact EXIF summary from an asset's exifInfo.
|
||||
* Returns undefined if no relevant EXIF data is available.
|
||||
*/
|
||||
export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSummary | undefined {
|
||||
const exif = asset.exifInfo;
|
||||
if (!exif) return undefined;
|
||||
|
||||
const camera = exif.model || undefined;
|
||||
const lens = exif.lensModel || undefined;
|
||||
const focalLength = exif.focalLength ? `${exif.focalLength}mm` : undefined;
|
||||
|
||||
if (!camera && !lens && !focalLength) return undefined;
|
||||
return { camera, lens, focalLength };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the natural aspect ratio (width / height) from EXIF dimensions.
|
||||
* Returns undefined if dimensions are not available.
|
||||
*/
|
||||
export function assetAspectRatio(asset: Pick<ImmichAsset, 'exifInfo'>): number | undefined {
|
||||
const w = asset.exifInfo?.exifImageWidth;
|
||||
const h = asset.exifInfo?.exifImageHeight;
|
||||
if (w && h && h > 0) return w / h;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
loader: 'custom',
|
||||
loaderFile: './lib/immichLoader.ts',
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
129
package-lock.json
generated
129
package-lock.json
generated
@@ -8,11 +8,15 @@
|
||||
"name": "immich-lightbox",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"gray-matter": "^4.0.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"thumbhash": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -1245,6 +1249,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -1887,7 +1898,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
@@ -3098,6 +3108,19 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
@@ -3144,6 +3167,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3472,6 +3507,43 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -3793,6 +3865,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -4093,7 +4174,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -4175,6 +4255,15 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/language-subtag-registry": {
|
||||
"version": "0.3.23",
|
||||
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
|
||||
@@ -4970,6 +5059,19 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -5195,6 +5297,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@@ -5339,6 +5447,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -5401,6 +5518,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/thumbhash": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
|
||||
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
|
||||
@@ -9,11 +9,15 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"gray-matter": "^4.0.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"thumbhash": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
145
plans/2026-02-14-developer-experience-testing-v1.md
Normal file
145
plans/2026-02-14-developer-experience-testing-v1.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Developer Experience & Testing
|
||||
|
||||
## Objective
|
||||
|
||||
Upgrade the developer tooling and quality infrastructure for immich-lightbox. This plan covers five features from IDEAS.md: migrating to CSS Modules, adding Zod-based env validation, configuring Prettier + stricter ESLint rules, adding Playwright end-to-end tests, and adding Vitest unit tests. The goal is a codebase that is easier to maintain, catches errors earlier, and has automated test coverage for critical paths.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### 1 · CSS Modules Migration
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is the highest-effort item. The 832-line `globals.css` will be split into co-located module files, which is a broad refactor touching every component and page.
|
||||
|
||||
**Current state:** One monolithic [globals.css](file:///Users/ralfo/git/immich-lightbox/app/globals.css) (832 lines) containing all component styles via BEM-like class names, plus [page.module.css](file:///Users/ralfo/git/immich-lightbox/app/page.module.css) and [about.css](file:///Users/ralfo/git/immich-lightbox/app/about/about.css). Next.js already supports CSS Modules natively — no config needed.
|
||||
|
||||
**Approach — CSS Modules (not Tailwind):** CSS Modules are the better fit here because the project already uses well-structured BEM classes, the design system relies on custom properties, and Tailwind would require learning a new utility API and add a build dependency. CSS Modules give local scoping with zero config in Next.js.
|
||||
|
||||
- [ ] 1. Create a global design-tokens file `app/tokens.css` containing only CSS custom properties (`:root { ... }`), font imports, and base reset styles extracted from the top ~56 lines of `globals.css`
|
||||
- [ ] 2. Create `components/Lightbox.module.css` — extract all `.lightbox-*`, `.exif-panel-*` rules (~140 lines) from `globals.css`
|
||||
- [ ] 3. Create `components/PasswordGate.module.css` — extract `.password-gate-*` rules (~70 lines) from `globals.css`
|
||||
- [ ] 4. Create `components/SubpageNav.module.css` — extract `.subpage-nav-*` rules if any from `globals.css`
|
||||
- [ ] 5. Create `app/[...path]/path.module.css` — extract `.photo-grid-*`, `.album-card-*`, `.album-grid-*` rules (~120 lines) from `globals.css`
|
||||
- [ ] 6. Keep shared layout styles (`.header-*`, `.main`, `.hero-*`, `.section-label-*`, `.subpage-grid-*`) in a slimmed-down `globals.css` since they are used across multiple pages. Convert the remaining BEM classes to camelCase where they become module-scoped
|
||||
- [ ] 7. Update all TSX files to import from their co-located `.module.css` and use `styles.className` syntax instead of string class names
|
||||
- [ ] 8. Move responsive media queries into each module file alongside their component styles rather than keeping them in one block at the bottom of `globals.css`
|
||||
- [ ] 9. Verify the build succeeds and visually spot-check the homepage, a subpage, the about page, and the lightbox
|
||||
|
||||
---
|
||||
|
||||
### 2 · Zod Env Validation
|
||||
|
||||
**Current state:** [config.ts](file:///Users/ralfo/git/immich-lightbox/lib/config.ts) uses a manual `requireEnv()` helper and `parseInt()` with defaults. Errors are thrown as plain strings with no type safety on the parsed result.
|
||||
|
||||
- [ ] 1. Install `zod` as a production dependency
|
||||
- [ ] 2. Create `lib/env.ts` — define a Zod schema for all environment variables (`IMMICH_API_URL`, `IMMICH_API_KEY`, `SITE_TITLE`, `SITE_SUBTITLE`, `CACHE_TTL`, `RATE_LIMIT_RPM`) with types, defaults, and transforms (e.g. `coerce.number().default(300)` for `CACHE_TTL`)
|
||||
- [ ] 3. Export a validated, typed `env` object from `lib/env.ts` that is parsed once at startup using `z.parse(process.env)`
|
||||
- [ ] 4. Replace all `requireEnv()` calls and manual `process.env` reads in `lib/config.ts` with imports from `lib/env.ts`. Remove the now-unused `requireEnv()` function
|
||||
- [ ] 5. Update `.env.local.example` comments to document the Zod-enforced constraints (required vs optional, types, defaults)
|
||||
- [ ] 6. Verify by running `npm run build` with a valid `.env.local` — should succeed. Then temporarily remove `IMMICH_API_KEY` and confirm the build fails with a clear Zod validation error
|
||||
|
||||
---
|
||||
|
||||
### 3 · Prettier + ESLint Hardening
|
||||
|
||||
**Current state:** [eslint.config.mjs](file:///Users/ralfo/git/immich-lightbox/eslint.config.mjs) uses ESLint v9 flat config with `eslint-config-next` core-web-vitals and typescript presets. No Prettier config exists. No format-on-save enforcement.
|
||||
|
||||
- [ ] 1. Install Prettier and the ESLint-Prettier integration packages: `prettier`, `eslint-config-prettier`, `eslint-plugin-prettier`
|
||||
- [ ] 2. Create `.prettierrc` with project conventions: single quotes, trailing commas, 100 char print width, 2-space indent (matching the existing code style)
|
||||
- [ ] 3. Create `.prettierignore` to skip `node_modules`, `.next`, `build`, `out`, `*.yaml`
|
||||
- [ ] 4. Add `eslint-config-prettier` to the ESLint flat config to disable formatting rules that conflict with Prettier, and add `eslint-plugin-prettier` to surface Prettier violations as ESLint errors
|
||||
- [ ] 5. Add stricter custom ESLint rules: `no-console: warn`, `@typescript-eslint/no-unused-vars: error` (with underscore-prefix ignore pattern), `@typescript-eslint/no-explicit-any: warn`
|
||||
- [ ] 6. Add npm scripts: `"format": "prettier --write ."` and `"format:check": "prettier --check ."`
|
||||
- [ ] 7. Run `npx prettier --write .` to format the entire codebase in one commit, then run `npm run lint` to confirm no remaining lint errors
|
||||
- [ ] 8. Optionally add a `.vscode/settings.json` recommending format-on-save with Prettier
|
||||
|
||||
---
|
||||
|
||||
### 4 · Playwright E2E Tests
|
||||
|
||||
**Current state:** No test framework is installed. The app has 3 page routes (homepage `/`, catch-all `[...path]`, about `/about`) and 5 API routes (`/api/auth`, `/api/exif`, `/api/health`, `/api/image`, `/api/og`).
|
||||
|
||||
> [!WARNING]
|
||||
> E2E tests require a running dev server and a configured Immich instance (or mocked API responses). Tests will need environment setup documented in a test README or CI configuration.
|
||||
|
||||
- [ ] 1. Install Playwright as a dev dependency: `npm init playwright@latest` — select TypeScript, put tests in `e2e/`, skip GitHub Actions for now
|
||||
- [ ] 2. Configure `playwright.config.ts` — set `baseURL` to `http://localhost:3000`, configure `webServer` to run `npm run dev` automatically, set screenshot-on-failure
|
||||
- [ ] 3. Write `e2e/health.spec.ts` — test `GET /api/health` returns 200 with expected JSON shape
|
||||
- [ ] 4. Write `e2e/homepage.spec.ts` — test that the homepage loads, renders the header with site title, shows hero section, and displays album cards or subpage grid
|
||||
- [ ] 5. Write `e2e/about.spec.ts` — test that `/about` loads and shows the about page content
|
||||
- [ ] 6. Write `e2e/navigation.spec.ts` — test clicking a subpage card navigates to the correct URL, and the back button works
|
||||
- [ ] 7. Write `e2e/lightbox.spec.ts` — test clicking a photo opens the lightbox overlay, arrow keys navigate, Escape closes
|
||||
- [ ] 8. Write `e2e/password.spec.ts` — test that a password-protected subpage shows the gate, rejects wrong passwords, and unlocks on correct entry
|
||||
- [ ] 9. Add `"test:e2e": "playwright test"` to npm scripts
|
||||
- [ ] 10. Ensure all tests pass against the local dev server with the existing `gallery.yaml` and `.env.local`
|
||||
|
||||
---
|
||||
|
||||
### 5 · Vitest Unit Tests
|
||||
|
||||
**Current state:** No unit test framework installed. Key pure-logic modules suitable for unit testing: [tokens.ts](file:///Users/ralfo/git/immich-lightbox/lib/tokens.ts) (encode/decode), [config.ts](file:///Users/ralfo/git/immich-lightbox/lib/config.ts) (slugify, UUID validation, YAML parsing), [urls.ts](file:///Users/ralfo/git/immich-lightbox/lib/urls.ts) (URL generation), [thumbhash.ts](file:///Users/ralfo/git/immich-lightbox/lib/thumbhash.ts) (ThumbHash conversion).
|
||||
|
||||
- [ ] 1. Install Vitest and related packages: `vitest`, `@vitest/coverage-v8` as dev dependencies
|
||||
- [ ] 2. Create `vitest.config.ts` at project root — configure path aliases to match `tsconfig.json` (`@/*`), set `environment: 'node'`, include `lib/**/*.test.ts`
|
||||
- [ ] 3. Write `lib/__tests__/tokens.test.ts` — test `encodeAssetId` produces a stable base64url token for a given UUID, `decodeAssetId` round-trips correctly, invalid tokens return null, and short tokens return null
|
||||
- [ ] 4. Write `lib/__tests__/config.test.ts` — test `slugify()` with various inputs (diacritics, special chars, leading/trailing hyphens), test UUID validation rejects invalid UUIDs, test YAML parsing with a mock `gallery.yaml` fixture
|
||||
- [ ] 5. Write `lib/__tests__/urls.test.ts` — test `imageUrl()` generates correct proxy paths with encoded tokens, test `exifUrl()`, test `assetPlaceholder()` returns null for missing thumbhash
|
||||
- [ ] 6. Write `lib/__tests__/thumbhash.test.ts` — test `thumbHashToBlurDataUrl()` produces a valid data URL, test `thumbHashToDominantHex()` returns a valid hex color
|
||||
- [ ] 7. Add npm scripts: `"test": "vitest"`, `"test:unit": "vitest run"`, `"test:coverage": "vitest run --coverage"`
|
||||
- [ ] 8. Run `npm run test:unit` and confirm all tests pass
|
||||
|
||||
---
|
||||
|
||||
## Verification Criteria
|
||||
|
||||
### Automated Tests
|
||||
|
||||
1. **Vitest unit tests** — `npm run test:unit` must pass all tests in `lib/__tests__/`
|
||||
2. **Playwright e2e tests** — `npm run test:e2e` must pass all tests in `e2e/`, requires dev server and valid Immich connection
|
||||
3. **Lint check** — `npm run lint` must produce zero errors
|
||||
4. **Format check** — `npm run format:check` must produce zero violations
|
||||
5. **Build** — `npm run build` must succeed (catches CSS Modules import errors, Zod schema problems, TypeScript issues)
|
||||
|
||||
### Manual Verification
|
||||
|
||||
1. **Visual regression** — After CSS Modules migration, open the gallery in a browser and visually check: homepage hero, subpage grid, album cards, photo grid, lightbox with EXIF panel, password gate, about page, and all responsive breakpoints (mobile, tablet, desktop)
|
||||
2. **Zod errors** — Temporarily remove `IMMICH_API_KEY` from `.env.local` and run `npm run dev` — should print a clear, typed Zod validation error instead of a generic "missing env" error
|
||||
|
||||
---
|
||||
|
||||
## Potential Risks and Mitigations
|
||||
|
||||
1. **CSS Modules migration breaks styling**
|
||||
Mitigation: Migrate one component at a time, verify visually after each. Keep a slimmed `globals.css` for truly shared styles to avoid duplication.
|
||||
|
||||
2. **Playwright tests are flaky without a stable Immich backend**
|
||||
Mitigation: Start with the `/api/health` endpoint test which is self-contained. For other tests, document the required Immich setup. Consider adding API mocking with Playwright's `route.fulfill()` in a future iteration.
|
||||
|
||||
3. **Prettier reformats the entire codebase in one commit**
|
||||
Mitigation: Apply Prettier in a dedicated "format" commit with no logic changes, making `git blame` easy to filter with `git blame --ignore-rev`.
|
||||
|
||||
4. **Zod adds a runtime dependency**
|
||||
Mitigation: Zod is ~13KB gzipped and only runs server-side at startup. No client bundle impact since `lib/env.ts` is server-only.
|
||||
|
||||
---
|
||||
|
||||
## Alternative Approaches
|
||||
|
||||
1. **Tailwind instead of CSS Modules** — Would provide utility-first rapid styling, but the existing design system relies heavily on custom properties and BEM. Tailwind migration would be a bigger rewrite and add a PostCSS dependency. CSS Modules is the lower-risk choice here.
|
||||
2. **`@t3-oss/env-nextjs` instead of raw Zod** — A higher-level wrapper around Zod specifically for Next.js env vars with client/server separation. Worth considering if more env vars are added later, but raw Zod is simpler and sufficient for the current 6 variables.
|
||||
3. **Jest instead of Vitest** — Jest works fine but Vitest is faster, has native ESM support, and shares Vite's config conventions which align better with modern Next.js tooling.
|
||||
4. **Cypress instead of Playwright** — Playwright is faster, has better multi-browser support, and is increasingly the standard for Next.js projects.
|
||||
|
||||
---
|
||||
|
||||
## Suggested Implementation Order
|
||||
|
||||
| Phase | Feature | Dependency |
|
||||
|-------|---------|------------|
|
||||
| 1 | Prettier + ESLint | None — clean baseline first |
|
||||
| 2 | Vitest unit tests | None — tests existing code |
|
||||
| 3 | Zod env validation | Unit tests can verify schemas |
|
||||
| 4 | CSS Modules migration | Lint/format tools catch issues |
|
||||
| 5 | Playwright e2e tests | All other changes landed first |
|
||||
87
plans/2026-02-14-performance-images-v1.md
Normal file
87
plans/2026-02-14-performance-images-v1.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Performance & Images
|
||||
|
||||
## Objective
|
||||
|
||||
Replace all raw `<img>` tags with `next/image`, add ThumbHash blur-up placeholders from Immich metadata, and extract dominant-color placeholders — giving every image a near-instant perceived load with automatic WebP/AVIF optimization and responsive srcsets.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **`next/image` with custom proxy** — because images are served through our own `/api/image/:token` proxy, `next/image` cannot optimize them server-side by default. Two approaches:
|
||||
>
|
||||
> 1. **Custom loader** — tell `next/image` to build URLs that point back to our own proxy, passing `width` and `quality` as query params. The proxy then asks Immich for the appropriate size. This is the recommended approach and what this plan proposes.
|
||||
> 2. **`unoptimized` prop** — skip Next.js image optimization entirely and only get the blur-up / lazy-load benefits. Simpler but loses WebP/AVIF transcoding and responsive srcsets.
|
||||
>
|
||||
> This plan uses approach 1 (custom loader) for maximum benefit.
|
||||
|
||||
> [!WARNING]
|
||||
> **Lightbox images stay as raw `<img>`** — the Lightbox component is a client-side fullscreen overlay that loads the preview-quality image into a `<img>`. Converting this to `next/image` would add unnecessary overhead (srcset negotiation, layout shifts) for an image that's already viewport-sized. The plan leaves Lightbox images as raw `<img>` but still adds ThumbHash blur placeholders to them.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
- [ ] 1. **Extend the image proxy to accept width/quality params** — Modify [route.ts](file:///Users/ralfo/git/immich-lightbox/app/api/image/[id]/route.ts) to accept optional `w` (width) and `q` (quality) query parameters. When `w` is provided, map it to the closest Immich size tier (`thumbnail` ≤ 250, `preview` ≤ 1440, `original` otherwise). Keep backward compatibility so existing `?size=` calls continue to work unchanged.
|
||||
|
||||
- [ ] 2. **Create a custom `next/image` loader** — Add [immichLoader.ts](file:///Users/ralfo/git/immich-lightbox/lib/immichLoader.ts) exporting a loader function that generates `/api/image/:token?w=WIDTH&q=QUALITY` URLs. Must be a standalone file referenced via `loaderFile` in `next.config.ts` (required for App Router). Handles the `src → token → proxy URL` mapping.
|
||||
|
||||
- [ ] 3. **Configure Next.js for the custom loader** — Modify [next.config.ts](file:///Users/ralfo/git/immich-lightbox/next.config.ts) to add `images.loader: 'custom'` and `images.loaderFile: './lib/immichLoader.ts'` so `next/image` uses our proxy globally.
|
||||
|
||||
- [ ] 4. **Add `thumbhash` dependency** — Modify [package.json](file:///Users/ralfo/git/immich-lightbox/package.json) to add the `thumbhash` npm package (~2 KB, pure JS, no native deps). Run `npm install`.
|
||||
|
||||
- [ ] 5. **Create ThumbHash helper utilities** — Add [thumbhash.ts](file:///Users/ralfo/git/immich-lightbox/lib/thumbhash.ts) with two helpers: `thumbHashToDataUrl(base64)` converting Immich's base64-encoded ThumbHash into a tiny data URL for `next/image`'s `blurDataURL` prop, and `thumbHashToDominantColor(base64)` extracting the average/dominant color as a hex string from the ThumbHash's decoded RGBA data. Since ThumbHash encodes both shape and color, both blur placeholder and dominant color come from the same Immich field — no extra API calls.
|
||||
|
||||
- [ ] 6. **Add placeholder helper to URL utilities** — Modify [urls.ts](file:///Users/ralfo/git/immich-lightbox/lib/urls.ts) to add an `assetPlaceholder(asset)` helper returning `{ blurDataURL, dominantColor } | null`. This encapsulates ThumbHash → placeholder conversion server-side so page components just pass it through as props.
|
||||
|
||||
- [ ] 7. **Migrate homepage hero to `next/image`** — Modify [page.tsx](file:///Users/ralfo/git/immich-lightbox/app/page.tsx) to replace the raw `<img>` (line 58) with `next/image` using `fill` layout and `priority` (above-the-fold LCP image). Add `blurDataURL` and `placeholder="blur"` from the hero asset's ThumbHash. Fetch ThumbHash via `immich.getAssetInfo()` for the hero image ID.
|
||||
|
||||
- [ ] 8. **Migrate subpage album covers to `next/image`** — Modify [page.tsx](file:///Users/ralfo/git/immich-lightbox/app/[...path]/page.tsx) to replace album cover `<img>` tags (line 98) with `next/image` using `fill` layout. Pass `blurDataURL` and `placeholder="blur"` from each album's thumbnail asset ThumbHash. Batch-fetch ThumbHash data for `albumThumbnailAssetId` when loading subpage data.
|
||||
|
||||
- [ ] 9. **Migrate about page portrait to `next/image`** — Modify [about/page.tsx](file:///Users/ralfo/git/immich-lightbox/app/about/page.tsx) to replace the portrait `<img>` (line 33) with `next/image` using `fill` layout and ThumbHash blur placeholder.
|
||||
|
||||
- [ ] 10. **Migrate photo grid thumbnails to `next/image`** — Modify [PhotoGrid.tsx](file:///Users/ralfo/git/immich-lightbox/app/[...path]/PhotoGrid.tsx) to replace grid `<img>` tags (line 84) with `next/image` using `fill` layout and `sizes` attribute for responsive sizing. Extend the `PhotoItem` interface with optional `blurDataURL` and `dominantColor` fields, generated server-side and passed as props. Use `placeholder="blur"` when available. Use `dominantColor` as CSS `background-color` on the grid item container for an instant color flash.
|
||||
|
||||
- [ ] 11. **Add placeholders to the Lightbox** — Modify [Lightbox.tsx](file:///Users/ralfo/git/immich-lightbox/components/Lightbox.tsx) to keep using raw `<img>` for fullscreen preview but add dominant-color background on `.lightbox__image-container` and show the ThumbHash blur as a CSS `background-image` while the main image loads, fading it out with a CSS transition on the image's `onload` event.
|
||||
|
||||
- [ ] 12. **Add CSS for placeholder transitions** — Modify [globals.css](file:///Users/ralfo/git/immich-lightbox/app/globals.css) to add smooth blur-to-sharp opacity transitions on grid items (pseudo-element or background-image), dominant color background styling for grid items, lightbox container, and album covers.
|
||||
|
||||
- [ ] 13. **Verify build and lint** — Run `npm run build` and `npm run lint` to confirm no TypeScript or ESLint errors.
|
||||
|
||||
- [ ] 14. **Manual visual verification** — Run `npm run dev`, test homepage hero blur, subpage album cover placeholders, photo grid blur-up, lightbox dominant color background, and about page portrait placeholder. Throttle to Slow 3G in DevTools to validate placeholder visibility.
|
||||
|
||||
## Verification Criteria
|
||||
|
||||
### Automated Tests
|
||||
|
||||
- `npm run build` — verify the project builds successfully with all changes (no TypeScript / ESLint errors)
|
||||
- `npm run lint` — verify ESLint passes
|
||||
|
||||
### Manual Verification
|
||||
|
||||
1. **Run `npm run dev`** and open [http://localhost:3000](http://localhost:3000) in the browser
|
||||
2. **Homepage hero**: Verify the hero image loads with a visible blur placeholder that transitions to the sharp image. Open DevTools Network tab and confirm the image request goes through `/api/image/` and the response Content-Type is `image/webp` or `image/avif`
|
||||
3. **Subpage grid**: Navigate to a subpage, verify album cover cards show a colored/blurred placeholder before the cover image loads
|
||||
4. **Photo grid**: Open an album, verify grid thumbnails show blur-up placeholders. Inspect a thumbnail `<img>` in DevTools and confirm it has `srcset` with multiple widths
|
||||
5. **Lightbox**: Click a photo, verify the lightbox shows a dominant color background before the full preview loads
|
||||
6. **About page**: Navigate to `/about`, verify the portrait image uses blur-up placeholder
|
||||
7. **Slow connection test**: In DevTools, throttle network to "Slow 3G" and reload an album page — confirm placeholder behavior is clearly visible (blur → sharp transition)
|
||||
|
||||
## Potential Risks and Mitigations
|
||||
|
||||
1. **`next/image` with custom loader may not support all optimization features**
|
||||
Mitigation: The custom loader approach is officially documented by Next.js. Worst case, we fall back to `unoptimized` mode and still get blur placeholders.
|
||||
|
||||
2. **ThumbHash decoding adds server-side compute per page render**
|
||||
Mitigation: ThumbHash decoding is extremely fast (microseconds per hash). The data is already cached in the album response. We decode once per render, and albums are cached via the existing TTL cache.
|
||||
|
||||
3. **Missing ThumbHash data for some assets**
|
||||
Mitigation: All helpers return `null` when ThumbHash is unavailable. Components fall back gracefully — no placeholder shown, image loads normally (current behavior).
|
||||
|
||||
4. **Lightbox image loading UX regression**
|
||||
Mitigation: We're adding placeholders to the lightbox (improvement), not changing the image loading mechanism. The raw `<img>` stays, so no layout shift or srcset complexity.
|
||||
|
||||
## Alternative Approaches
|
||||
|
||||
1. **BlurHash instead of ThumbHash**: Immich uses ThumbHash (not BlurHash) internally. ThumbHash is the newer format and encodes both blur shape + color info, making it strictly better for our use case. No reason to use the older BlurHash format.
|
||||
|
||||
2. **Server-side image optimization (Sharp)**: Instead of the custom loader approach, we could add Sharp to the proxy route and actually resize/transcode images server-side. This would give true optimization but adds significant CPU and memory load to the server. Not recommended for a self-hosted gallery.
|
||||
|
||||
3. **`plaiceholder` package**: A popular Next.js package for generating blur placeholders. However, it requires fetching the actual image server-side to compute the placeholder. Since Immich already provides ThumbHash data, using it directly is much more efficient.
|
||||
90
plans/2026-02-14-photography-features-v1.md
Normal file
90
plans/2026-02-14-photography-features-v1.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Photography Features
|
||||
|
||||
## Objective
|
||||
|
||||
Implement four photography-focused enhancements to the gallery: a hero image carousel with crossfade transitions, automatic full-bleed rendering when a subpage contains only one album, EXIF metadata on grid hover with a global toggle, and a configurable grid layout system supporting masonry vs. uniform modes, custom column counts, gaps, and aspect ratios.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
---
|
||||
|
||||
### Feature 1 — Hero Carousel
|
||||
|
||||
- [ ] 1. **Add `hero` list support to `gallery.yaml` and config** — Extend the `GalleryYaml` interface in `lib/config.ts:32` so that `hero` accepts either a single UUID string (backward compatible) or an array of UUID strings. Add a validation step that normalizes the value to an array (`heroImages: string[]`) in `AppConfig` at `lib/config.ts:100`. Update `gallery.yaml.example` with a commented example showing the array syntax. This lets users define 3–5 hero images.
|
||||
|
||||
- [ ] 2. **Create `HeroCarousel` client component** — Create a new `components/HeroCarousel.tsx` client component. It receives a list of `{ src, blurDataURL }` objects. It rotates through images on a timer (e.g. 6 seconds), crossfading by layering two `next/image` elements with CSS opacity transitions. Only two are mounted at any time (current + next). Use `priority` on the first image only. Clear/restart the interval on unmount. If only one image is provided, render a single static image with no timer — this maintains backward compatibility with the current single-hero setup.
|
||||
|
||||
- [ ] 3. **Update homepage to use `HeroCarousel`** — Modify `app/page.tsx:62-79` to replace the single `<Image>` with the `HeroCarousel` component. Fetch `getAssetInfo` for each hero image in `config.heroImages`, generate placeholder data via `assetPlaceholder`, and pass the array to the carousel. If no hero images are configured, fall back to the existing placeholder div.
|
||||
|
||||
- [ ] 4. **Add carousel CSS** — Add CSS rules in `app/globals.css` for the carousel: stack images with `position: absolute`, crossfade using `opacity` transitions with `var(--transition-slow)`, handle `z-index` layering. Ensure the `.hero__right` container already has `position: relative; overflow: hidden` (it does) so images fill correctly.
|
||||
|
||||
---
|
||||
|
||||
### Feature 2 — Full-Bleed Single Albums
|
||||
|
||||
- [ ] 5. **Detect single-album subpages in the catch-all route** — In `app/[...path]/page.tsx:150-202` (the subpage rendering branch), after fetching `getSubpageAlbums`, check if `albums.length === 1`. If so, skip the subpage album grid entirely and instead render the album detail view (album header + `PhotoGrid`) directly, consistent with how a standalone album renders. Include the back-link pointing to `/` since the intermediate subpage grid is being bypassed.
|
||||
|
||||
- [ ] 6. **Preserve metadata for single-album subpages** — In `generateMetadata` at `app/[...path]/page.tsx:46-48`, when the slug is a subpage, also check if only one album exists. If so, generate metadata using the album name and photo count instead of the subpage name, since the user lands directly in the album.
|
||||
|
||||
---
|
||||
|
||||
### Feature 3 — EXIF on Hover
|
||||
|
||||
- [ ] 7. **Extend `PhotoItem` with EXIF summary fields** — Add optional `camera` (string), `lens` (string), and `focalLength` (string) fields to the `PhotoItem` interface in `app/[...path]/PhotoGrid.tsx:12-20`. These are compact summary strings derived server-side from `ImmichExifInfo.model`, `ImmichExifInfo.lensModel`, and `ImmichExifInfo.focalLength` on each asset.
|
||||
|
||||
- [ ] 8. **Pass EXIF summary data from server pages** — In `app/[...path]/page.tsx`, when building the `PhotoItem` array for `PhotoGrid`, extract the EXIF summary from `asset.exifInfo` (already present on `ImmichAsset`). Construct the compact strings (e.g. `"Leica M11"`, `"50mm Summilux"`, `"50mm"`) and include them in each `PhotoItem`. Only include fields that are non-null.
|
||||
|
||||
- [ ] 9. **Add EXIF hover tooltip to `PhotoGrid`** — In `PhotoGrid.tsx`, render a small overlay div inside each `.photo-grid__item` when `camera` or `lens` is present. The overlay shows camera + lens + focal length in a compact one-line format. Use CSS `opacity: 0` by default and `opacity: 1` on `.photo-grid__item:hover`, matching the existing hover transition timing. Position at the bottom of the grid cell.
|
||||
|
||||
- [ ] 10. **Add an `exifOnHover` toggle to `gallery.yaml`** — Add an optional boolean `exifOnHover` field (default `true`) to the `GalleryYaml` interface and `AppConfig` in `lib/config.ts`. When `false`, the server pages skip attaching the EXIF data to `PhotoItem`, and the overlay elements are not rendered. Pass the flag through to `PhotoGrid` as a prop.
|
||||
|
||||
- [ ] 11. **Add CSS for EXIF hover overlay** — Add styles in `app/globals.css` for `.photo-grid__item-exif`: positioned at the bottom of the grid cell using `position: absolute; bottom: 0`, with a dark gradient background, small white text (font-size ~0.7rem), `opacity: 0` transitioning to `1` on parent hover, with `pointer-events: none` so it doesn't interfere with click-to-lightbox.
|
||||
|
||||
---
|
||||
|
||||
### Feature 4 — Customizable Grid Layout
|
||||
|
||||
- [ ] 12. **Add grid configuration to `gallery.yaml`** — Extend `GalleryYaml` and `AppConfig` in `lib/config.ts` with a `grid` object containing: `columns` (number, default 3), `gap` (number in px, default 12), `aspectRatio` (string like `"1"`, `"3/2"`, `"auto"`, default `"1"`), and `layout` (enum `"masonry"` | `"uniform"`, default `"masonry"`). Parse and validate these in `getConfig()`. Update `gallery.yaml.example` with commented examples.
|
||||
|
||||
- [ ] 13. **Pass grid config as CSS custom properties** — In the server page components (`app/[...path]/page.tsx`), wrap `PhotoGrid` in a container div that sets CSS custom properties from the grid config: `--grid-columns`, `--grid-gap`, `--grid-aspect-ratio`. This keeps the client component simple — it just reads props and the CSS handles the rest.
|
||||
|
||||
- [ ] 14. **Update `PhotoGrid` to accept grid layout prop** — Add a `layout` prop (`"masonry" | "uniform"`) to `PhotoGridProps`. When `"masonry"`, use the existing CSS `column-count` approach. When `"uniform"`, switch to CSS `display: grid` with `grid-template-columns: repeat(var(--grid-columns), 1fr)`. Apply different classnames: `.photo-grid--masonry` vs `.photo-grid--uniform`.
|
||||
|
||||
- [ ] 15. **Update CSS grid styles** — Replace the hardcoded `column-count: 3`, `column-gap: 12px`, and `aspect-ratio: 1` values in `app/globals.css:390-414` with CSS custom properties: `column-count: var(--grid-columns)`, `column-gap: var(--grid-gap)`, `aspect-ratio: var(--grid-aspect-ratio)`. Add `.photo-grid--uniform` styles that use CSS Grid instead of columns. Update responsive breakpoints to adjust `--grid-columns` accordingly. When `aspectRatio` is `"auto"`, remove the fixed aspect ratio from masonry items and restore `height: auto`.
|
||||
|
||||
---
|
||||
|
||||
## Verification Criteria
|
||||
|
||||
- `npx tsc --noEmit` passes with zero errors
|
||||
- `npm run lint` passes with zero warnings
|
||||
- Single hero image config still works identically (backward compatible)
|
||||
- Array hero config shows crossfade carousel with smooth transitions
|
||||
- Subpages with 1 album go straight to PhotoGrid (no intermediate album card grid)
|
||||
- Subpages with 2+ albums still show the album card grid
|
||||
- EXIF overlay appears on grid hover when `exifOnHover: true` or when omitted (default)
|
||||
- EXIF overlay does not appear when `exifOnHover: false`
|
||||
- Grid respects `columns`, `gap`, `aspectRatio`, and `layout` from `gallery.yaml`
|
||||
- Responsive breakpoints still reduce columns on small viewports
|
||||
|
||||
## Potential Risks and Mitigations
|
||||
|
||||
1. **Hero carousel timer causes memory leaks on fast navigation**
|
||||
Mitigation: Clear interval on component unmount via `useEffect` cleanup. Only mount the timer when the component is visible.
|
||||
|
||||
2. **EXIF data bloats server-rendered HTML for large albums**
|
||||
Mitigation: Only pass 3 short strings per asset (camera, lens, focal length), not the full EXIF object. This adds ~50-100 bytes per image to the serialized props — negligible even for 200+ photo albums.
|
||||
|
||||
3. **Grid layout changes break existing CSS overrides or custom styles**
|
||||
Mitigation: Use CSS custom properties with sensible defaults so existing deployments work without any `gallery.yaml` changes. The defaults match the current hardcoded values.
|
||||
|
||||
4. **Full-bleed single albums lose the subpage name context**
|
||||
Mitigation: Include the subpage name in the back-link text and update metadata to use the album name directly.
|
||||
|
||||
## Alternative Approaches
|
||||
|
||||
1. **Hero carousel via CSS-only (no client component)**: Could use CSS animations with `animation-delay` on stacked images, avoiding React state. Trade-off: harder to control timing, no pause-on-hover, and can't preload images intelligently. Rejected in favor of the React component approach for better control.
|
||||
|
||||
2. **EXIF on hover via client-side fetch (lazy)**: Instead of passing EXIF data server-side, could fetch on first hover per image. Trade-off: adds latency on hover and requires per-image API calls. Since `exifInfo` is already on `ImmichAsset`, passing it server-side is both simpler and faster.
|
||||
|
||||
3. **Grid config via a separate `lightbox.config.ts` file**: Could create a dedicated TypeScript config file instead of extending `gallery.yaml`. Trade-off: adds another config surface. Since `gallery.yaml` already serves as the central configuration point and the grid settings are gallery-specific, extending it keeps things consolidated.
|
||||
Reference in New Issue
Block a user