feat: portfolio with subpages, hero homepage, and obfuscated asset URLs
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -39,3 +39,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# personal notes
|
||||||
|
IDEAS.md
|
||||||
|
|||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# ── Dependencies ──────────────────────────────────
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# ── Build ─────────────────────────────────────────
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Runtime ───────────────────────────────────────
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
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 ["node", "server.js"]
|
||||||
77
README.md
77
README.md
@@ -1,36 +1,73 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Immich Folio
|
||||||
|
|
||||||
## Getting Started
|
A self-hosted photography portfolio powered by [Immich](https://immich.app). Turns your Immich albums into a beautiful, public-facing gallery — no database required.
|
||||||
|
|
||||||
First, run the development server:
|
## Features
|
||||||
|
|
||||||
|
- **Portfolio-style navigation** — horizontal nav bar with all your collections
|
||||||
|
- **Split hero homepage** — configurable hero image with navigation links
|
||||||
|
- **Subpage grouping** — organize albums under named collections (e.g. `/japan/tokyo-2023`)
|
||||||
|
- **Image proxy** — serves Immich assets without exposing your server or API key
|
||||||
|
- **Obfuscated URLs** — asset IDs are encrypted, never exposed to the public
|
||||||
|
- **Masonry photo grid** — responsive layout with lightbox viewer
|
||||||
|
- **EXIF metadata** — camera, lens, and settings displayed on demand
|
||||||
|
- **Aggressive caching** — configurable TTL for fast repeat loads
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Clone and install
|
||||||
|
git clone https://github.com/YOUR_USER/immich-lightbox.git
|
||||||
|
cd immich-lightbox
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
# Edit .env.local with your Immich server details
|
||||||
|
|
||||||
|
# Run
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
## Configuration
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
All configuration is done via environment variables in `.env.local`:
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
```env
|
||||||
|
# Required
|
||||||
|
IMMICH_API_URL=http://your-immich-server:2283
|
||||||
|
IMMICH_API_KEY=your-api-key
|
||||||
|
|
||||||
## Learn More
|
# Albums to publish (comma-separated UUIDs, optional labels)
|
||||||
|
LIGHTBOX_ALBUMS=Wedding:uuid-1, Vacation:uuid-2
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
# Group albums into subpages (one per line, wrap in quotes)
|
||||||
|
LIGHTBOX_SUBPAGES="
|
||||||
|
Japan > uuid-tokyo, uuid-kyoto
|
||||||
|
Italy > uuid-rome, uuid-florence
|
||||||
|
"
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
# Optional
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
SITE_TITLE=My Portfolio
|
||||||
|
SITE_SUBTITLE=Photography
|
||||||
|
HERO_IMAGE=asset-uuid-for-homepage
|
||||||
|
CACHE_TTL=300
|
||||||
|
```
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
## Tech Stack
|
||||||
|
|
||||||
## Deploy on Vercel
|
- **Next.js 16** (App Router)
|
||||||
|
- **React 19**
|
||||||
|
- **TypeScript**
|
||||||
|
- **Vanilla CSS** (no framework)
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
## Docker
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
```bash
|
||||||
|
docker build -t immich-lightbox .
|
||||||
|
docker run -p 3000:3000 --env-file .env.local immich-lightbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private — all rights reserved.
|
||||||
|
|||||||
104
app/[...path]/PhotoGrid.tsx
Normal file
104
app/[...path]/PhotoGrid.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* PhotoGrid — client component wrapping the masonry grid and lightbox.
|
||||||
|
* Handles image click → lightbox open, keyboard nav, and EXIF fetching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Lightbox } from '@/components/Lightbox';
|
||||||
|
|
||||||
|
export interface PhotoItem {
|
||||||
|
id: string;
|
||||||
|
thumbUrl: string;
|
||||||
|
previewUrl: string;
|
||||||
|
originalUrl: string;
|
||||||
|
exifUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoGridProps {
|
||||||
|
assets: PhotoItem[];
|
||||||
|
albumId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoGrid({ assets }: PhotoGridProps) {
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const openLightbox = useCallback((index: number) => {
|
||||||
|
setLightboxIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeLightbox = useCallback(() => {
|
||||||
|
setLightboxIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
setLightboxIndex((prev) =>
|
||||||
|
prev !== null ? (prev + 1) % assets.length : null,
|
||||||
|
);
|
||||||
|
}, [assets.length]);
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
setLightboxIndex((prev) =>
|
||||||
|
prev !== null ? (prev - 1 + assets.length) % assets.length : null,
|
||||||
|
);
|
||||||
|
}, [assets.length]);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (lightboxIndex === null) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
closeLightbox();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
goNext();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
goPrev();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
// Prevent body scroll when lightbox is open
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [lightboxIndex, closeLightbox, goNext, goPrev]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="photo-grid">
|
||||||
|
{assets.map((asset, index) => (
|
||||||
|
<div
|
||||||
|
key={asset.id}
|
||||||
|
className="photo-grid__item"
|
||||||
|
onClick={() => openLightbox(index)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={asset.thumbUrl}
|
||||||
|
alt=""
|
||||||
|
loading={index < 6 ? 'eager' : 'lazy'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightboxIndex !== null && (
|
||||||
|
<Lightbox
|
||||||
|
assets={assets}
|
||||||
|
currentIndex={lightboxIndex}
|
||||||
|
onClose={closeLightbox}
|
||||||
|
onNext={goNext}
|
||||||
|
onPrev={goPrev}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
app/[...path]/page.tsx
Normal file
156
app/[...path]/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Catch-all route — handles both subpage listings and album detail pages.
|
||||||
|
*
|
||||||
|
* Single segment:
|
||||||
|
* - If slug matches a subpage → render subpage album grid
|
||||||
|
* - If slug matches a standalone album → render album detail
|
||||||
|
*
|
||||||
|
* Two segments:
|
||||||
|
* - Treat as subpage-slug/album-slug → render album detail
|
||||||
|
* with back-link to the subpage
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { immich } from '@/lib/immich';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { PhotoGrid } from './PhotoGrid';
|
||||||
|
import { imageUrl, exifUrl } from '@/lib/urls';
|
||||||
|
|
||||||
|
// Render at request time — requires live Immich connection
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface PathPageProps {
|
||||||
|
params: Promise<{ path: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PathPage({ params }: PathPageProps) {
|
||||||
|
const { path } = await params;
|
||||||
|
|
||||||
|
if (!path || path.length === 0) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Two segments: subpage/album ──────────────────────────────
|
||||||
|
if (path.length === 2) {
|
||||||
|
const [subpageSlug, albumSlug] = path;
|
||||||
|
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug);
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subpage name for the back link
|
||||||
|
const subpageData = await immich.getSubpageAlbums(subpageSlug);
|
||||||
|
const subpageName = subpageData?.subpage.name ?? subpageSlug;
|
||||||
|
|
||||||
|
const images = album.assets.filter((asset) => asset.type === 'IMAGE');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="album-header">
|
||||||
|
<Link href={`/${subpageSlug}`} className="album-header__back">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
Back to {subpageName}
|
||||||
|
</Link>
|
||||||
|
<h1 className="album-header__title">{album.albumName}</h1>
|
||||||
|
<p className="album-header__meta">
|
||||||
|
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||||
|
{album.description && ` · ${album.description}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PhotoGrid
|
||||||
|
assets={images.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
thumbUrl: imageUrl(a.id, 'preview'),
|
||||||
|
previewUrl: imageUrl(a.id, 'preview'),
|
||||||
|
originalUrl: imageUrl(a.id, 'original'),
|
||||||
|
exifUrl: exifUrl(a.id),
|
||||||
|
}))}
|
||||||
|
albumId={album.id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single segment: subpage or standalone album ──────────────
|
||||||
|
const slug = path[0];
|
||||||
|
|
||||||
|
// Check if it's a subpage
|
||||||
|
if (immich.isSubpageSlug(slug)) {
|
||||||
|
const result = await immich.getSubpageAlbums(slug);
|
||||||
|
if (!result || result.albums.length === 0) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { albums } = result;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subpage-grid">
|
||||||
|
{albums.map((album) => (
|
||||||
|
<Link
|
||||||
|
key={album.id}
|
||||||
|
href={`/${slug}/${album.slug}`}
|
||||||
|
className="subpage-grid__item"
|
||||||
|
>
|
||||||
|
{album.albumThumbnailAssetId ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
|
||||||
|
alt={album.albumName}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="subpage-grid__item-overlay">
|
||||||
|
<span className="subpage-grid__item-title">{album.albumName}</span>
|
||||||
|
<span className="subpage-grid__item-count">
|
||||||
|
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise treat as a standalone album slug
|
||||||
|
const album = await immich.getAlbumBySlug(slug);
|
||||||
|
if (!album) {
|
||||||
|
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) => ({
|
||||||
|
id: a.id,
|
||||||
|
thumbUrl: imageUrl(a.id, 'preview'),
|
||||||
|
previewUrl: imageUrl(a.id, 'preview'),
|
||||||
|
originalUrl: imageUrl(a.id, 'original'),
|
||||||
|
exifUrl: exifUrl(a.id),
|
||||||
|
}))}
|
||||||
|
albumId={album.id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
app/api/exif/[id]/route.ts
Normal file
41
app/api/exif/[id]/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* EXIF metadata API route — returns camera/lens/settings info for an asset.
|
||||||
|
* Called on-demand by the lightbox when the user clicks "Info".
|
||||||
|
*
|
||||||
|
* The :token is an encoded asset ID (not a raw UUID).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { immich } from '@/lib/immich';
|
||||||
|
import { decodeAssetId } from '@/lib/tokens';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id: token } = await params;
|
||||||
|
|
||||||
|
// Decode the opaque token back to an Immich asset ID
|
||||||
|
const assetId = decodeAssetId(token);
|
||||||
|
if (!assetId) {
|
||||||
|
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await immich.getAssetInfo(assetId);
|
||||||
|
if (!asset?.exifInfo) {
|
||||||
|
return NextResponse.json({ error: 'No EXIF data' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exif = asset.exifInfo;
|
||||||
|
return NextResponse.json({
|
||||||
|
make: exif.make,
|
||||||
|
model: exif.model,
|
||||||
|
lensModel: exif.lensModel,
|
||||||
|
focalLength: exif.focalLength,
|
||||||
|
fNumber: exif.fNumber,
|
||||||
|
exposureTime: exif.exposureTime,
|
||||||
|
iso: exif.iso,
|
||||||
|
city: exif.city,
|
||||||
|
country: exif.country,
|
||||||
|
});
|
||||||
|
}
|
||||||
49
app/api/image/[id]/route.ts
Normal file
49
app/api/image/[id]/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Image proxy route — serves Immich assets to the browser
|
||||||
|
* without exposing the Immich server or API key.
|
||||||
|
*
|
||||||
|
* Usage: GET /api/image/:token?size=thumbnail|preview|original
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
const VALID_SIZES: ImageSize[] = ['thumbnail', 'preview', 'original'];
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id: token } = await params;
|
||||||
|
|
||||||
|
// Decode the opaque token back to an Immich asset ID
|
||||||
|
const assetId = decodeAssetId(token);
|
||||||
|
if (!assetId) {
|
||||||
|
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
const result = await immich.streamAsset(assetId, size);
|
||||||
|
if (!result) {
|
||||||
|
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': result.contentType,
|
||||||
|
// Images are immutable once uploaded to Immich — cache aggressively
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.contentLength) {
|
||||||
|
headers['Content-Length'] = result.contentLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(result.stream, { headers });
|
||||||
|
}
|
||||||
732
app/globals.css
732
app/globals.css
@@ -1,33 +1,58 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Playfair+Display:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
/* ── Design Tokens ────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--bg-primary: #0a0a0a;
|
||||||
--foreground: #171717;
|
--bg-secondary: #111111;
|
||||||
|
--bg-card: #161616;
|
||||||
|
--bg-card-hover: #1a1a1a;
|
||||||
|
--bg-overlay: rgba(0, 0, 0, 0.92);
|
||||||
|
|
||||||
|
--text-primary: #f0f0f0;
|
||||||
|
--text-secondary: #999999;
|
||||||
|
--text-muted: #666666;
|
||||||
|
|
||||||
|
--border-subtle: #222222;
|
||||||
|
--border-hover: #333333;
|
||||||
|
|
||||||
|
--accent: #d4af37;
|
||||||
|
--accent-dim: rgba(212, 175, 55, 0.15);
|
||||||
|
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif;
|
||||||
|
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-normal: 300ms ease;
|
||||||
|
--transition-slow: 500ms ease;
|
||||||
|
|
||||||
|
--max-width: 1400px;
|
||||||
|
--header-height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* ── Reset ────────────────────────────────────────── */
|
||||||
:root {
|
*,
|
||||||
--background: #0a0a0a;
|
*::before,
|
||||||
--foreground: #ededed;
|
*::after {
|
||||||
}
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html {
|
||||||
body {
|
|
||||||
max-width: 100vw;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
color: var(--foreground);
|
|
||||||
background: var(--background);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
body {
|
||||||
box-sizing: border-box;
|
font-family: var(--font-sans);
|
||||||
padding: 0;
|
background: var(--bg-primary);
|
||||||
margin: 0;
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -35,8 +60,667 @@ a {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
img {
|
||||||
html {
|
display: block;
|
||||||
color-scheme: dark;
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header / Navigation ──────────────────────────── */
|
||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(10, 10, 10, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__nav-link {
|
||||||
|
padding: 18px 16px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__nav-link:hover,
|
||||||
|
.header__nav-link.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main Content ─────────────────────────────────── */
|
||||||
|
.main {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Homepage hero uses full bleed — override main padding */
|
||||||
|
.main:has(.hero) {
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero Split Layout (Homepage) ────────────────── */
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 64px 48px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__subtitle {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__nav-link {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
text-decoration-color: rgba(255, 255, 255, 0.2);
|
||||||
|
transition: color var(--transition-fast), text-decoration-color var(--transition-fast);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__nav-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__right {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section Labels ──────────────────────────────── */
|
||||||
|
.section-label {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 48px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subpage Album Grid (2-column, edge-to-edge) ─── */
|
||||||
|
.subpage-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-grid__item {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-grid__item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform var(--transition-slow), filter var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-grid__item:hover img {
|
||||||
|
transform: scale(1.03);
|
||||||
|
filter: brightness(0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-grid__item-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(to top,
|
||||||
|
rgba(0, 0, 0, 0.6) 0%,
|
||||||
|
transparent 50%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-grid__item:hover .subpage-grid__item-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-grid__item-title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-grid__item-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 300;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Album Grid (Homepage) ────────────────────────── */
|
||||||
|
.album-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
transition: border-color var(--transition-normal), transform var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform var(--transition-slow), filter var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:hover .album-card__image {
|
||||||
|
transform: scale(1.03);
|
||||||
|
filter: brightness(0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card__overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 24px;
|
||||||
|
background: linear-gradient(to top,
|
||||||
|
rgba(0, 0, 0, 0.8) 0%,
|
||||||
|
rgba(0, 0, 0, 0.3) 40%,
|
||||||
|
transparent 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:hover .album-card__overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card__title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card__count {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Photo Grid (Album Detail) ────────────────────── */
|
||||||
|
.album-header {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-header__back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-header__back:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-header__back svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-header__title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-header__meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
column-count: 3;
|
||||||
|
column-gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid__item {
|
||||||
|
break-inside: avoid;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid__item img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
transition: filter var(--transition-normal), transform var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid__item:hover img {
|
||||||
|
filter: brightness(0.85);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lightbox ─────────────────────────────────────── */
|
||||||
|
.lightbox-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: lightbox-fade-in var(--transition-normal) ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lightbox-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lightbox-overlay.closing {
|
||||||
|
animation: lightbox-fade-out var(--transition-fast) ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lightbox-fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__image-container {
|
||||||
|
position: relative;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__image {
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__close {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 1001;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__close svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1001;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__nav:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__nav--prev {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__nav--next {
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__nav svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__counter {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── EXIF Info Panel ──────────────────────────────── */
|
||||||
|
.lightbox__info-toggle {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 1001;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__info-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 60px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 1001;
|
||||||
|
background: rgba(20, 20, 20, 0.92);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 240px;
|
||||||
|
animation: exif-slide-up var(--transition-normal) ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes exif-slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-panel__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-panel__row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-panel__label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-panel__value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading States ───────────────────────────────── */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--bg-card) 0%, var(--bg-card-hover) 50%, var(--bg-card) 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-shimmer 1.5s infinite;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error / Empty States ─────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 96px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__text {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ───────────────────────────────────── */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.photo-grid {
|
||||||
|
column-count: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__right {
|
||||||
|
order: -1;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__left {
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
column-count: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 24px 16px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__nav-link {
|
||||||
|
padding: 14px 10px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-header__title {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card__overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-grid__item-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__nav {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__nav--prev {
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__nav--next {
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,35 @@
|
|||||||
import type { Metadata } from "next";
|
/**
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
* Root layout — site header with portfolio-style navigation.
|
||||||
import "./globals.css";
|
* Shows all subpages + standalone albums as nav links.
|
||||||
|
*/
|
||||||
|
|
||||||
const geistSans = Geist({
|
import type { Metadata } from 'next';
|
||||||
variable: "--font-geist-sans",
|
import Link from 'next/link';
|
||||||
subsets: ["latin"],
|
import './globals.css';
|
||||||
});
|
import { SubpageNav } from '@/components/SubpageNav';
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: process.env.SITE_TITLE || 'Gallery',
|
||||||
description: "Generated by create next app",
|
description: 'A curated photography portfolio',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body>
|
||||||
{children}
|
<header className="header">
|
||||||
|
<nav className="header__nav">
|
||||||
|
<Link href="/" className="header__nav-link">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<SubpageNav />
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main className="main">{children}</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
123
app/page.tsx
123
app/page.tsx
@@ -1,66 +1,69 @@
|
|||||||
import Image from "next/image";
|
/**
|
||||||
import styles from "./page.module.css";
|
* Homepage — Split hero layout.
|
||||||
|
* Left panel: site title, subtitle, navigation links.
|
||||||
|
* Right panel: hero image from Immich.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { immich } from '@/lib/immich';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { imageUrl } from '@/lib/urls';
|
||||||
|
|
||||||
|
// Render at request time — requires live Immich connection
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const config = getConfig();
|
||||||
|
const [subpages, albums] = await Promise.all([
|
||||||
|
immich.getSubpages(),
|
||||||
|
immich.getStandaloneAlbums(),
|
||||||
|
]);
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className="hero">
|
||||||
<main className={styles.main}>
|
{/* ── Left Panel ──────────────────────────────── */}
|
||||||
<Image
|
<div className="hero__left">
|
||||||
className={styles.logo}
|
<div className="hero__content">
|
||||||
src="/next.svg"
|
<h1 className="hero__title">{config.siteTitle}</h1>
|
||||||
alt="Next.js logo"
|
{config.siteSubtitle && (
|
||||||
width={100}
|
<p className="hero__subtitle">{config.siteSubtitle}</p>
|
||||||
height={20}
|
)}
|
||||||
priority
|
|
||||||
/>
|
<nav className="hero__nav">
|
||||||
<div className={styles.intro}>
|
{subpages.map((sp) => (
|
||||||
<h1>To get started, edit the page.tsx file.</h1>
|
<Link
|
||||||
<p>
|
key={sp.slug}
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
href={`/${sp.slug}`}
|
||||||
<a
|
className="hero__nav-link"
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
>
|
||||||
target="_blank"
|
{sp.name}
|
||||||
rel="noopener noreferrer"
|
</Link>
|
||||||
>
|
))}
|
||||||
Templates
|
{albums.map((album) => (
|
||||||
</a>{" "}
|
<Link
|
||||||
or the{" "}
|
key={album.id}
|
||||||
<a
|
href={`/${album.slug}`}
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
className="hero__nav-link"
|
||||||
target="_blank"
|
>
|
||||||
rel="noopener noreferrer"
|
{album.albumName}
|
||||||
>
|
</Link>
|
||||||
Learning
|
))}
|
||||||
</a>{" "}
|
</nav>
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.ctas}>
|
</div>
|
||||||
<a
|
|
||||||
className={styles.primary}
|
{/* ── Right Panel (Hero Image) ────────────────── */}
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
<div className="hero__right">
|
||||||
target="_blank"
|
{config.heroImage ? (
|
||||||
rel="noopener noreferrer"
|
<img
|
||||||
>
|
src={imageUrl(config.heroImage, 'preview')}
|
||||||
<Image
|
alt=""
|
||||||
className={styles.logo}
|
className="hero__image"
|
||||||
src="/vercel.svg"
|
/>
|
||||||
alt="Vercel logomark"
|
) : (
|
||||||
width={16}
|
<div className="hero__image-placeholder" />
|
||||||
height={16}
|
)}
|
||||||
/>
|
</div>
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className={styles.secondary}
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
250
components/Lightbox.tsx
Normal file
250
components/Lightbox.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* Lightbox — fullscreen image viewer with navigation and EXIF info.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Full-resolution image display
|
||||||
|
* - Previous/Next navigation (arrows + swipe)
|
||||||
|
* - Close (Esc, click outside, X button)
|
||||||
|
* - EXIF metadata panel (fetched on demand)
|
||||||
|
* - Preloads adjacent images
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||||
|
|
||||||
|
interface ExifData {
|
||||||
|
make?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
lensModel?: string | null;
|
||||||
|
focalLength?: number | null;
|
||||||
|
fNumber?: number | null;
|
||||||
|
exposureTime?: string | null;
|
||||||
|
iso?: number | null;
|
||||||
|
city?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LightboxProps {
|
||||||
|
assets: PhotoItem[];
|
||||||
|
currentIndex: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Lightbox({
|
||||||
|
assets,
|
||||||
|
currentIndex,
|
||||||
|
onClose,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
}: LightboxProps) {
|
||||||
|
const [showExif, setShowExif] = useState(false);
|
||||||
|
const [exifData, setExifData] = useState<ExifData | null>(null);
|
||||||
|
const [exifLoading, setExifLoading] = useState(false);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const touchStartX = useRef<number>(0);
|
||||||
|
const touchStartY = useRef<number>(0);
|
||||||
|
|
||||||
|
const current = assets[currentIndex];
|
||||||
|
|
||||||
|
// Fetch EXIF data on demand
|
||||||
|
const fetchExif = useCallback((url: string) => {
|
||||||
|
setExifLoading(true);
|
||||||
|
setExifData(null);
|
||||||
|
fetch(url)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => setExifData(data))
|
||||||
|
.catch(() => setExifData(null))
|
||||||
|
.finally(() => setExifLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset EXIF data when switching images; refetch if panel is open
|
||||||
|
useEffect(() => {
|
||||||
|
setExifData(null);
|
||||||
|
if (showExif && current) {
|
||||||
|
fetchExif(current.exifUrl);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentIndex]);
|
||||||
|
|
||||||
|
// When the toggle is turned on or the image changes while open, fetch
|
||||||
|
const handleExifToggle = useCallback(() => {
|
||||||
|
const next = !showExif;
|
||||||
|
setShowExif(next);
|
||||||
|
if (next && current) {
|
||||||
|
fetchExif(current.exifUrl);
|
||||||
|
}
|
||||||
|
}, [showExif, current, fetchExif]);
|
||||||
|
|
||||||
|
// Preload adjacent images
|
||||||
|
useEffect(() => {
|
||||||
|
const preload = (index: number) => {
|
||||||
|
if (index >= 0 && index < assets.length) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = assets[index].previewUrl;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
preload(currentIndex + 1);
|
||||||
|
preload(currentIndex - 1);
|
||||||
|
}, [currentIndex, assets]);
|
||||||
|
|
||||||
|
// Touch/swipe support
|
||||||
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
touchStartX.current = e.touches[0].clientX;
|
||||||
|
touchStartY.current = e.touches[0].clientY;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||||
|
const dy = e.changedTouches[0].clientY - touchStartY.current;
|
||||||
|
|
||||||
|
// Only register horizontal swipes (not vertical scrolling)
|
||||||
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
if (dx > 0) onPrev();
|
||||||
|
else onNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onNext, onPrev],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click on overlay background → close
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (e.target === overlayRef.current) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="lightbox-overlay"
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button className="lightbox__close" onClick={onClose} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Previous button */}
|
||||||
|
<button
|
||||||
|
className="lightbox__nav lightbox__nav--prev"
|
||||||
|
onClick={onPrev}
|
||||||
|
aria-label="Previous photo"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<div className="lightbox__image-container">
|
||||||
|
<img
|
||||||
|
className="lightbox__image"
|
||||||
|
src={current.previewUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next button */}
|
||||||
|
<button
|
||||||
|
className="lightbox__nav lightbox__nav--next"
|
||||||
|
onClick={onNext}
|
||||||
|
aria-label="Next photo"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="9 6 15 12 9 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Counter */}
|
||||||
|
<div className="lightbox__counter">
|
||||||
|
{currentIndex + 1} / {assets.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* EXIF toggle */}
|
||||||
|
<button
|
||||||
|
className="lightbox__info-toggle"
|
||||||
|
onClick={handleExifToggle}
|
||||||
|
>
|
||||||
|
{showExif ? 'Hide Info' : 'Info'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* EXIF panel */}
|
||||||
|
{showExif && (
|
||||||
|
<div className="exif-panel">
|
||||||
|
{exifLoading ? (
|
||||||
|
<div className="exif-panel__row">
|
||||||
|
<span className="exif-panel__label">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : exifData ? (
|
||||||
|
<>
|
||||||
|
{exifData.model && (
|
||||||
|
<div className="exif-panel__row">
|
||||||
|
<span className="exif-panel__label">Camera</span>
|
||||||
|
<span className="exif-panel__value">
|
||||||
|
{[exifData.make, exifData.model].filter(Boolean).join(' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exifData.lensModel && (
|
||||||
|
<div className="exif-panel__row">
|
||||||
|
<span className="exif-panel__label">Lens</span>
|
||||||
|
<span className="exif-panel__value">{exifData.lensModel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exifData.focalLength && (
|
||||||
|
<div className="exif-panel__row">
|
||||||
|
<span className="exif-panel__label">Focal Length</span>
|
||||||
|
<span className="exif-panel__value">{exifData.focalLength}mm</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exifData.fNumber && (
|
||||||
|
<div className="exif-panel__row">
|
||||||
|
<span className="exif-panel__label">Aperture</span>
|
||||||
|
<span className="exif-panel__value">ƒ/{exifData.fNumber}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exifData.exposureTime && (
|
||||||
|
<div className="exif-panel__row">
|
||||||
|
<span className="exif-panel__label">Shutter</span>
|
||||||
|
<span className="exif-panel__value">{exifData.exposureTime}s</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exifData.iso && (
|
||||||
|
<div className="exif-panel__row">
|
||||||
|
<span className="exif-panel__label">ISO</span>
|
||||||
|
<span className="exif-panel__value">{exifData.iso}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(exifData.city || exifData.country) && (
|
||||||
|
<div className="exif-panel__row">
|
||||||
|
<span className="exif-panel__label">Location</span>
|
||||||
|
<span className="exif-panel__value">
|
||||||
|
{[exifData.city, exifData.country].filter(Boolean).join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="exif-panel__row">
|
||||||
|
<span className="exif-panel__label">No EXIF data available</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/SubpageNav.tsx
Normal file
37
components/SubpageNav.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* SubpageNav — server component that renders navigation links
|
||||||
|
* for all subpages and standalone albums in the header.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { immich } from '@/lib/immich';
|
||||||
|
|
||||||
|
export async function SubpageNav() {
|
||||||
|
const [subpages, standaloneAlbums] = await Promise.all([
|
||||||
|
immich.getSubpages(),
|
||||||
|
immich.getStandaloneAlbums(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{subpages.map((sp) => (
|
||||||
|
<Link
|
||||||
|
key={sp.slug}
|
||||||
|
href={`/${sp.slug}`}
|
||||||
|
className="header__nav-link"
|
||||||
|
>
|
||||||
|
{sp.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{standaloneAlbums.map((album) => (
|
||||||
|
<Link
|
||||||
|
key={album.id}
|
||||||
|
href={`/${album.slug}`}
|
||||||
|
className="header__nav-link"
|
||||||
|
>
|
||||||
|
{album.albumName}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
lightbox:
|
||||||
|
build: .
|
||||||
|
container_name: immich-lightbox
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- IMMICH_API_URL=${IMMICH_API_URL}
|
||||||
|
- IMMICH_API_KEY=${IMMICH_API_KEY}
|
||||||
|
- LIGHTBOX_ALBUMS=${LIGHTBOX_ALBUMS}
|
||||||
|
- SITE_TITLE=${SITE_TITLE:-Gallery}
|
||||||
|
- CACHE_TTL=${CACHE_TTL:-300}
|
||||||
|
env_file:
|
||||||
|
- .env.local
|
||||||
45
lib/cache.ts
Normal file
45
lib/cache.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Simple in-memory LRU cache for Immich API responses.
|
||||||
|
* Avoids hammering the Immich server with identical requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryCache {
|
||||||
|
private store = new Map<string, CacheEntry<unknown>>();
|
||||||
|
private maxEntries = 200;
|
||||||
|
|
||||||
|
get<T>(key: string): T | null {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.store.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
set<T>(key: string, data: T, ttlMs: number): void {
|
||||||
|
// Evict oldest entries if at capacity
|
||||||
|
if (this.store.size >= this.maxEntries) {
|
||||||
|
const firstKey = this.store.keys().next().value;
|
||||||
|
if (firstKey) this.store.delete(firstKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.set(key, {
|
||||||
|
data,
|
||||||
|
expiresAt: Date.now() + ttlMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.store.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cache = new MemoryCache();
|
||||||
123
lib/config.ts
Normal file
123
lib/config.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Typed environment configuration with validation.
|
||||||
|
* Fails fast at startup if required variables are missing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A subpage groups multiple Immich albums under one URL path. */
|
||||||
|
export interface SubpageConfig {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
albumIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an album name into a URL-friendly slug.
|
||||||
|
* "Kloster Chorin" → "kloster-chorin"
|
||||||
|
*/
|
||||||
|
export function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // strip diacritics (ü→u, é→e)
|
||||||
|
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens
|
||||||
|
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppConfig = ReturnType<typeof getConfig>;
|
||||||
320
lib/immich.ts
Normal file
320
lib/immich.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* Server-only Immich API client.
|
||||||
|
* Inspired by immich-public-proxy's approach but uses API key auth
|
||||||
|
* to access specific albums rather than shared link keys.
|
||||||
|
*
|
||||||
|
* API key never leaves the server — all client-facing image
|
||||||
|
* requests go through our proxy route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConfig, slugify, type SubpageConfig } from './config';
|
||||||
|
import { cache } from './cache';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ImmichAlbum {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
albumName: string;
|
||||||
|
description: string;
|
||||||
|
albumThumbnailAssetId: string | null;
|
||||||
|
assetCount: number;
|
||||||
|
assets: ImmichAsset[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImmichAsset {
|
||||||
|
id: string;
|
||||||
|
type: 'IMAGE' | 'VIDEO';
|
||||||
|
originalFileName: string;
|
||||||
|
originalMimeType: string;
|
||||||
|
thumbhash: string | null;
|
||||||
|
fileCreatedAt: string;
|
||||||
|
exifInfo?: ImmichExifInfo;
|
||||||
|
isTrashed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImmichExifInfo {
|
||||||
|
make: string | null;
|
||||||
|
model: string | null;
|
||||||
|
lensModel: string | null;
|
||||||
|
focalLength: number | null;
|
||||||
|
fNumber: number | null;
|
||||||
|
exposureTime: string | null;
|
||||||
|
iso: number | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
country: string | null;
|
||||||
|
dateTimeOriginal: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageSize = 'thumbnail' | 'preview' | 'original';
|
||||||
|
|
||||||
|
/** Enriched subpage with album metadata (for rendering cards). */
|
||||||
|
export interface SubpageSummary {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
albumCount: number;
|
||||||
|
totalAssetCount: number;
|
||||||
|
coverAssetId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Client ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ImmichClient {
|
||||||
|
private hasLoggedAlbums = false;
|
||||||
|
|
||||||
|
private get config() {
|
||||||
|
return getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated request to the Immich API.
|
||||||
|
*/
|
||||||
|
private async request<T>(endpoint: string): Promise<T | null> {
|
||||||
|
const url = `${this.config.immich.apiUrl}${endpoint}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'x-api-key': this.config.immich.apiKey,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('Content-Type') || '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Immich] Failed to reach ${url}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a binary response from Immich (for image proxying).
|
||||||
|
*/
|
||||||
|
async streamAsset(
|
||||||
|
assetId: string,
|
||||||
|
size: ImageSize = 'preview',
|
||||||
|
): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> {
|
||||||
|
const endpoint =
|
||||||
|
size === 'original'
|
||||||
|
? `/assets/${encodeURIComponent(assetId)}/original`
|
||||||
|
: `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`;
|
||||||
|
|
||||||
|
const url = `${this.config.immich.apiUrl}${endpoint}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'x-api-key': this.config.immich.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream: res.body,
|
||||||
|
contentType: res.headers.get('Content-Type') || 'application/octet-stream',
|
||||||
|
contentLength: res.headers.get('Content-Length'),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Immich] Stream error for ${assetId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ALL configured albums (filtered by the full allowlist).
|
||||||
|
* Uses ?shared=true to only fetch albums that have been shared in Immich.
|
||||||
|
*/
|
||||||
|
async getAlbums(): Promise<ImmichAlbum[]> {
|
||||||
|
const cacheKey = 'albums-list';
|
||||||
|
const cached = cache.get<ImmichAlbum[]>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const all = await this.request<ImmichAlbum[]>('/albums?shared=true');
|
||||||
|
if (!all) return [];
|
||||||
|
|
||||||
|
const allowedIds = new Set(this.config.albums);
|
||||||
|
const filtered = all
|
||||||
|
.filter((album) => allowedIds.has(album.id))
|
||||||
|
.map((album) => ({
|
||||||
|
...album,
|
||||||
|
slug: slugify(album.albumName),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Log album summary on first load so admins can see what's published
|
||||||
|
if (!this.hasLoggedAlbums) {
|
||||||
|
this.hasLoggedAlbums = true;
|
||||||
|
console.log('\n[Lightbox] Published albums:');
|
||||||
|
console.log('─'.repeat(80));
|
||||||
|
|
||||||
|
// Log standalone albums
|
||||||
|
const standaloneIds = new Set(this.config.standaloneAlbums);
|
||||||
|
const standalone = filtered.filter((a) => standaloneIds.has(a.id));
|
||||||
|
if (standalone.length > 0) {
|
||||||
|
console.log(' Standalone:');
|
||||||
|
for (const a of standalone) {
|
||||||
|
console.log(` 📷 ${a.albumName}`);
|
||||||
|
console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log subpage groupings
|
||||||
|
for (const sp of this.config.subpages) {
|
||||||
|
const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id));
|
||||||
|
console.log(` 📁 ${sp.name} (/${sp.slug}):`);
|
||||||
|
for (const a of spAlbums) {
|
||||||
|
console.log(` 📷 ${a.albumName}`);
|
||||||
|
console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = this.config.albums.filter((id) => !all.some((a) => a.id === id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
console.log('─'.repeat(80) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(cacheKey, filtered, this.config.cacheTtl);
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only standalone albums (not in any subpage) — for the homepage.
|
||||||
|
*/
|
||||||
|
async getStandaloneAlbums(): Promise<ImmichAlbum[]> {
|
||||||
|
const albums = await this.getAlbums();
|
||||||
|
const standaloneIds = new Set(this.config.standaloneAlbums);
|
||||||
|
return albums.filter((a) => standaloneIds.has(a.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enriched subpage summaries for the homepage.
|
||||||
|
*/
|
||||||
|
async getSubpages(): Promise<SubpageSummary[]> {
|
||||||
|
if (this.config.subpages.length === 0) return [];
|
||||||
|
|
||||||
|
const albums = await this.getAlbums();
|
||||||
|
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||||
|
|
||||||
|
return this.config.subpages.map((sp) => {
|
||||||
|
const spAlbums = sp.albumIds
|
||||||
|
.map((id) => albumMap.get(id))
|
||||||
|
.filter((a): a is ImmichAlbum => a !== undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: sp.name,
|
||||||
|
slug: sp.slug,
|
||||||
|
albumCount: spAlbums.length,
|
||||||
|
totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0),
|
||||||
|
coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get albums belonging to a specific subpage.
|
||||||
|
*/
|
||||||
|
async getSubpageAlbums(subpageSlug: string): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
|
||||||
|
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
|
||||||
|
if (!subpage) return null;
|
||||||
|
|
||||||
|
const allAlbums = await this.getAlbums();
|
||||||
|
const subpageAlbumIds = new Set(subpage.albumIds);
|
||||||
|
const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id));
|
||||||
|
|
||||||
|
return { subpage, albums };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single album with its assets.
|
||||||
|
*/
|
||||||
|
async getAlbum(albumId: string): Promise<ImmichAlbum | null> {
|
||||||
|
// Security: only serve configured albums
|
||||||
|
if (!this.config.albums.includes(albumId)) {
|
||||||
|
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `album-${albumId}`;
|
||||||
|
const cached = cache.get<ImmichAlbum>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const album = await this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`);
|
||||||
|
if (!album) return null;
|
||||||
|
|
||||||
|
// Filter out trashed assets
|
||||||
|
album.assets = (album.assets || []).filter((a) => !a.isTrashed);
|
||||||
|
album.slug = slugify(album.albumName);
|
||||||
|
|
||||||
|
cache.set(cacheKey, album, this.config.cacheTtl);
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an album by its URL slug.
|
||||||
|
* If subpageSlug is provided, only search within that subpage's albums.
|
||||||
|
*/
|
||||||
|
async getAlbumBySlug(slug: string, subpageSlug?: string): Promise<ImmichAlbum | null> {
|
||||||
|
const albums = await this.getAlbums();
|
||||||
|
|
||||||
|
let searchSet = albums;
|
||||||
|
if (subpageSlug) {
|
||||||
|
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
|
||||||
|
if (!subpage) return null;
|
||||||
|
const subpageIds = new Set(subpage.albumIds);
|
||||||
|
searchSet = albums.filter((a) => subpageIds.has(a.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = searchSet.find((a) => a.slug === slug);
|
||||||
|
if (!match) return null;
|
||||||
|
return this.getAlbum(match.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a slug corresponds to a subpage.
|
||||||
|
*/
|
||||||
|
isSubpageSlug(slug: string): boolean {
|
||||||
|
return this.config.subpages.some((sp) => sp.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get asset info including EXIF data.
|
||||||
|
*/
|
||||||
|
async getAssetInfo(assetId: string): Promise<ImmichAsset | null> {
|
||||||
|
const cacheKey = `asset-${assetId}`;
|
||||||
|
const cached = cache.get<ImmichAsset>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const asset = await this.request<ImmichAsset>(`/assets/${encodeURIComponent(assetId)}`);
|
||||||
|
if (!asset) return null;
|
||||||
|
|
||||||
|
cache.set(cacheKey, asset, this.config.cacheTtl);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Immich server is reachable.
|
||||||
|
*/
|
||||||
|
async ping(): Promise<boolean> {
|
||||||
|
const res = await this.request<{ res: string }>('/server/ping');
|
||||||
|
return !!res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const immich = new ImmichClient();
|
||||||
62
lib/tokens.ts
Normal file
62
lib/tokens.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Token encoding/decoding for asset IDs.
|
||||||
|
* Obfuscates Immich UUIDs in public-facing URLs using AES encryption.
|
||||||
|
*
|
||||||
|
* Uses deterministic encryption (IV derived from asset ID) so the same
|
||||||
|
* asset always produces the same token — essential for browser caching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { getConfig } from './config';
|
||||||
|
|
||||||
|
let _key: Buffer | null = null;
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
if (!_key) {
|
||||||
|
const apiKey = getConfig().immich.apiKey;
|
||||||
|
_key = crypto.createHash('sha256').update(apiKey).digest();
|
||||||
|
}
|
||||||
|
return _key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode an asset ID into an opaque URL-safe token.
|
||||||
|
*/
|
||||||
|
export function encodeAssetId(assetId: string): string {
|
||||||
|
const key = getKey();
|
||||||
|
// Deterministic IV from asset ID (same input → same token)
|
||||||
|
const iv = crypto.createHash('md5').update(assetId).digest();
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(assetId, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
// Compact: base64url(iv + ciphertext)
|
||||||
|
return Buffer.concat([iv, encrypted]).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a token back into an asset ID.
|
||||||
|
* Returns null if the token is invalid.
|
||||||
|
*/
|
||||||
|
export function decodeAssetId(token: string): string | null {
|
||||||
|
try {
|
||||||
|
const key = getKey();
|
||||||
|
const data = Buffer.from(token, 'base64url');
|
||||||
|
if (data.length < 17) return null; // iv(16) + at least 1 byte
|
||||||
|
|
||||||
|
const iv = data.subarray(0, 16);
|
||||||
|
const encrypted = data.subarray(16);
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(encrypted),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
|
|
||||||
|
// Validate it looks like a UUID
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidRegex.test(decrypted) ? decrypted : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
lib/urls.ts
Normal file
20
lib/urls.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Server-side URL helpers for generating public-facing asset URLs.
|
||||||
|
* These use encoded tokens instead of raw Immich UUIDs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { encodeAssetId } from './tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a public image proxy URL for an asset.
|
||||||
|
*/
|
||||||
|
export function imageUrl(assetId: string, size: 'thumbnail' | 'preview' | 'original' = 'preview'): string {
|
||||||
|
return `/api/image/${encodeAssetId(assetId)}?size=${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a public EXIF API URL for an asset.
|
||||||
|
*/
|
||||||
|
export function exifUrl(assetId: string): string {
|
||||||
|
return `/api/exif/${encodeAssetId(assetId)}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: 'standalone',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
114
plans/2026-02-13-immich-lightbox-v1.md
Normal file
114
plans/2026-02-13-immich-lightbox-v1.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Immich Lightbox — Public Photo Gallery
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Build a public-facing web gallery that connects to a private Immich server via its API, selects specific albums, and presents them as a beautiful, Lightroom-style portfolio website — similar in spirit to [ralfo.myportfolio.com](http://ralfo.myportfolio.com/). The Immich server stays behind the firewall; only curated album images flow outward through this app.
|
||||||
|
|
||||||
|
### Key Goals
|
||||||
|
|
||||||
|
- **Album curation** — Admin picks which Immich albums to publish; only those appear on the public site
|
||||||
|
- **Proxy architecture** — The gallery server talks to Immich; visitors never touch Immich directly. API key stays server-side
|
||||||
|
- **Portfolio-grade design** — Clean, dark, image-first aesthetic with masonry/grid layout, smooth lightbox, and responsive behavior
|
||||||
|
- **Low operational overhead** — Single Docker container, minimal config, no database needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
**Visitor (Browser)** → HTTPS → **Lightbox Server (Next.js)** → HTTP → **Immich Server (Private API)**
|
||||||
|
|
||||||
|
The Lightbox Server sits between visitors and Immich. It proxies images, caches API responses, and holds the album selection config. The Immich server stays private and serves album data and original images only to the Lightbox Server.
|
||||||
|
|
||||||
|
**Stack:**
|
||||||
|
- **Next.js 15** (App Router) — SSR for fast first paint, API routes for proxying
|
||||||
|
- **TypeScript** — End-to-end type safety
|
||||||
|
- **CSS Modules + CSS custom properties** — Clean, maintainable dark-theme styling
|
||||||
|
- **Docker** — Single-container deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
- [ ] 1. **Scaffold the Next.js project** — Initialize a Next.js 15 app with TypeScript in the current workspace directory using `npx -y create-next-app@latest ./`. Configure project settings (app router, TypeScript, ESLint). This is the foundation everything else builds on.
|
||||||
|
|
||||||
|
- [ ] 2. **Create the Immich API client module** — Build a server-only module (`lib/immich.ts`) that wraps the Immich REST API. This module handles authentication (API key from env vars), album listing (`GET /api/albums`), album detail fetching (`GET /api/albums/{id}`), and asset thumbnail/original URL construction. All API calls happen server-side only — the API key never reaches the browser.
|
||||||
|
|
||||||
|
- [ ] 3. **Create environment configuration** — Set up `.env.local.example` documenting required variables: `IMMICH_API_URL` (the Immich server base URL), `IMMICH_API_KEY` (service account key), and `LIGHTBOX_ALBUMS` (comma-separated album IDs to publish). Add a `lib/config.ts` module that validates and exports typed config at startup.
|
||||||
|
|
||||||
|
- [ ] 4. **Build the image proxy API route** — Create `app/api/image/[id]/route.ts` that proxies image requests through to Immich. Accepts an asset ID and optional `size` query param (thumbnail vs. original). Streams the response from Immich to the client, setting appropriate cache headers (long `Cache-Control` since images are immutable). This keeps the Immich server unexposed while serving images to visitors.
|
||||||
|
|
||||||
|
- [ ] 5. **Design and implement the global layout and theme** — Create the root layout (`app/layout.tsx`) and global styles (`app/globals.css`). Establish a dark, minimal design system: near-black backgrounds, subtle borders, elegant typography (Google Fonts — Inter or similar). Include a minimal header/nav with the site title and responsive breakpoints. The aesthetic should feel premium and image-focused, not app-like.
|
||||||
|
|
||||||
|
- [ ] 6. **Build the homepage — album grid** — Create `app/page.tsx` as a server component that fetches the configured albums from Immich, displays them in a responsive grid layout. Each album card shows its cover image (fetched via the proxy), album title, and asset count. Clicking an album navigates to its detail page. Add hover effects (subtle scale, overlay with title) for an engaging, interactive feel.
|
||||||
|
|
||||||
|
- [ ] 7. **Build the album detail page — photo grid** — Create `app/album/[id]/page.tsx` as a server component. Fetches all assets in the selected album from Immich, displays them in a masonry or justified grid using CSS Grid. Shows thumbnails initially for fast loading. A back-to-home navigation link or breadcrumb provides context. Each image is clickable to open a fullscreen lightbox.
|
||||||
|
|
||||||
|
- [ ] 8. **Build the client-side lightbox component** — Create a `components/Lightbox.tsx` client component. When a user clicks a photo in the album grid, an overlay opens showing the full-resolution image with smooth fade/slide transitions. Include previous/next navigation (arrow keys + swipe on mobile), close button (Esc key support), and an optional EXIF info overlay (camera, lens, ISO, aperture, shutter speed — data from Immich). Preload adjacent images for snappy navigation.
|
||||||
|
|
||||||
|
- [ ] 9. **Add EXIF metadata display** — Enhance the Immich API client to fetch asset EXIF data (`GET /api/assets/{id}`). Display this in the lightbox as an optional info panel: camera model, lens, focal length, aperture, shutter speed, ISO. This adds the "photographer's portfolio" feel that distinguishes this from a basic gallery.
|
||||||
|
|
||||||
|
- [ ] 10. **Implement image loading optimizations** — Use Next.js `<Image>` component with blur placeholder data-URLs (generated server-side from Immich thumbnails). Implement progressive loading: show tiny blurred thumbnails instantly, then swap to full thumbnails on load. Apply `loading="lazy"` on below-the-fold images. Set proper `sizes` attributes for responsive image delivery.
|
||||||
|
|
||||||
|
- [ ] 11. **Add server-side caching layer** — Implement an in-memory LRU cache (or file-based cache in production) for Immich API responses. Album lists and metadata rarely change, so cache with a configurable TTL (default 5 minutes). Image proxy responses rely on HTTP cache headers; no server-side image caching needed. This dramatically reduces Immich API load.
|
||||||
|
|
||||||
|
- [ ] 12. **Create the Docker deployment setup** — Write a `Dockerfile` (multi-stage: build + runtime) and `docker-compose.yml`. The compose file references env vars for Immich connection details. Include health check and proper signal handling. Document deployment in `README.md`.
|
||||||
|
|
||||||
|
- [ ] 13. **Write the README and configuration guide** — Create a comprehensive `README.md` covering: project overview, screenshots, setup instructions (how to get an Immich API key, how to find album IDs, env var reference), Docker deployment, and development workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Criteria
|
||||||
|
|
||||||
|
- **Album selection**: Only albums specified in `LIGHTBOX_ALBUMS` appear on the homepage; other Immich albums are hidden
|
||||||
|
- **Image proxying**: Browser Network tab shows all image requests going to the Lightbox server, never directly to Immich. The `x-api-key` header never appears in browser-visible requests
|
||||||
|
- **Responsive layout**: Gallery looks correct on mobile (375px), tablet (768px), and desktop (1440px)
|
||||||
|
- **Lightbox navigation**: Arrow keys, swipe gestures, and on-screen buttons all work for prev/next. Esc closes the lightbox
|
||||||
|
- **Performance**: Homepage loads in under 2 seconds with cached data. Lighthouse performance score ≥ 85
|
||||||
|
- **Docker deployment**: `docker compose up` brings the app up and serves the gallery
|
||||||
|
|
||||||
|
### Automated Tests
|
||||||
|
|
||||||
|
- Run `npm run build` to validate TypeScript compilation and Next.js build succeeds
|
||||||
|
- Run `npm run lint` to check for code quality issues
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> You will need a running Immich instance with at least 2 albums to test against. Please confirm your Immich server URL and provide an API key before we begin implementation.
|
||||||
|
|
||||||
|
1. **Homepage check** — Open `http://localhost:3000` in a browser, confirm only the configured albums appear as cards with cover images
|
||||||
|
2. **Album detail check** — Click an album card, confirm all photos from that album load in a grid
|
||||||
|
3. **Lightbox check** — Click a photo, confirm the lightbox opens with full-res image, arrow navigation works, Esc closes
|
||||||
|
4. **Security check** — Open browser DevTools → Network tab, confirm no requests go directly to the Immich server URL, and the API key does not appear in any request headers or URLs
|
||||||
|
5. **Mobile check** — Use browser responsive mode at 375px width, confirm the layout adapts cleanly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Potential Risks and Mitigations
|
||||||
|
|
||||||
|
1. **Immich API changes between versions**
|
||||||
|
Mitigation: Pin to a specific Immich API version in documentation. Use defensive coding with optional chaining and fallbacks for missing fields. The OpenAPI spec is stable for core album/asset endpoints.
|
||||||
|
|
||||||
|
2. **Large albums (1000+ images) causing slow page loads**
|
||||||
|
Mitigation: Implement pagination or virtual scrolling in the album detail view. Initially load 50 thumbnails and lazy-load more on scroll.
|
||||||
|
|
||||||
|
3. **Immich server unreachable causes gallery downtime**
|
||||||
|
Mitigation: The server-side cache ensures the gallery continues serving recently-fetched data even if Immich is temporarily down. Add graceful error states showing "Gallery temporarily unavailable."
|
||||||
|
|
||||||
|
4. **Image proxy becomes a bandwidth bottleneck**
|
||||||
|
Mitigation: Set aggressive `Cache-Control` headers (1 year for immutable images) so browsers and CDNs cache aggressively. Consider optional CDN (Cloudflare) in front.
|
||||||
|
|
||||||
|
5. **API key exposure**
|
||||||
|
Mitigation: API key is only used in server-side code (API routes, server components). It never appears in client bundles or browser requests. Environment variables are validated at startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative Approaches
|
||||||
|
|
||||||
|
1. **Static Site Generation (SSG) instead of SSR** — Pre-render all pages at build time, deploy to a static host (Netlify, Vercel). Eliminates the need for a running server. Trade-off: requires a rebuild whenever album contents change; no real-time sync with Immich.
|
||||||
|
|
||||||
|
2. **Use Immich Shared Links instead of API** — Create shared links for each album in Immich and embed/proxy those. Trade-off: less control over presentation, relies on Immich's built-in sharing UI, and shared link URLs may expire.
|
||||||
|
|
||||||
|
3. **Client-side only (SPA)** — Fetch everything from the browser via CORS. Trade-off: exposes the Immich API key to the browser, which is a security risk. Not recommended.
|
||||||
|
|
||||||
|
4. **Sync + Static Files** — A cron job syncs selected album images to local disk, then a static site serves them. Trade-off: duplicates storage, adds operational complexity, but provides complete decoupling from Immich availability.
|
||||||
Reference in New Issue
Block a user