feat: visual polish & UX — fade-in, theme toggle, scroll-to-top, footer, cursor, badge
- Add FadeIn component (IntersectionObserver scroll-reveal + load animation) - Add dark/light theme toggle (useSyncExternalStore + localStorage) - Add scroll-to-top button (appears after 300px scroll) - Add configurable footer with social links (gallery.yaml) - Add crosshair cursor on photo grid items - Add 'X photos' count badge on subpage cover images - Add light theme color palette in tokens.css
This commit is contained in:
@@ -1,17 +1,27 @@
|
||||
# Immich Server Connection
|
||||
# ── Immich Server Connection (required) ──────────────────────────
|
||||
# Validated by Zod at startup — missing or invalid values produce clear errors.
|
||||
|
||||
# Base URL of your Immich instance (must be a valid URL)
|
||||
IMMICH_API_URL=http://your-immich-server:2283
|
||||
|
||||
# Immich API key (from Admin → API Keys)
|
||||
IMMICH_API_KEY=your-api-key-here
|
||||
|
||||
# Optional: Site title and subtitle (default: "Gallery")
|
||||
# ── Optional Settings ────────────────────────────────────────────
|
||||
# All have sensible defaults and are type-checked by Zod.
|
||||
|
||||
# Site title shown in the header (string, default: "Gallery")
|
||||
SITE_TITLE=My Photography
|
||||
|
||||
# Subtitle displayed below the title (string, default: "")
|
||||
SITE_SUBTITLE=A visual journal
|
||||
|
||||
# Optional: Cache TTL in seconds (default: 300 = 5 minutes)
|
||||
# Cache TTL in seconds (integer >= 0, default: 300 = 5 minutes)
|
||||
CACHE_TTL=300
|
||||
|
||||
# Optional: Rate limit for image proxy (requests per minute per IP, default: 120)
|
||||
# Rate limit for image proxy (integer >= 1, requests per minute per IP, default: 120)
|
||||
# RATE_LIMIT_RPM=120
|
||||
|
||||
# ── Album configuration ──
|
||||
# ── Album configuration ──────────────────────────────────────────
|
||||
# Albums, subpages, hero image, and passwords are defined in content/gallery.yaml
|
||||
# See content/gallery.yaml.example for the full reference.
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,8 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
build
|
||||
out
|
||||
*.yaml
|
||||
package-lock.json
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -28,7 +28,7 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
|
||||
### Security
|
||||
|
||||
| Concern | Protection |
|
||||
|---------|-----------|
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **Server exposure** | Immich URL and port are never sent to the browser — all requests are proxied server-side |
|
||||
| **API key** | Stored only in `.env.local`, never included in client-side code or responses |
|
||||
| **Asset IDs** | Immich UUIDs are encrypted (AES-256) into opaque tokens — visitors cannot guess or enumerate asset IDs |
|
||||
@@ -108,7 +108,7 @@ subpages:
|
||||
grid:
|
||||
columns: 4
|
||||
layout: uniform
|
||||
aspectRatio: "3/2"
|
||||
aspectRatio: '3/2'
|
||||
albums:
|
||||
- uuid-tokyo
|
||||
- uuid-kyoto
|
||||
@@ -147,7 +147,7 @@ services:
|
||||
container_name: immich-folio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7211:7211"
|
||||
- '7211:7211'
|
||||
env_file:
|
||||
- .env.local
|
||||
volumes:
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Lightbox } from '@/components/Lightbox';
|
||||
import { FadeIn } from '@/components/FadeIn';
|
||||
|
||||
export interface PhotoItem {
|
||||
id: string;
|
||||
@@ -44,15 +45,11 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
}, []);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setLightboxIndex((prev) =>
|
||||
prev !== null ? (prev + 1) % assets.length : null,
|
||||
);
|
||||
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,
|
||||
);
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null));
|
||||
}, [assets.length]);
|
||||
|
||||
// Keyboard navigation
|
||||
@@ -87,8 +84,8 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
<>
|
||||
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
|
||||
{assets.map((asset, index) => (
|
||||
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
|
||||
<div
|
||||
key={asset.id}
|
||||
className="photo-grid__item"
|
||||
onClick={() => openLightbox(index)}
|
||||
style={{
|
||||
@@ -114,6 +111,7 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -129,4 +127,3 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,13 @@ import type { Metadata } from 'next';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { imageUrl, exifUrl, assetPlaceholder, assetExifSummary, assetAspectRatio } from '@/lib/urls';
|
||||
import {
|
||||
imageUrl,
|
||||
exifUrl,
|
||||
assetPlaceholder,
|
||||
assetExifSummary,
|
||||
assetAspectRatio,
|
||||
} from '@/lib/urls';
|
||||
import { getConfig, type GridConfig } from '@/lib/config';
|
||||
import { isProtected, isAuthenticated } from '@/lib/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
@@ -129,7 +135,14 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
<>
|
||||
<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">
|
||||
<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}
|
||||
@@ -197,7 +210,14 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
<>
|
||||
<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">
|
||||
<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
|
||||
@@ -263,11 +283,11 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
<div className="skeleton" style={{ width: '100%', height: '100%' }} />
|
||||
)}
|
||||
<span className="subpage-grid__item-badge">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
<div className="subpage-grid__item-overlay">
|
||||
<span className="subpage-grid__item-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-count">
|
||||
@@ -293,7 +313,14 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
<>
|
||||
<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">
|
||||
<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
|
||||
|
||||
@@ -28,9 +28,7 @@ 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 portraitAsset = meta.portrait ? await immich.getAssetInfo(meta.portrait) : null;
|
||||
const portraitPlaceholder = portraitAsset ? assetPlaceholder(portraitAsset) : null;
|
||||
|
||||
return (
|
||||
@@ -56,15 +54,16 @@ export default async function AboutPage() {
|
||||
{/* ── Text ─────────────────────────────────────── */}
|
||||
<div className="about__text-col">
|
||||
{meta.name && <h1 className="about__name">{meta.name}</h1>}
|
||||
{meta.location && (
|
||||
<p className="about__location">{meta.location}</p>
|
||||
)}
|
||||
{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) => (
|
||||
{body
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -85,4 +84,3 @@ export default async function AboutPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,35 +12,20 @@ export async function POST(request: NextRequest) {
|
||||
const { slug, password } = body;
|
||||
|
||||
if (!slug || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing slug or password' },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Missing slug or password' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isProtected(slug)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Album is not password-protected' },
|
||||
{ status: 400 },
|
||||
);
|
||||
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({ error: 'Invalid password' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true },
|
||||
{ headers: { 'Set-Cookie': setCookie } },
|
||||
);
|
||||
return NextResponse.json({ success: true }, { headers: { 'Set-Cookie': setCookie } });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body' },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,7 @@ 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 }> },
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -24,10 +24,7 @@ function widthToSize(w: number): ImageSize {
|
||||
return 'original';
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
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);
|
||||
@@ -86,4 +83,3 @@ export async function GET(
|
||||
|
||||
return new NextResponse(result.stream, { headers });
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ export async function GET(request: NextRequest) {
|
||||
const subtitle = searchParams.get('subtitle') || '';
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -80,8 +79,7 @@ export async function GET(request: NextRequest) {
|
||||
marginTop: '40px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
|
||||
566
app/globals.css
566
app/globals.css
@@ -1,69 +1,4 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Playfair+Display:wght@400;500;600&display=swap');
|
||||
|
||||
/* ── Design Tokens ────────────────────────────────── */
|
||||
:root {
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #111111;
|
||||
--bg-card: #161616;
|
||||
--bg-card-hover: #1a1a1a;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.92);
|
||||
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: #999999;
|
||||
--text-muted: #666666;
|
||||
|
||||
--border-subtle: #222222;
|
||||
--border-hover: #333333;
|
||||
|
||||
--accent: #d4af37;
|
||||
--accent-dim: rgba(212, 175, 55, 0.15);
|
||||
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 300ms ease;
|
||||
--transition-slow: 500ms ease;
|
||||
|
||||
--max-width: 1400px;
|
||||
--header-height: 64px;
|
||||
}
|
||||
|
||||
/* ── Reset ────────────────────────────────────────── */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
@import url('./tokens.css');
|
||||
|
||||
/* ── Header / Navigation ──────────────────────────── */
|
||||
.header {
|
||||
@@ -77,6 +12,10 @@ img {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
[data-theme='light'] .header {
|
||||
background: rgba(250, 250, 250, 0.9);
|
||||
}
|
||||
|
||||
.header__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -180,7 +119,9 @@ img {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: rgba(255, 255, 255, 0.2);
|
||||
transition: color var(--transition-fast), text-decoration-color var(--transition-fast);
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
text-decoration-color var(--transition-fast);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
@@ -252,7 +193,9 @@ img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--transition-slow), filter var(--transition-slow);
|
||||
transition:
|
||||
transform var(--transition-slow),
|
||||
filter var(--transition-slow);
|
||||
}
|
||||
|
||||
.subpage-grid__item:hover img {
|
||||
@@ -267,9 +210,7 @@ img {
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 20px;
|
||||
background: linear-gradient(to top,
|
||||
rgba(0, 0, 0, 0.6) 0%,
|
||||
transparent 50%);
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, transparent 50%);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
@@ -307,7 +248,9 @@ img {
|
||||
cursor: pointer;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
transition: border-color var(--transition-normal), transform var(--transition-normal);
|
||||
transition:
|
||||
border-color var(--transition-normal),
|
||||
transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
@@ -319,7 +262,9 @@ img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--transition-slow), filter var(--transition-slow);
|
||||
transition:
|
||||
transform var(--transition-slow),
|
||||
filter var(--transition-slow);
|
||||
}
|
||||
|
||||
.album-card:hover .album-card__image {
|
||||
@@ -430,7 +375,9 @@ img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: filter var(--transition-normal), transform var(--transition-slow);
|
||||
transition:
|
||||
filter var(--transition-normal),
|
||||
transform var(--transition-slow);
|
||||
}
|
||||
|
||||
.photo-grid__item:hover img {
|
||||
@@ -463,218 +410,12 @@ img {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Lightbox ─────────────────────────────────────── */
|
||||
.lightbox-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: lightbox-fade-in var(--transition-normal) ease forwards;
|
||||
}
|
||||
|
||||
@keyframes lightbox-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.lightbox-overlay.closing {
|
||||
animation: lightbox-fade-out var(--transition-fast) ease forwards;
|
||||
}
|
||||
|
||||
@keyframes lightbox-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lightbox__image-container {
|
||||
position: relative;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lightbox__image {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-sm);
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.lightbox__image--loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lightbox__close {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 24px;
|
||||
z-index: 1001;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.lightbox__close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.lightbox__close svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.lightbox__nav {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1001;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.lightbox__nav:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.lightbox__nav--prev {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.lightbox__nav--next {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.lightbox__nav svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.lightbox__counter {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ── EXIF Info Panel ──────────────────────────────── */
|
||||
.lightbox__info-toggle {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 1001;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-sans);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.lightbox__info-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exif-panel {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
right: 24px;
|
||||
z-index: 1001;
|
||||
background: rgba(20, 20, 20, 0.92);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
min-width: 240px;
|
||||
animation: exif-slide-up var(--transition-normal) ease forwards;
|
||||
}
|
||||
|
||||
@keyframes exif-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.exif-panel__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.exif-panel__row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.exif-panel__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.exif-panel__value {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Loading States ───────────────────────────────── */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--bg-card) 0%, var(--bg-card-hover) 50%, var(--bg-card) 100%);
|
||||
background: linear-gradient(90deg,
|
||||
var(--bg-card) 0%,
|
||||
var(--bg-card-hover) 50%,
|
||||
var(--bg-card) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -690,6 +431,155 @@ img {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Fade-In Animations ────────────────────────────── */
|
||||
.fade-in {
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
}
|
||||
|
||||
.fade-in--up {
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.fade-in--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Custom Cursor (Photo Grid) ─────────────────── */
|
||||
.photo-grid__item {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* ── Scroll-to-Top Button ────────────────────────── */
|
||||
.scroll-to-top {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
z-index: 90;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
transition:
|
||||
opacity var(--transition-normal),
|
||||
transform var(--transition-normal),
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.scroll-to-top--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.scroll-to-top:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Theme Toggle ────────────────────────────────── */
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Footer ──────────────────────────────────────── */
|
||||
.footer {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 32px 24px;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.footer__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer__name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.footer__links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer__link {
|
||||
color: var(--text-muted);
|
||||
transition: color var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer__link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Image Count Badge (Subpage Covers) ─────────── */
|
||||
.subpage-grid__item-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
padding: 4px 10px;
|
||||
border-radius: 100px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Error / Empty States ─────────────────────────── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
@@ -708,97 +598,6 @@ img {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* ── Password Gate ────────────────────────────────── */
|
||||
.password-gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.password-gate__card {
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.password-gate__title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.password-gate__subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 300;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.password-gate__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.password-gate__input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.password-gate__input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.password-gate__input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.password-gate__button {
|
||||
padding: 12px 24px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.password-gate__button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.password-gate__button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-gate__error {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: #e74c3c;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────── */
|
||||
@media (max-width: 1024px) {
|
||||
.photo-grid {
|
||||
@@ -860,21 +659,4 @@ img {
|
||||
.subpage-grid__item-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lightbox__nav {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.lightbox__nav--prev {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.lightbox__nav--next {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.password-gate__card {
|
||||
padding: 36px 24px;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import './globals.css';
|
||||
import { SubpageNav } from '@/components/SubpageNav';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { ScrollToTop } from '@/components/ScrollToTop';
|
||||
import { Footer } from '@/components/Footer';
|
||||
|
||||
const siteTitle = process.env.SITE_TITLE || 'Gallery';
|
||||
const siteDescription = 'A curated photography portfolio';
|
||||
@@ -28,13 +31,9 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<header className="header">
|
||||
<nav className="header__nav">
|
||||
@@ -45,9 +44,12 @@ export default function RootLayout({
|
||||
<Link href="/about" className="header__nav-link">
|
||||
About
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</header>
|
||||
<main className="main">{children}</main>
|
||||
<Footer />
|
||||
<ScrollToTop />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
20
app/page.tsx
20
app/page.tsx
@@ -9,6 +9,7 @@ import { immich } from '@/lib/immich';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||
import { HeroCarousel } from '@/components/HeroCarousel';
|
||||
import { FadeIn } from '@/components/FadeIn';
|
||||
|
||||
// Render at request time — requires live Immich connection
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -37,31 +38,29 @@ export default async function HomePage() {
|
||||
{/* ── Left Panel ──────────────────────────────── */}
|
||||
<div className="hero__left">
|
||||
<div className="hero__content">
|
||||
<FadeIn delay={0}>
|
||||
<h1 className="hero__title">{config.siteTitle}</h1>
|
||||
</FadeIn>
|
||||
{config.siteSubtitle && (
|
||||
<FadeIn delay={100}>
|
||||
<p className="hero__subtitle">{config.siteSubtitle}</p>
|
||||
</FadeIn>
|
||||
)}
|
||||
|
||||
<FadeIn delay={200}>
|
||||
<nav className="hero__nav">
|
||||
{subpages.map((sp) => (
|
||||
<Link
|
||||
key={sp.slug}
|
||||
href={`/${sp.slug}`}
|
||||
className="hero__nav-link"
|
||||
>
|
||||
<Link key={sp.slug} href={`/${sp.slug}`} className="hero__nav-link">
|
||||
{sp.name}
|
||||
</Link>
|
||||
))}
|
||||
{albums.map((album) => (
|
||||
<Link
|
||||
key={album.id}
|
||||
href={`/${album.slug}`}
|
||||
className="hero__nav-link"
|
||||
>
|
||||
<Link key={album.id} href={`/${album.slug}`} className="hero__nav-link">
|
||||
{album.albumName}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,4 +71,3 @@ export default async function HomePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
85
app/tokens.css
Normal file
85
app/tokens.css
Normal file
@@ -0,0 +1,85 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Playfair+Display:wght@400;500;600&display=swap');
|
||||
|
||||
/* ── Design Tokens ────────────────────────────────── */
|
||||
:root {
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #111111;
|
||||
--bg-card: #161616;
|
||||
--bg-card-hover: #1a1a1a;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.92);
|
||||
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: #999999;
|
||||
--text-muted: #666666;
|
||||
|
||||
--border-subtle: #222222;
|
||||
--border-hover: #333333;
|
||||
|
||||
--accent: #d4af37;
|
||||
--accent-dim: rgba(212, 175, 55, 0.15);
|
||||
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 300ms ease;
|
||||
--transition-slow: 500ms ease;
|
||||
|
||||
--max-width: 1400px;
|
||||
--header-height: 64px;
|
||||
}
|
||||
|
||||
/* ── Light Theme ──────────────────────────────────── */
|
||||
[data-theme='light'] {
|
||||
--bg-primary: #fafafa;
|
||||
--bg-secondary: #f0f0f0;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #f5f5f5;
|
||||
--bg-overlay: rgba(255, 255, 255, 0.92);
|
||||
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #666666;
|
||||
--text-muted: #999999;
|
||||
|
||||
--border-subtle: #e0e0e0;
|
||||
--border-hover: #cccccc;
|
||||
|
||||
--accent: #b8960e;
|
||||
--accent-dim: rgba(184, 150, 14, 0.12);
|
||||
}
|
||||
|
||||
/* ── Reset ────────────────────────────────────────── */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
63
components/FadeIn.tsx
Normal file
63
components/FadeIn.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* FadeIn — scroll-reveal wrapper using IntersectionObserver.
|
||||
*
|
||||
* Wraps children in a div that fades/slides in when it enters the viewport.
|
||||
* Supports an optional stagger delay for grid items.
|
||||
* On the homepage (no scroll), acts as a simple load-in animation.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface FadeInProps {
|
||||
children: React.ReactNode;
|
||||
/** Stagger delay in ms (e.g. index * 60) */
|
||||
delay?: number;
|
||||
/** Slide direction */
|
||||
direction?: 'up' | 'none';
|
||||
/** CSS class name for the wrapper */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FadeIn({ children, delay = 0, direction = 'up', className }: FadeInProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const reveal = useCallback(() => {
|
||||
ref.current?.classList.add('fade-in--visible');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
// Respect reduced-motion preference — reveal immediately
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
el.classList.add('fade-in--visible');
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
reveal();
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [reveal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`fade-in ${direction === 'up' ? 'fade-in--up' : ''} ${className ?? ''}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
components/Footer.tsx
Normal file
92
components/Footer.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Footer — minimal footer with photographer name + social links.
|
||||
* Reads configuration from gallery.yaml via getConfig().
|
||||
*/
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export function Footer() {
|
||||
const config = getConfig();
|
||||
const { footer } = config;
|
||||
|
||||
// Don't render if no footer config
|
||||
if (!footer || (!footer.name && !footer.instagram && !footer.email && !footer.website)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="footer__inner">
|
||||
{footer.name && <span className="footer__name">{footer.name}</span>}
|
||||
|
||||
<div className="footer__links">
|
||||
{footer.instagram && (
|
||||
<a
|
||||
href={`https://instagram.com/${footer.instagram.replace('@', '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer__link"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
|
||||
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
|
||||
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{footer.email && (
|
||||
<a href={`mailto:${footer.email}`} className="footer__link" aria-label="Email">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||
<polyline points="22,6 12,13 2,6" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{footer.website && (
|
||||
<a
|
||||
href={footer.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer__link"
|
||||
aria-label="Website"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -49,9 +49,7 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
|
||||
fill
|
||||
sizes="50vw"
|
||||
priority
|
||||
{...(img.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
|
||||
: {})}
|
||||
{...(img.blurDataURL ? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
229
components/Lightbox.module.css
Normal file
229
components/Lightbox.module.css
Normal file
@@ -0,0 +1,229 @@
|
||||
/* ── Lightbox Module ──────────────────────────────── */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: lightbox-fade-in var(--transition-normal) ease forwards;
|
||||
}
|
||||
|
||||
@keyframes lightbox-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay.closing {
|
||||
animation: lightbox-fade-out var(--transition-fast) ease forwards;
|
||||
}
|
||||
|
||||
@keyframes lightbox-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-sm);
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.imageLoaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 24px;
|
||||
z-index: 1001;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1001;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navPrev {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.navNext {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.nav svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.counter {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ── EXIF Info Panel ──────────────────────────────── */
|
||||
.infoToggle {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 1001;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-sans);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.infoToggle:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exifPanel {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
right: 24px;
|
||||
z-index: 1001;
|
||||
background: rgba(20, 20, 20, 0.92);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
min-width: 240px;
|
||||
animation: exif-slide-up var(--transition-normal) ease forwards;
|
||||
}
|
||||
|
||||
@keyframes exif-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.exifRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.exifRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.exifLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.exifValue {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.nav {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.navPrev {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.navNext {
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||
import styles from './Lightbox.module.css';
|
||||
|
||||
interface ExifData {
|
||||
make?: string | null;
|
||||
@@ -34,13 +35,7 @@ interface LightboxProps {
|
||||
onPrev: () => void;
|
||||
}
|
||||
|
||||
export function Lightbox({
|
||||
assets,
|
||||
currentIndex,
|
||||
onClose,
|
||||
onNext,
|
||||
onPrev,
|
||||
}: LightboxProps) {
|
||||
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);
|
||||
@@ -125,15 +120,22 @@ export function Lightbox({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lightbox-overlay"
|
||||
className={styles.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">
|
||||
<button className={styles.close} onClick={onClose} aria-label="Close">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
@@ -141,18 +143,25 @@ export function Lightbox({
|
||||
|
||||
{/* Previous button */}
|
||||
<button
|
||||
className="lightbox__nav lightbox__nav--prev"
|
||||
className={`${styles.nav} ${styles.navPrev}`}
|
||||
onClick={onPrev}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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"
|
||||
className={styles.imageContainer}
|
||||
style={{
|
||||
backgroundColor: current.dominantColor || '#000',
|
||||
backgroundImage: current.blurDataURL ? `url(${current.blurDataURL})` : undefined,
|
||||
@@ -162,7 +171,7 @@ export function Lightbox({
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className={`lightbox__image${imageLoaded ? ' lightbox__image--loaded' : ''}`}
|
||||
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
|
||||
src={current.previewUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
@@ -172,87 +181,91 @@ export function Lightbox({
|
||||
|
||||
{/* Next button */}
|
||||
<button
|
||||
className="lightbox__nav lightbox__nav--next"
|
||||
className={`${styles.nav} ${styles.navNext}`}
|
||||
onClick={onNext}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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">
|
||||
<div className={styles.counter}>
|
||||
{currentIndex + 1} / {assets.length}
|
||||
</div>
|
||||
|
||||
{/* EXIF toggle */}
|
||||
<button
|
||||
className="lightbox__info-toggle"
|
||||
onClick={handleExifToggle}
|
||||
>
|
||||
<button className={styles.infoToggle} onClick={handleExifToggle}>
|
||||
{showExif ? 'Hide Info' : 'Info'}
|
||||
</button>
|
||||
|
||||
{/* EXIF panel */}
|
||||
{showExif && (
|
||||
<div className="exif-panel">
|
||||
<div className={styles.exifPanel}>
|
||||
{exifLoading ? (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">Loading...</span>
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Loading...</span>
|
||||
</div>
|
||||
) : exifData ? (
|
||||
<>
|
||||
{exifData.model && (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">Camera</span>
|
||||
<span className="exif-panel__value">
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Camera</span>
|
||||
<span className={styles.exifValue}>
|
||||
{[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 className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Lens</span>
|
||||
<span className={styles.exifValue}>{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 className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Focal Length</span>
|
||||
<span className={styles.exifValue}>{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 className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Aperture</span>
|
||||
<span className={styles.exifValue}>ƒ/{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 className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Shutter</span>
|
||||
<span className={styles.exifValue}>{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 className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>ISO</span>
|
||||
<span className={styles.exifValue}>{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">
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Location</span>
|
||||
<span className={styles.exifValue}>
|
||||
{[exifData.city, exifData.country].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">No EXIF data available</span>
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>No EXIF data available</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
98
components/PasswordGate.module.css
Normal file
98
components/PasswordGate.module.css
Normal file
@@ -0,0 +1,98 @@
|
||||
/* ── Password Gate Module ─────────────────────────── */
|
||||
|
||||
.gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 300;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 12px 24px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: #e74c3c;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.card {
|
||||
padding: 36px 24px;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import styles from './PasswordGate.module.css';
|
||||
|
||||
interface PasswordGateProps {
|
||||
slug: string;
|
||||
@@ -43,33 +44,27 @@ export default function PasswordGate({ slug, title }: PasswordGateProps) {
|
||||
}
|
||||
|
||||
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>
|
||||
<div className={styles.gate}>
|
||||
<div className={styles.card}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<p className={styles.subtitle}>This gallery is password-protected.</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="password-gate__form">
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="password-gate__input"
|
||||
className={styles.input}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="password-gate__button"
|
||||
disabled={loading}
|
||||
>
|
||||
<button type="submit" className={styles.button} disabled={loading}>
|
||||
{loading ? 'Verifying…' : 'Enter'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className="password-gate__error">{error}</p>}
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
37
components/ScrollToTop.tsx
Normal file
37
components/ScrollToTop.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* ScrollToTop — fixed ↑ button that appears after scrolling down.
|
||||
* Smooth-scrolls back to the top when clicked.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const SHOW_THRESHOLD = 300;
|
||||
|
||||
export function ScrollToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setVisible(window.scrollY > SHOW_THRESHOLD);
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`scroll-to-top ${visible ? 'scroll-to-top--visible' : ''}`}
|
||||
onClick={scrollToTop}
|
||||
aria-label="Scroll to top"
|
||||
type="button"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -15,20 +15,12 @@ export async function SubpageNav() {
|
||||
return (
|
||||
<>
|
||||
{subpages.map((sp) => (
|
||||
<Link
|
||||
key={sp.slug}
|
||||
href={`/${sp.slug}`}
|
||||
className="header__nav-link"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<Link key={album.id} href={`/${album.slug}`} className="header__nav-link">
|
||||
{album.albumName}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
102
components/ThemeToggle.tsx
Normal file
102
components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* ThemeToggle — dark/light mode switch.
|
||||
*
|
||||
* Persists preference in localStorage and sets data-theme on <html>.
|
||||
* Defaults to dark theme (matching existing design).
|
||||
* Uses useSyncExternalStore to satisfy React strict mode lint rules.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore, useCallback } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
/** Module-level theme state — shared across all instances. */
|
||||
let currentTheme: Theme = 'dark';
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
function getSnapshot(): Theme {
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): Theme {
|
||||
return 'dark'; // SSR default
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
currentTheme = theme;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
listeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// Initialize from localStorage on module load (client-side only)
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem('theme') as Theme | null;
|
||||
currentTheme = stored || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', currentTheme);
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
applyTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="theme-toggle"
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
type="button"
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
// Sun icon
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
) : (
|
||||
// Moon icon
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -23,3 +23,10 @@ subpages:
|
||||
password: clientpass123
|
||||
albums:
|
||||
- 4ad0ed89-55de-4e69-937f-2d9988f63ad1
|
||||
|
||||
# Footer — optional minimal footer with social links
|
||||
footer:
|
||||
name: Ralf Ohlenbostel
|
||||
instagram: quasiralf
|
||||
# email: hello@example.com
|
||||
# website: https://example.com
|
||||
|
||||
@@ -42,3 +42,10 @@ subpages:
|
||||
password: clientpass123 # visitors must enter this to view
|
||||
albums:
|
||||
- 55555555-5555-5555-5555-555555555555
|
||||
|
||||
# Footer — optional minimal footer with social links
|
||||
# footer:
|
||||
# name: John Doe
|
||||
# instagram: johndoe
|
||||
# email: hello@example.com
|
||||
# website: https://example.com
|
||||
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
container_name: immich-lightbox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7211:7211"
|
||||
- '7211:7211'
|
||||
env_file:
|
||||
- .env.local
|
||||
volumes:
|
||||
|
||||
23
e2e/about.spec.ts
Normal file
23
e2e/about.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('About page', () => {
|
||||
test('loads and shows the about content', async ({ page }) => {
|
||||
await page.goto('/about');
|
||||
const about = page.locator('.about');
|
||||
await expect(about).toBeVisible();
|
||||
});
|
||||
|
||||
test('has a portrait column', async ({ page }) => {
|
||||
await page.goto('/about');
|
||||
const portrait = page.locator('.about__portrait-col');
|
||||
await expect(portrait).toBeVisible();
|
||||
});
|
||||
|
||||
test('has a text column with name', async ({ page }) => {
|
||||
await page.goto('/about');
|
||||
const name = page.locator('.about__name');
|
||||
await expect(name).toBeVisible();
|
||||
const text = await name.textContent();
|
||||
expect(text).toBeTruthy();
|
||||
});
|
||||
});
|
||||
10
e2e/health.spec.ts
Normal file
10
e2e/health.spec.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Health API', () => {
|
||||
test('GET /api/health returns 200 with expected shape', async ({ request }) => {
|
||||
const response = await request.get('/api/health');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('status');
|
||||
});
|
||||
});
|
||||
38
e2e/homepage.spec.ts
Normal file
38
e2e/homepage.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Homepage', () => {
|
||||
test('loads and renders the hero section', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Wait for the hero section to appear
|
||||
const hero = page.locator('.hero');
|
||||
await expect(hero).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows the site title in the hero', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const title = page.locator('.hero__title');
|
||||
await expect(title).toBeVisible();
|
||||
const text = await title.textContent();
|
||||
expect(text).toBeTruthy();
|
||||
});
|
||||
|
||||
test('has navigation links in the header', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const nav = page.locator('.header__nav');
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
// Should have at least the Home and About links
|
||||
const links = nav.locator('.header__nav-link');
|
||||
const count = await links.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('hero nav links are present for subpages', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const heroNav = page.locator('.hero__nav');
|
||||
await expect(heroNav).toBeVisible();
|
||||
const links = heroNav.locator('.hero__nav-link');
|
||||
const count = await links.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
31
e2e/navigation.spec.ts
Normal file
31
e2e/navigation.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
test('clicking a hero nav link navigates to a subpage', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const heroNav = page.locator('.hero__nav .hero__nav-link').first();
|
||||
const href = await heroNav.getAttribute('href');
|
||||
expect(href).toBeTruthy();
|
||||
|
||||
await heroNav.click();
|
||||
await page.waitForURL(`**${href}`);
|
||||
expect(page.url()).toContain(href);
|
||||
});
|
||||
|
||||
test('header About link navigates to /about', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const aboutLink = page.locator('.header__nav-link', { hasText: 'About' });
|
||||
await aboutLink.click();
|
||||
await page.waitForURL('**/about');
|
||||
expect(page.url()).toContain('/about');
|
||||
});
|
||||
|
||||
test('header Home link navigates back to /', async ({ page }) => {
|
||||
await page.goto('/about');
|
||||
const homeLink = page.locator('.header__nav-link', { hasText: 'Home' });
|
||||
await homeLink.click();
|
||||
await page.waitForURL('**/');
|
||||
// Accept both "/" and "?..." after root
|
||||
expect(new URL(page.url()).pathname).toBe('/');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,38 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTs from 'eslint-config-next/typescript';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import prettierPlugin from 'eslint-plugin-prettier';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'no-console': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
53
lib/__tests__/config.test.ts
Normal file
53
lib/__tests__/config.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock env.ts so importing config.ts doesn't trigger Zod validation
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {
|
||||
IMMICH_API_URL: 'http://localhost:2283',
|
||||
IMMICH_API_KEY: 'test-key',
|
||||
SITE_TITLE: 'Test Gallery',
|
||||
SITE_SUBTITLE: '',
|
||||
CACHE_TTL: 300,
|
||||
RATE_LIMIT_RPM: 120,
|
||||
},
|
||||
}));
|
||||
|
||||
import { slugify } from '@/lib/config';
|
||||
|
||||
describe('slugify', () => {
|
||||
it('converts a simple name to lowercase with hyphens', () => {
|
||||
expect(slugify('Kloster Chorin')).toBe('kloster-chorin');
|
||||
});
|
||||
|
||||
it('strips diacritics', () => {
|
||||
expect(slugify('Schöne Aussicht')).toBe('schone-aussicht');
|
||||
expect(slugify('Café Müller')).toBe('cafe-muller');
|
||||
});
|
||||
|
||||
it('replaces special characters with hyphens', () => {
|
||||
expect(slugify('Hello, World!')).toBe('hello-world');
|
||||
expect(slugify('foo & bar (baz)')).toBe('foo-bar-baz');
|
||||
});
|
||||
|
||||
it('trims leading and trailing hyphens', () => {
|
||||
expect(slugify('--leading')).toBe('leading');
|
||||
expect(slugify('trailing---')).toBe('trailing');
|
||||
expect(slugify('---both---')).toBe('both');
|
||||
});
|
||||
|
||||
it('collapses consecutive hyphens', () => {
|
||||
expect(slugify('a b c')).toBe('a-b-c');
|
||||
});
|
||||
|
||||
it('handles an empty string', () => {
|
||||
expect(slugify('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles all-special-character input', () => {
|
||||
expect(slugify('!@#$%')).toBe('');
|
||||
});
|
||||
|
||||
it('preserves numbers', () => {
|
||||
expect(slugify('Album 2024')).toBe('album-2024');
|
||||
});
|
||||
});
|
||||
27
lib/__tests__/thumbhash.test.ts
Normal file
27
lib/__tests__/thumbhash.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { thumbHashToBlurDataUrl, thumbHashToDominantHex } from '@/lib/thumbhash';
|
||||
|
||||
// A real ThumbHash captured from an Immich asset (13 bytes, valid for the library)
|
||||
// This is a minimal valid ThumbHash that produces a tiny image.
|
||||
// Generate one via: thumbhash.rgbaToThumbHash(w, h, rgba)
|
||||
// For testing, we use a known-good base64 string that the library accepts.
|
||||
const VALID_THUMBHASH = 'IQgSFQJ3d2d2eHiIh4h3d3c';
|
||||
|
||||
describe('thumbHashToBlurDataUrl', () => {
|
||||
it('produces a valid data URL', () => {
|
||||
const result = thumbHashToBlurDataUrl(VALID_THUMBHASH);
|
||||
expect(result).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
|
||||
it('produces a non-trivial string (has actual image data)', () => {
|
||||
const result = thumbHashToBlurDataUrl(VALID_THUMBHASH);
|
||||
expect(result.length).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbHashToDominantHex', () => {
|
||||
it('returns a valid hex color string', () => {
|
||||
const result = thumbHashToDominantHex(VALID_THUMBHASH);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
});
|
||||
62
lib/__tests__/tokens.test.ts
Normal file
62
lib/__tests__/tokens.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the config module so tokens.ts can resolve getConfig().immich.apiKey
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
immich: { apiKey: 'test-api-key-for-vitest-unit-tests' },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
|
||||
|
||||
const SAMPLE_UUID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
describe('tokens', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('encodeAssetId', () => {
|
||||
it('produces a non-empty base64url string', () => {
|
||||
const token = encodeAssetId(SAMPLE_UUID);
|
||||
expect(token).toBeTruthy();
|
||||
expect(typeof token).toBe('string');
|
||||
// base64url characters only
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('is deterministic — same input yields same token', () => {
|
||||
const t1 = encodeAssetId(SAMPLE_UUID);
|
||||
const t2 = encodeAssetId(SAMPLE_UUID);
|
||||
expect(t1).toBe(t2);
|
||||
});
|
||||
|
||||
it('produces different tokens for different UUIDs', () => {
|
||||
const t1 = encodeAssetId(SAMPLE_UUID);
|
||||
const t2 = encodeAssetId('12345678-aaaa-bbbb-cccc-123456789abc');
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeAssetId', () => {
|
||||
it('round-trips a valid UUID', () => {
|
||||
const token = encodeAssetId(SAMPLE_UUID);
|
||||
const decoded = decodeAssetId(token);
|
||||
expect(decoded).toBe(SAMPLE_UUID);
|
||||
});
|
||||
|
||||
it('returns null for a garbage string', () => {
|
||||
expect(decodeAssetId('not-a-valid-token')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty string', () => {
|
||||
expect(decodeAssetId('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a token that is too short', () => {
|
||||
// less than 17 bytes when decoded
|
||||
const short = Buffer.from('shortdata').toString('base64url');
|
||||
expect(decodeAssetId(short)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
162
lib/__tests__/urls.test.ts
Normal file
162
lib/__tests__/urls.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock tokens so we get predictable URL output
|
||||
vi.mock('@/lib/tokens', () => ({
|
||||
encodeAssetId: (id: string) => `ENCODED_${id}`,
|
||||
}));
|
||||
|
||||
// Mock thumbhash helpers
|
||||
vi.mock('@/lib/thumbhash', () => ({
|
||||
thumbHashToBlurDataUrl: () => 'data:image/png;base64,mockblur',
|
||||
thumbHashToDominantHex: () => '#aabbcc',
|
||||
}));
|
||||
|
||||
import {
|
||||
imageUrl,
|
||||
exifUrl,
|
||||
assetPlaceholder,
|
||||
assetExifSummary,
|
||||
assetAspectRatio,
|
||||
} from '@/lib/urls';
|
||||
|
||||
describe('imageUrl', () => {
|
||||
it('generates a proxy URL with encoded token and default size', () => {
|
||||
const url = imageUrl('some-uuid');
|
||||
expect(url).toBe('/api/image/ENCODED_some-uuid?size=preview');
|
||||
});
|
||||
|
||||
it('uses the specified size parameter', () => {
|
||||
expect(imageUrl('id', 'thumbnail')).toBe('/api/image/ENCODED_id?size=thumbnail');
|
||||
expect(imageUrl('id', 'original')).toBe('/api/image/ENCODED_id?size=original');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exifUrl', () => {
|
||||
it('generates an EXIF API URL with encoded token', () => {
|
||||
expect(exifUrl('my-asset')).toBe('/api/exif/ENCODED_my-asset');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetPlaceholder', () => {
|
||||
it('returns blur data and dominant color for an asset with thumbhash', () => {
|
||||
const result = assetPlaceholder({ thumbhash: 'abc123' });
|
||||
expect(result).toEqual({
|
||||
blurDataURL: 'data:image/png;base64,mockblur',
|
||||
dominantColor: '#aabbcc',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when thumbhash is null', () => {
|
||||
expect(assetPlaceholder({ thumbhash: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when thumbhash is empty string', () => {
|
||||
expect(assetPlaceholder({ thumbhash: '' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetExifSummary', () => {
|
||||
it('returns camera, lens, and focal length from exifInfo', () => {
|
||||
const result = assetExifSummary({
|
||||
exifInfo: {
|
||||
make: 'Leica',
|
||||
model: 'M11-P',
|
||||
lensModel: 'Summilux-M 50mm',
|
||||
focalLength: 50,
|
||||
fNumber: null,
|
||||
exposureTime: null,
|
||||
iso: null,
|
||||
exifImageWidth: null,
|
||||
exifImageHeight: null,
|
||||
city: null,
|
||||
state: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
camera: 'M11-P',
|
||||
lens: 'Summilux-M 50mm',
|
||||
focalLength: '50mm',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when no exifInfo exists', () => {
|
||||
expect(assetExifSummary({ exifInfo: undefined })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when all relevant fields are null', () => {
|
||||
expect(
|
||||
assetExifSummary({
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
lensModel: null,
|
||||
focalLength: null,
|
||||
fNumber: null,
|
||||
exposureTime: null,
|
||||
iso: null,
|
||||
exifImageWidth: null,
|
||||
exifImageHeight: null,
|
||||
city: null,
|
||||
state: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetAspectRatio', () => {
|
||||
it('computes width / height ratio', () => {
|
||||
const result = assetAspectRatio({
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
lensModel: null,
|
||||
focalLength: null,
|
||||
fNumber: null,
|
||||
exposureTime: null,
|
||||
iso: null,
|
||||
exifImageWidth: 3000,
|
||||
exifImageHeight: 2000,
|
||||
city: null,
|
||||
state: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(1.5);
|
||||
});
|
||||
|
||||
it('returns undefined when dimensions are missing', () => {
|
||||
expect(assetAspectRatio({ exifInfo: undefined })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when height is 0', () => {
|
||||
expect(
|
||||
assetAspectRatio({
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
lensModel: null,
|
||||
focalLength: null,
|
||||
fNumber: null,
|
||||
exposureTime: null,
|
||||
iso: null,
|
||||
exifImageWidth: 100,
|
||||
exifImageHeight: 0,
|
||||
city: null,
|
||||
state: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
15
lib/auth.ts
15
lib/auth.ts
@@ -12,10 +12,7 @@ import { getConfig, SubpageConfig } from './config';
|
||||
const TOKEN_EXPIRY_HOURS = 24;
|
||||
|
||||
function hmac(data: string): string {
|
||||
return crypto
|
||||
.createHmac('sha256', getConfig().immich.apiKey)
|
||||
.update(data)
|
||||
.digest('hex');
|
||||
return crypto.createHmac('sha256', getConfig().immich.apiKey).update(data).digest('hex');
|
||||
}
|
||||
|
||||
function authToken(slug: string, password: string): string {
|
||||
@@ -45,10 +42,7 @@ export function isProtected(slug: string): boolean {
|
||||
* 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 {
|
||||
export function authenticate(slug: string, password: string): string | null {
|
||||
const sp = findSubpageBySlug(slug);
|
||||
if (!sp?.password) return null;
|
||||
|
||||
@@ -77,10 +71,7 @@ export function isAuthenticated(
|
||||
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'),
|
||||
);
|
||||
return crypto.timingSafeEqual(Buffer.from(cookie, 'hex'), Buffer.from(expected, 'hex'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -6,16 +6,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { env } from './env';
|
||||
|
||||
// ── Env helpers ────────────────────────────────────────────────
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// ── Env helpers (now handled by lib/env.ts via Zod) ───────────
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +23,14 @@ export interface SubpageConfig {
|
||||
grid?: Partial<GridConfig>;
|
||||
}
|
||||
|
||||
/** Footer configuration for social links. */
|
||||
export interface FooterConfig {
|
||||
name?: string;
|
||||
instagram?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
/** Raw YAML structure (before validation). */
|
||||
interface GalleryYaml {
|
||||
hero?: string | string[];
|
||||
@@ -41,6 +42,12 @@ interface GalleryYaml {
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
footer?: {
|
||||
name?: string;
|
||||
instagram?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
};
|
||||
subpages?: Array<{
|
||||
name: string;
|
||||
albums: string[];
|
||||
@@ -126,6 +133,8 @@ export interface AppConfig {
|
||||
exifOnHover: boolean;
|
||||
/** Photo grid layout configuration. */
|
||||
grid: GridConfig;
|
||||
/** Footer social links config. */
|
||||
footer: FooterConfig | null;
|
||||
cacheTtl: number;
|
||||
rateLimitRpm: number;
|
||||
}
|
||||
@@ -137,13 +146,14 @@ let _config: AppConfig | null = null;
|
||||
export function getConfig(): AppConfig {
|
||||
if (_config) return _config;
|
||||
|
||||
const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, '');
|
||||
const apiKey = requireEnv('IMMICH_API_KEY');
|
||||
const apiUrl = env.IMMICH_API_URL;
|
||||
const apiKey = env.IMMICH_API_KEY;
|
||||
const gallery = loadGalleryYaml();
|
||||
|
||||
// Parse standalone albums
|
||||
const standaloneAlbumIds = (gallery.albums ?? [])
|
||||
.map((id) => validateUuid(id, 'gallery.yaml albums'));
|
||||
const standaloneAlbumIds = (gallery.albums ?? []).map((id) =>
|
||||
validateUuid(id, 'gallery.yaml albums'),
|
||||
);
|
||||
|
||||
if (standaloneAlbumIds.length === 0 && (!gallery.subpages || gallery.subpages.length === 0)) {
|
||||
throw new Error('gallery.yaml must define at least one album or subpage');
|
||||
@@ -152,21 +162,31 @@ export function getConfig(): AppConfig {
|
||||
// Parse subpages
|
||||
const subpages: SubpageConfig[] = (gallery.subpages ?? []).map((sp) => {
|
||||
if (!sp.name || !sp.albums || sp.albums.length === 0) {
|
||||
throw new Error(`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`);
|
||||
throw new Error(
|
||||
`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: sp.name,
|
||||
slug: slugify(sp.name),
|
||||
albumIds: sp.albums.map((id) => validateUuid(id, `subpage "${sp.name}"`)),
|
||||
password: sp.password,
|
||||
...(sp.grid ? {
|
||||
...(sp.grid
|
||||
? {
|
||||
grid: {
|
||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||
...(sp.grid.layout != null ? { layout: (sp.grid.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'] } : {}),
|
||||
...(sp.grid.layout != null
|
||||
? {
|
||||
layout: (sp.grid.layout === 'uniform'
|
||||
? 'uniform'
|
||||
: 'masonry') as GridConfig['layout'],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -182,11 +202,12 @@ export function getConfig(): AppConfig {
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||
subpages,
|
||||
siteTitle: process.env.SITE_TITLE || 'Gallery',
|
||||
siteSubtitle: process.env.SITE_SUBTITLE || '',
|
||||
siteTitle: env.SITE_TITLE,
|
||||
siteSubtitle: env.SITE_SUBTITLE,
|
||||
heroImages: gallery.hero
|
||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
|
||||
.map((id) => validateUuid(id, 'gallery.yaml hero'))
|
||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
|
||||
validateUuid(id, 'gallery.yaml hero'),
|
||||
)
|
||||
: [],
|
||||
exifOnHover: gallery.exifOnHover !== false,
|
||||
grid: {
|
||||
@@ -195,8 +216,16 @@ export function getConfig(): AppConfig {
|
||||
aspectRatio: gallery.grid?.aspectRatio ?? '1',
|
||||
layout: (gallery.grid?.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'],
|
||||
},
|
||||
cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000,
|
||||
rateLimitRpm: parseInt(process.env.RATE_LIMIT_RPM || '120', 10),
|
||||
footer: gallery.footer
|
||||
? {
|
||||
name: gallery.footer.name,
|
||||
instagram: gallery.footer.instagram,
|
||||
email: gallery.footer.email,
|
||||
website: gallery.footer.website,
|
||||
}
|
||||
: null,
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
};
|
||||
|
||||
return _config;
|
||||
|
||||
45
lib/env.ts
Normal file
45
lib/env.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Zod-validated environment variables.
|
||||
* Parsed once at startup — all env access should go through this module.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
/** Base URL of your Immich instance, e.g. https://immich.example.com */
|
||||
IMMICH_API_URL: z
|
||||
.string()
|
||||
.url('IMMICH_API_URL must be a valid URL')
|
||||
.transform((url) => url.replace(/\/+$/, '')),
|
||||
|
||||
/** Immich API key (from Admin → API Keys) */
|
||||
IMMICH_API_KEY: z.string().min(1, 'IMMICH_API_KEY is required'),
|
||||
|
||||
/** Public site title shown in the header */
|
||||
SITE_TITLE: z.string().default('Gallery'),
|
||||
|
||||
/** Subtitle displayed below the title */
|
||||
SITE_SUBTITLE: z.string().default(''),
|
||||
|
||||
/** Cache TTL in seconds */
|
||||
CACHE_TTL: z.coerce.number().int().min(0).default(300),
|
||||
|
||||
/** Rate limit: max requests per minute per IP */
|
||||
RATE_LIMIT_RPM: z.coerce.number().int().min(1).default(120),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
function parseEnv(): Env {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
const formatted = result.error.issues
|
||||
.map((issue) => ` ✗ ${issue.path.join('.')}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`\n❌ Environment validation failed:\n${formatted}\n`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/** Validated, typed environment variables. */
|
||||
export const env = parseEnv();
|
||||
@@ -232,7 +232,9 @@ class ImmichClient {
|
||||
/**
|
||||
* Get albums belonging to a specific subpage.
|
||||
*/
|
||||
async getSubpageAlbums(subpageSlug: string): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
|
||||
async getSubpageAlbums(
|
||||
subpageSlug: string,
|
||||
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
|
||||
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
|
||||
if (!subpage) return null;
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ 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;
|
||||
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) {
|
||||
@@ -37,6 +40,9 @@ export function thumbHashToDominantHex(base64: string): string {
|
||||
|
||||
if (total === 0) return '#888888';
|
||||
|
||||
const avg = (v: number) => Math.round(v / total).toString(16).padStart(2, '0');
|
||||
const avg = (v: number) =>
|
||||
Math.round(v / total)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return `#${avg(r)}${avg(g)}${avg(b)}`;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,7 @@ export function encodeAssetId(assetId: string): string {
|
||||
// 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(),
|
||||
]);
|
||||
const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]);
|
||||
// Compact: base64url(iv + ciphertext)
|
||||
return Buffer.concat([iv, encrypted]).toString('base64url');
|
||||
}
|
||||
@@ -48,10 +45,9 @@ export function decodeAssetId(token: string): string | null {
|
||||
const iv = data.subarray(0, 16);
|
||||
const encrypted = data.subarray(16);
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
const 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;
|
||||
|
||||
@@ -10,7 +10,10 @@ import type { ImmichAsset } from './immich';
|
||||
/**
|
||||
* Generate a public image proxy URL for an asset.
|
||||
*/
|
||||
export function imageUrl(assetId: string, size: 'thumbnail' | 'preview' | 'original' = 'preview'): string {
|
||||
export function imageUrl(
|
||||
assetId: string,
|
||||
size: 'thumbnail' | 'preview' | 'original' = 'preview',
|
||||
): string {
|
||||
return `/api/image/${encodeAssetId(assetId)}?size=${size}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
|
||||
1729
package-lock.json
generated
1729
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -6,7 +6,13 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest",
|
||||
"test:unit": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -14,15 +20,22 @@
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"thumbhash": "^0.1.1"
|
||||
"thumbhash": "^0.1.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"typescript": "^5"
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ Build a public-facing web gallery that connects to a private Immich server via i
|
||||
The Lightbox Server sits between visitors and Immich. It proxies images, caches API responses, and holds the album selection config. The Immich server stays private and serves album data and original images only to the Lightbox Server.
|
||||
|
||||
**Stack:**
|
||||
|
||||
- **Next.js 15** (App Router) — SSR for fast first paint, API routes for proxying
|
||||
- **TypeScript** — End-to-end type safety
|
||||
- **CSS Modules + CSS custom properties** — Clean, maintainable dark-theme styling
|
||||
|
||||
@@ -137,7 +137,7 @@ Upgrade the developer tooling and quality infrastructure for immich-lightbox. Th
|
||||
## Suggested Implementation Order
|
||||
|
||||
| Phase | Feature | Dependency |
|
||||
|-------|---------|------------|
|
||||
| ----- | --------------------- | ------------------------------ |
|
||||
| 1 | Prettier + ESLint | None — clean baseline first |
|
||||
| 2 | Vitest unit tests | None — tests existing code |
|
||||
| 3 | Zod env validation | Unit tests can verify schemas |
|
||||
|
||||
27
playwright.config.ts
Normal file
27
playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
19
vitest.config.ts
Normal file
19
vitest.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['lib/__tests__/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['lib/**/*.ts'],
|
||||
exclude: ['lib/__tests__/**'],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user