security: implement AUTH_SECRET and secure map/exif API routes
This commit is contained in:
@@ -22,6 +22,10 @@ CACHE_TTL=300
|
|||||||
# Rate limit for image proxy (integer >= 1, requests per minute per IP, default: 120)
|
# Rate limit for image proxy (integer >= 1, requests per minute per IP, default: 120)
|
||||||
# RATE_LIMIT_RPM=120
|
# RATE_LIMIT_RPM=120
|
||||||
|
|
||||||
|
# Secret for signing auth cookies and encrypting asset tokens (string, min 16 chars).
|
||||||
|
# If not provided, IMMICH_API_KEY will be used as a fallback (not recommended).
|
||||||
|
# AUTH_SECRET=your-super-long-random-secret-here
|
||||||
|
|
||||||
# ── Album configuration ──────────────────────────────────────────
|
# ── Album configuration ──────────────────────────────────────────
|
||||||
# Albums, subpages, hero image, and passwords are defined in content/gallery.yaml
|
# Albums, subpages, hero image, and passwords are defined in content/gallery.yaml
|
||||||
# See content/gallery.yaml.example for the full reference.
|
# See content/gallery.yaml.example for the full reference.
|
||||||
|
|||||||
@@ -136,8 +136,9 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
|||||||
{assets.map((asset, index) => (
|
{assets.map((asset, index) => (
|
||||||
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
|
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
|
||||||
<div
|
<div
|
||||||
className={`photo-grid__item${layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
|
className={`photo-grid__item${
|
||||||
}`}
|
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
|
||||||
|
}`}
|
||||||
onClick={() => openLightbox(index)}
|
onClick={() => openLightbox(index)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
|||||||
@@ -24,15 +24,22 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exif = asset.exifInfo;
|
const exif = asset.exifInfo;
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
make: exif.make,
|
{
|
||||||
model: exif.model,
|
make: exif.make,
|
||||||
lensModel: exif.lensModel,
|
model: exif.model,
|
||||||
focalLength: exif.focalLength,
|
lensModel: exif.lensModel,
|
||||||
fNumber: exif.fNumber,
|
focalLength: exif.focalLength,
|
||||||
exposureTime: exif.exposureTime,
|
fNumber: exif.fNumber,
|
||||||
iso: exif.iso,
|
exposureTime: exif.exposureTime,
|
||||||
city: exif.city,
|
iso: exif.iso,
|
||||||
country: exif.country,
|
city: exif.city,
|
||||||
});
|
country: exif.country,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=3600',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,39 +4,56 @@
|
|||||||
* Cover asset IDs are encrypted for security.
|
* Cover asset IDs are encrypted for security.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
import { immich } from '@/lib/immich';
|
import { immich } from '@/lib/immich';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { imageUrl } from '@/lib/urls';
|
import { imageUrl } from '@/lib/urls';
|
||||||
|
import { isAuthenticated } from '@/lib/auth';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(_request: NextRequest) {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
if (!config.map) {
|
if (!config.map) {
|
||||||
return NextResponse.json({ error: 'Map is not enabled' }, { status: 404 });
|
return NextResponse.json({ error: 'Map is not enabled' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const locations = await immich.getMapData();
|
const cookieStore = await cookies();
|
||||||
|
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
||||||
|
|
||||||
// Encrypt cover asset IDs and convert to public-safe image URLs
|
const locations = await immich.getMapData();
|
||||||
const publicLocations = locations.map((loc) => ({
|
|
||||||
|
// Filter locations and albums based on auth
|
||||||
|
const publicLocations = locations
|
||||||
|
.map((loc) => {
|
||||||
|
// Only keep albums the user is allowed to see
|
||||||
|
const allowedAlbums = loc.albums.filter((a) => {
|
||||||
|
if (!a.subpageSlug) return true; // Standalone albums are public
|
||||||
|
return isAuthenticated(a.subpageSlug, getCookie);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allowedAlbums.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
city: loc.city,
|
city: loc.city,
|
||||||
country: loc.country,
|
country: loc.country,
|
||||||
lat: loc.lat,
|
lat: loc.lat,
|
||||||
lng: loc.lng,
|
lng: loc.lng,
|
||||||
photoCount: loc.photoCount,
|
photoCount: loc.photoCount, // Note: This count might be slightly off if some photos are in hidden albums
|
||||||
coverUrl: imageUrl(loc.coverAssetId, 'thumbnail'),
|
coverUrl: imageUrl(loc.coverAssetId, 'thumbnail'),
|
||||||
albums: loc.albums.map((a) => ({
|
albums: allowedAlbums.map((a) => ({
|
||||||
name: a.name,
|
name: a.name,
|
||||||
url: a.subpageSlug ? `/${a.subpageSlug}/${a.slug}` : `/${a.slug}`,
|
url: a.subpageSlug ? `/${a.subpageSlug}/${a.slug}` : `/${a.slug}`,
|
||||||
})),
|
})),
|
||||||
}));
|
};
|
||||||
|
})
|
||||||
|
.filter((loc) => loc !== null);
|
||||||
|
|
||||||
return NextResponse.json(publicLocations, {
|
return NextResponse.json(publicLocations, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${Math.floor(config.cacheTtl / 1000)}, stale-while-revalidate=60`,
|
'Cache-Control': `private, no-cache, no-store, must-revalidate`, // Don't cache personalized map data publicly
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,34 +13,34 @@ import './map.css';
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Map',
|
title: 'Map',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MapPage() {
|
export default function MapPage() {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
if (!config.map) {
|
if (!config.map) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Leaflet CSS from CDN */}
|
{/* Leaflet CSS from CDN */}
|
||||||
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
|
{}
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
crossOrigin=""
|
crossOrigin=""
|
||||||
/>
|
/>
|
||||||
<div className="map-page">
|
<div className="map-page">
|
||||||
<div className="map-page__header">
|
<div className="map-page__header">
|
||||||
<BackLink href="/" label="Back to Gallery" />
|
<BackLink href="/" label="Back to Gallery" />
|
||||||
<h1 className="map-page__title">Map</h1>
|
<h1 className="map-page__title">Map</h1>
|
||||||
<p className="map-page__subtitle">Where the photos were taken</p>
|
<p className="map-page__subtitle">Where the photos were taken</p>
|
||||||
</div>
|
</div>
|
||||||
<MapView />
|
<MapView />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
10
app/page.tsx
10
app/page.tsx
@@ -80,9 +80,7 @@ export default async function HomePage() {
|
|||||||
<nav className="hero__nav hero__nav--indexed">
|
<nav className="hero__nav hero__nav--indexed">
|
||||||
{subpages.map((sp, i) => (
|
{subpages.map((sp, i) => (
|
||||||
<Link key={sp.slug} href={`/${sp.slug}`} className="hero__nav-link">
|
<Link key={sp.slug} href={`/${sp.slug}`} className="hero__nav-link">
|
||||||
<span className="hero__nav-index">
|
<span className="hero__nav-index">{String(i + 1).padStart(2, '0')}</span>
|
||||||
{String(i + 1).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
{sp.name}
|
{sp.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -127,11 +125,7 @@ export default async function HomePage() {
|
|||||||
<FadeIn delay={200}>
|
<FadeIn delay={200}>
|
||||||
<nav className="hero__thumbnail-strip">
|
<nav className="hero__thumbnail-strip">
|
||||||
{allEntries.map((entry) => (
|
{allEntries.map((entry) => (
|
||||||
<Link
|
<Link key={entry.slug} href={`/${entry.slug}`} className="hero__thumbnail-item">
|
||||||
key={entry.slug}
|
|
||||||
href={`/${entry.slug}`}
|
|
||||||
className="hero__thumbnail-item"
|
|
||||||
>
|
|
||||||
<span className="hero__thumbnail-label">{entry.name}</span>
|
<span className="hero__thumbnail-label">{entry.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
|
||||||
|
|
||||||
const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph', 'botanica'];
|
const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph', 'botanica'];
|
||||||
const FRAMES = ['none', 'passepartout', 'shadow'];
|
const FRAMES = ['none', 'passepartout', 'shadow'];
|
||||||
@@ -20,426 +20,429 @@ const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-
|
|||||||
|
|
||||||
// Font stacks for each preset (must match THEME_PRESETS in config.ts)
|
// Font stacks for each preset (must match THEME_PRESETS in config.ts)
|
||||||
const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = {
|
const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = {
|
||||||
studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
|
studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
|
||||||
minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
|
minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
|
||||||
editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'IBM Plex Mono' },
|
editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'IBM Plex Mono' },
|
||||||
classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'EB Garamond' },
|
classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'EB Garamond' },
|
||||||
noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'IBM Plex Mono' },
|
noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'IBM Plex Mono' },
|
||||||
monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'JetBrains Mono' },
|
monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'JetBrains Mono' },
|
||||||
botanica: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Lora' },
|
botanica: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Lora' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const PRESET_ACCENTS: Record<string, string> = {
|
const PRESET_ACCENTS: Record<string, string> = {
|
||||||
studio: '#e60012',
|
studio: '#e60012',
|
||||||
minimal: '#000000',
|
minimal: '#000000',
|
||||||
editorial: '#8B2500',
|
editorial: '#8B2500',
|
||||||
classic: '#c49a3c',
|
classic: '#c49a3c',
|
||||||
noir: '#ff6b35',
|
noir: '#ff6b35',
|
||||||
monograph: '#333333',
|
monograph: '#333333',
|
||||||
botanica: '#4a7c59',
|
botanica: '#4a7c59',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getAttr(attr: string): string {
|
function getAttr(attr: string): string {
|
||||||
return document.documentElement.getAttribute(attr) ?? '';
|
return document.documentElement.getAttribute(attr) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAttr(attr: string, value: string) {
|
function setAttr(attr: string, value: string) {
|
||||||
document.documentElement.setAttribute(attr, value);
|
document.documentElement.setAttribute(attr, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVar(name: string, value: string) {
|
function setVar(name: string, value: string) {
|
||||||
document.documentElement.style.setProperty(name, value);
|
document.documentElement.style.setProperty(name, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevToolbar() {
|
export function DevToolbar() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [preset, setPreset] = useState('');
|
const [preset, setPreset] = useState('');
|
||||||
const [frame, setFrame] = useState('');
|
const [frame, setFrame] = useState('');
|
||||||
const [theme, setTheme] = useState('');
|
const [theme, setTheme] = useState('');
|
||||||
const [grain, setGrain] = useState('');
|
const [grain, setGrain] = useState('');
|
||||||
const [copiedYaml, setCopiedYaml] = useState('');
|
const [copiedYaml, setCopiedYaml] = useState('');
|
||||||
const [gridCols, setGridCols] = useState(3);
|
const [gridCols, setGridCols] = useState(3);
|
||||||
const [gridGap, setGridGap] = useState(12);
|
const [gridGap, setGridGap] = useState(12);
|
||||||
|
|
||||||
// Read initial values from DOM
|
// Read initial values from DOM
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setPreset(getAttr('data-preset'));
|
setPreset(getAttr('data-preset')); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
setFrame(getAttr('data-photo-frame'));
|
setFrame(getAttr('data-photo-frame'));
|
||||||
setTheme(getAttr('data-theme') || 'dark');
|
setTheme(getAttr('data-theme') || 'dark');
|
||||||
setGrain(getAttr('data-grain'));
|
setGrain(getAttr('data-grain'));
|
||||||
// Read grid vars
|
// Read grid vars
|
||||||
const style = getComputedStyle(document.documentElement);
|
const style = getComputedStyle(document.documentElement);
|
||||||
const cols = parseInt(style.getPropertyValue('--grid-columns') || '3', 10);
|
const cols = parseInt(style.getPropertyValue('--grid-columns') || '3', 10);
|
||||||
const gap = parseInt(style.getPropertyValue('--grid-gap') || '12', 10);
|
const gap = parseInt(style.getPropertyValue('--grid-gap') || '12', 10);
|
||||||
if (!isNaN(cols)) setGridCols(cols);
|
if (!isNaN(cols)) setGridCols(cols);
|
||||||
if (!isNaN(gap)) setGridGap(gap);
|
if (!isNaN(gap)) setGridGap(gap);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const switchPreset = useCallback((p: string) => {
|
const switchPreset = useCallback((p: string) => {
|
||||||
setAttr('data-preset', p);
|
setAttr('data-preset', p);
|
||||||
setPreset(p);
|
setPreset(p);
|
||||||
// Update accent + fonts
|
// Update accent + fonts
|
||||||
const accent = PRESET_ACCENTS[p] || '#e60012';
|
const accent = PRESET_ACCENTS[p] || '#e60012';
|
||||||
setVar('--accent', accent);
|
setVar('--accent', accent);
|
||||||
setVar('--accent-dim', `${accent}1f`);
|
setVar('--accent-dim', `${accent}1f`);
|
||||||
const fonts = PRESET_FONTS[p];
|
const fonts = PRESET_FONTS[p];
|
||||||
if (fonts) {
|
if (fonts) {
|
||||||
setVar('--font-serif', `'${fonts.heading}', Georgia, 'Times New Roman', serif`);
|
setVar('--font-serif', `'${fonts.heading}', Georgia, 'Times New Roman', serif`);
|
||||||
setVar('--font-sans', `'${fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`);
|
setVar(
|
||||||
setVar('--font-caption', `'${fonts.caption}', Georgia, serif`);
|
'--font-sans',
|
||||||
// Dynamically load Google Fonts if not already loaded
|
`'${fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
||||||
const families = [fonts.heading, fonts.body, fonts.caption]
|
);
|
||||||
.filter((f, i, a) => a.indexOf(f) === i)
|
setVar('--font-caption', `'${fonts.caption}', Georgia, serif`);
|
||||||
.map((f) => `family=${f.replace(/\s+/g, '+')}:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400`)
|
// Dynamically load Google Fonts if not already loaded
|
||||||
.join('&');
|
const families = [fonts.heading, fonts.body, fonts.caption]
|
||||||
const href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
.filter((f, i, a) => a.indexOf(f) === i)
|
||||||
// Check if already loaded
|
.map(
|
||||||
const existing = document.querySelector(`link[href="${href}"]`);
|
(f) => `family=${f.replace(/\s+/g, '+')}:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400`,
|
||||||
if (!existing) {
|
)
|
||||||
const link = document.createElement('link');
|
.join('&');
|
||||||
link.rel = 'stylesheet';
|
const href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||||
link.href = href;
|
// Check if already loaded
|
||||||
document.head.appendChild(link);
|
const existing = document.querySelector(`link[href="${href}"]`);
|
||||||
}
|
if (!existing) {
|
||||||
}
|
const link = document.createElement('link');
|
||||||
}, []);
|
link.rel = 'stylesheet';
|
||||||
|
link.href = href;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const switchFrame = useCallback((f: string) => {
|
const switchFrame = useCallback((f: string) => {
|
||||||
setAttr('data-photo-frame', f);
|
setAttr('data-photo-frame', f);
|
||||||
setFrame(f);
|
setFrame(f);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const switchTheme = useCallback((t: string) => {
|
const switchTheme = useCallback((t: string) => {
|
||||||
setAttr('data-theme', t);
|
setAttr('data-theme', t);
|
||||||
setTheme(t);
|
setTheme(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleGrain = useCallback(() => {
|
const toggleGrain = useCallback(() => {
|
||||||
const next = grain === 'true' ? 'false' : 'true';
|
const next = grain === 'true' ? 'false' : 'true';
|
||||||
setAttr('data-grain', next);
|
setAttr('data-grain', next);
|
||||||
setGrain(next);
|
setGrain(next);
|
||||||
}, [grain]);
|
}, [grain]);
|
||||||
|
|
||||||
const updateGridCols = useCallback((cols: number) => {
|
const updateGridCols = useCallback((cols: number) => {
|
||||||
setGridCols(cols);
|
setGridCols(cols);
|
||||||
// Update all photo-grid elements
|
// Update all photo-grid elements
|
||||||
document.querySelectorAll<HTMLElement>('.photo-grid').forEach((el) => {
|
document.querySelectorAll<HTMLElement>('.photo-grid').forEach((el) => {
|
||||||
el.style.setProperty('--grid-columns', String(cols));
|
el.style.setProperty('--grid-columns', String(cols));
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateGridGap = useCallback((gap: number) => {
|
const updateGridGap = useCallback((gap: number) => {
|
||||||
setGridGap(gap);
|
setGridGap(gap);
|
||||||
document.querySelectorAll<HTMLElement>('.photo-grid').forEach((el) => {
|
document.querySelectorAll<HTMLElement>('.photo-grid').forEach((el) => {
|
||||||
el.style.setProperty('--grid-gap', `${gap}px`);
|
el.style.setProperty('--grid-gap', `${gap}px`);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const copyYaml = useCallback(
|
const copyYaml = useCallback((section: string, yaml: string) => {
|
||||||
(section: string, yaml: string) => {
|
navigator.clipboard.writeText(yaml);
|
||||||
navigator.clipboard.writeText(yaml);
|
setCopiedYaml(section);
|
||||||
setCopiedYaml(section);
|
setTimeout(() => setCopiedYaml(''), 1500);
|
||||||
setTimeout(() => setCopiedYaml(''), 1500);
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentYaml = `theme:
|
const currentYaml = `theme:
|
||||||
preset: ${preset}
|
preset: ${preset}
|
||||||
photoFrame: ${frame}
|
photoFrame: ${frame}
|
||||||
grain: ${grain === 'true'}`;
|
grain: ${grain === 'true'}`;
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl/Cmd + Shift + T
|
// Keyboard shortcut: Ctrl/Cmd + Shift + T
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'T') {
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'T') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen((o) => !o);
|
setOpen((o) => !o);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', handler);
|
document.addEventListener('keydown', handler);
|
||||||
return () => document.removeEventListener('keydown', handler);
|
return () => document.removeEventListener('keydown', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Floating Toggle Button ─────────────────────── */}
|
{/* ── Floating Toggle Button ─────────────────────── */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
aria-label="Dev Toolbar"
|
aria-label="Dev Toolbar"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
left: 20,
|
left: 20,
|
||||||
zIndex: 99999,
|
zIndex: 99999,
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
border: '2px solid rgba(255,255,255,0.2)',
|
border: '2px solid rgba(255,255,255,0.2)',
|
||||||
background: open
|
background: open ? 'linear-gradient(135deg, #f43f5e, #a855f7)' : 'rgba(30,30,30,0.9)',
|
||||||
? 'linear-gradient(135deg, #f43f5e, #a855f7)'
|
backdropFilter: 'blur(12px)',
|
||||||
: 'rgba(30,30,30,0.9)',
|
color: '#fff',
|
||||||
backdropFilter: 'blur(12px)',
|
fontSize: 18,
|
||||||
color: '#fff',
|
cursor: 'pointer',
|
||||||
fontSize: 18,
|
display: 'flex',
|
||||||
cursor: 'pointer',
|
alignItems: 'center',
|
||||||
display: 'flex',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
transition: 'all 0.2s ease',
|
||||||
justifyContent: 'center',
|
boxShadow: '0 4px 24px rgba(0,0,0,0.4)',
|
||||||
transition: 'all 0.2s ease',
|
}}
|
||||||
boxShadow: '0 4px 24px rgba(0,0,0,0.4)',
|
>
|
||||||
}}
|
{open ? '✕' : '🎨'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* ── Panel ──────────────────────────────────────── */}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 76,
|
||||||
|
left: 20,
|
||||||
|
zIndex: 99998,
|
||||||
|
width: 300,
|
||||||
|
maxHeight: 'calc(100vh - 120px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: 'rgba(18,18,22,0.96)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
borderRadius: 16,
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
boxShadow: '0 8px 48px rgba(0,0,0,0.6)',
|
||||||
|
color: '#e4e4e7',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Header ──────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px 10px',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 13, letterSpacing: '0.02em' }}>
|
||||||
|
🎨 Theme Builder
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#71717a',
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{open ? '✕' : '🎨'}
|
DEV ONLY
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Preset ──────────────────────────── */}
|
||||||
|
<Section title="Preset">
|
||||||
|
<PillGrid items={PRESETS} active={preset} onSelect={switchPreset} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Dark / Light ────────────────────── */}
|
||||||
|
<Section title="Mode">
|
||||||
|
<PillGrid items={['dark', 'light']} active={theme} onSelect={switchTheme} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Photo Frame ─────────────────────── */}
|
||||||
|
<Section title="Photo Frame">
|
||||||
|
<PillGrid items={FRAMES} active={frame} onSelect={switchFrame} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Grain ───────────────────────────── */}
|
||||||
|
<Section title="Film Grain">
|
||||||
|
<button
|
||||||
|
onClick={toggleGrain}
|
||||||
|
style={{
|
||||||
|
...pillStyle,
|
||||||
|
background:
|
||||||
|
grain === 'true'
|
||||||
|
? 'linear-gradient(135deg, #f43f5e, #a855f7)'
|
||||||
|
: 'rgba(255,255,255,0.06)',
|
||||||
|
color: grain === 'true' ? '#fff' : '#a1a1aa',
|
||||||
|
border:
|
||||||
|
grain === 'true'
|
||||||
|
? '1px solid rgba(255,255,255,0.2)'
|
||||||
|
: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{grain === 'true' ? '● On' : '○ Off'}
|
||||||
</button>
|
</button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* ── Panel ──────────────────────────────────────── */}
|
{/* ── Grid Controls ───────────────────── */}
|
||||||
{open && (
|
<Section title="Grid">
|
||||||
<div
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
style={{
|
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>
|
||||||
position: 'fixed',
|
Cols: {gridCols}
|
||||||
bottom: 76,
|
</label>
|
||||||
left: 20,
|
<input
|
||||||
zIndex: 99998,
|
type="range"
|
||||||
width: 300,
|
min={1}
|
||||||
maxHeight: 'calc(100vh - 120px)',
|
max={6}
|
||||||
overflowY: 'auto',
|
value={gridCols}
|
||||||
background: 'rgba(18,18,22,0.96)',
|
onChange={(e) => updateGridCols(Number(e.target.value))}
|
||||||
backdropFilter: 'blur(20px)',
|
style={{ flex: 1, accentColor: '#a855f7' }}
|
||||||
borderRadius: 16,
|
/>
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
</div>
|
||||||
boxShadow: '0 8px 48px rgba(0,0,0,0.6)',
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 8 }}>
|
||||||
color: '#e4e4e7',
|
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
Gap: {gridGap}px
|
||||||
fontSize: 12,
|
</label>
|
||||||
padding: 0,
|
<input
|
||||||
}}
|
type="range"
|
||||||
>
|
min={0}
|
||||||
{/* ── Header ──────────────────────────── */}
|
max={32}
|
||||||
<div
|
value={gridGap}
|
||||||
style={{
|
onChange={(e) => updateGridGap(Number(e.target.value))}
|
||||||
padding: '14px 16px 10px',
|
style={{ flex: 1, accentColor: '#a855f7' }}
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
/>
|
||||||
display: 'flex',
|
</div>
|
||||||
alignItems: 'center',
|
</Section>
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: 700, fontSize: 13, letterSpacing: '0.02em' }}>
|
|
||||||
🎨 Theme Builder
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
color: '#71717a',
|
|
||||||
background: 'rgba(255,255,255,0.06)',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
DEV ONLY
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Preset ──────────────────────────── */}
|
{/* ── Hero Style (info only) ──────────── */}
|
||||||
<Section title="Preset">
|
<Section title="Hero Style (server)">
|
||||||
<PillGrid
|
<PillGrid
|
||||||
items={PRESETS}
|
items={HERO_STYLES}
|
||||||
active={preset}
|
active=""
|
||||||
onSelect={switchPreset}
|
onSelect={(h) => {
|
||||||
/>
|
copyYaml('hero', `theme:\n heroStyle: ${h}`);
|
||||||
</Section>
|
}}
|
||||||
|
/>
|
||||||
|
{copiedYaml === 'hero' && <CopyBadge />}
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* ── Dark / Light ────────────────────── */}
|
{/* ── Grid Layout (info only) ─────────── */}
|
||||||
<Section title="Mode">
|
<Section title="Grid Layout (server)">
|
||||||
<PillGrid
|
<PillGrid
|
||||||
items={['dark', 'light']}
|
items={GRID_LAYOUTS}
|
||||||
active={theme}
|
active=""
|
||||||
onSelect={switchTheme}
|
onSelect={(l) => {
|
||||||
/>
|
copyYaml('layout', `grid:\n layout: ${l}`);
|
||||||
</Section>
|
}}
|
||||||
|
/>
|
||||||
|
{copiedYaml === 'layout' && <CopyBadge />}
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* ── Photo Frame ─────────────────────── */}
|
{/* ── Export YAML ──────────────────────── */}
|
||||||
<Section title="Photo Frame">
|
<div
|
||||||
<PillGrid
|
style={{
|
||||||
items={FRAMES}
|
padding: '12px 16px 16px',
|
||||||
active={frame}
|
borderTop: '1px solid rgba(255,255,255,0.08)',
|
||||||
onSelect={switchFrame}
|
}}
|
||||||
/>
|
>
|
||||||
</Section>
|
<button
|
||||||
|
onClick={() => copyYaml('all', currentYaml)}
|
||||||
{/* ── Grain ───────────────────────────── */}
|
style={{
|
||||||
<Section title="Film Grain">
|
width: '100%',
|
||||||
<button
|
padding: '10px 0',
|
||||||
onClick={toggleGrain}
|
borderRadius: 10,
|
||||||
style={{
|
border: 'none',
|
||||||
...pillStyle,
|
background:
|
||||||
background: grain === 'true'
|
copiedYaml === 'all' ? '#22c55e' : 'linear-gradient(135deg, #a855f7, #6366f1)',
|
||||||
? 'linear-gradient(135deg, #f43f5e, #a855f7)'
|
color: '#fff',
|
||||||
: 'rgba(255,255,255,0.06)',
|
fontWeight: 600,
|
||||||
color: grain === 'true' ? '#fff' : '#a1a1aa',
|
fontSize: 12,
|
||||||
border: grain === 'true' ? '1px solid rgba(255,255,255,0.2)' : '1px solid rgba(255,255,255,0.06)',
|
cursor: 'pointer',
|
||||||
}}
|
transition: 'all 0.15s ease',
|
||||||
>
|
letterSpacing: '0.02em',
|
||||||
{grain === 'true' ? '● On' : '○ Off'}
|
}}
|
||||||
</button>
|
>
|
||||||
</Section>
|
{copiedYaml === 'all' ? '✓ Copied to clipboard' : 'Copy YAML to clipboard'}
|
||||||
|
</button>
|
||||||
{/* ── Grid Controls ───────────────────── */}
|
</div>
|
||||||
<Section title="Grid">
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
)}
|
||||||
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>Cols: {gridCols}</label>
|
</>
|
||||||
<input
|
);
|
||||||
type="range"
|
|
||||||
min={1}
|
|
||||||
max={6}
|
|
||||||
value={gridCols}
|
|
||||||
onChange={(e) => updateGridCols(Number(e.target.value))}
|
|
||||||
style={{ flex: 1, accentColor: '#a855f7' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 8 }}>
|
|
||||||
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>Gap: {gridGap}px</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={32}
|
|
||||||
value={gridGap}
|
|
||||||
onChange={(e) => updateGridGap(Number(e.target.value))}
|
|
||||||
style={{ flex: 1, accentColor: '#a855f7' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* ── Hero Style (info only) ──────────── */}
|
|
||||||
<Section title="Hero Style (server)">
|
|
||||||
<PillGrid items={HERO_STYLES} active="" onSelect={(h) => {
|
|
||||||
copyYaml('hero', `theme:\n heroStyle: ${h}`);
|
|
||||||
}} />
|
|
||||||
{copiedYaml === 'hero' && <CopyBadge />}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* ── Grid Layout (info only) ─────────── */}
|
|
||||||
<Section title="Grid Layout (server)">
|
|
||||||
<PillGrid items={GRID_LAYOUTS} active="" onSelect={(l) => {
|
|
||||||
copyYaml('layout', `grid:\n layout: ${l}`);
|
|
||||||
}} />
|
|
||||||
{copiedYaml === 'layout' && <CopyBadge />}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* ── Export YAML ──────────────────────── */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '12px 16px 16px',
|
|
||||||
borderTop: '1px solid rgba(255,255,255,0.08)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => copyYaml('all', currentYaml)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px 0',
|
|
||||||
borderRadius: 10,
|
|
||||||
border: 'none',
|
|
||||||
background: copiedYaml === 'all'
|
|
||||||
? '#22c55e'
|
|
||||||
: 'linear-gradient(135deg, #a855f7, #6366f1)',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
letterSpacing: '0.02em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copiedYaml === 'all' ? '✓ Copied to clipboard' : 'Copy YAML to clipboard'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sub-components ───────────────────────────────── */
|
/* ── Sub-components ───────────────────────────────── */
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px 16px 8px' }}>
|
<div style={{ padding: '12px 16px 8px' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.1em',
|
letterSpacing: '0.1em',
|
||||||
color: '#71717a',
|
color: '#71717a',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pillStyle: React.CSSProperties = {
|
const pillStyle: React.CSSProperties = {
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.12s ease',
|
transition: 'all 0.12s ease',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
};
|
};
|
||||||
|
|
||||||
function PillGrid({
|
function PillGrid({
|
||||||
items,
|
items,
|
||||||
active,
|
active,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
items: string[];
|
items: string[];
|
||||||
active: string;
|
active: string;
|
||||||
onSelect: (item: string) => void;
|
onSelect: (item: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
onClick={() => onSelect(item)}
|
onClick={() => onSelect(item)}
|
||||||
style={{
|
style={{
|
||||||
...pillStyle,
|
...pillStyle,
|
||||||
background:
|
background:
|
||||||
item === active
|
item === active
|
||||||
? 'linear-gradient(135deg, #a855f7, #6366f1)'
|
? 'linear-gradient(135deg, #a855f7, #6366f1)'
|
||||||
: 'rgba(255,255,255,0.06)',
|
: 'rgba(255,255,255,0.06)',
|
||||||
color: item === active ? '#fff' : '#a1a1aa',
|
color: item === active ? '#fff' : '#a1a1aa',
|
||||||
border:
|
border:
|
||||||
item === active
|
item === active
|
||||||
? '1px solid rgba(255,255,255,0.2)'
|
? '1px solid rgba(255,255,255,0.2)'
|
||||||
: '1px solid rgba(255,255,255,0.06)',
|
: '1px solid rgba(255,255,255,0.06)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyBadge() {
|
function CopyBadge() {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✓ YAML copied — paste into gallery.yaml
|
✓ YAML copied — paste into gallery.yaml
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,79 +8,80 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
interface MapLocationPublic {
|
interface MapLocationPublic {
|
||||||
city: string;
|
city: string;
|
||||||
country: string;
|
country: string;
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
photoCount: number;
|
photoCount: number;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
albums: { name: string; url: string }[];
|
albums: { name: string; url: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapView() {
|
export function MapView() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<unknown>(null);
|
const mapRef = useRef<unknown>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
// Dynamically import Leaflet (client-only)
|
// Dynamically import Leaflet (client-only)
|
||||||
const L = (await import('leaflet')).default;
|
const L = (await import('leaflet')).default;
|
||||||
|
|
||||||
if (cancelled || !containerRef.current || mapRef.current) return;
|
if (cancelled || !containerRef.current || mapRef.current) return;
|
||||||
|
|
||||||
// Fetch map data
|
// Fetch map data
|
||||||
const res = await fetch('/api/map');
|
const res = await fetch('/api/map');
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError('Failed to load map data');
|
setError('Failed to load map data');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const locations: MapLocationPublic[] = await res.json();
|
const locations: MapLocationPublic[] = await res.json();
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
const map = L.map(containerRef.current, {
|
const map = L.map(containerRef.current, {
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
});
|
});
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
// Dark-themed tiles (CartoDB Dark Matter)
|
// Dark-themed tiles (CartoDB Dark Matter)
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
attribution:
|
||||||
subdomains: 'abcd',
|
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
maxZoom: 19,
|
subdomains: 'abcd',
|
||||||
}).addTo(map);
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
if (locations.length === 0) {
|
if (locations.length === 0) {
|
||||||
// No geotagged photos — show world view
|
// No geotagged photos — show world view
|
||||||
map.setView([20, 0], 2);
|
map.setView([20, 0], 2);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create markers with custom HTML icons
|
// Create markers with custom HTML icons
|
||||||
const markers: L.Marker[] = [];
|
const markers: L.Marker[] = [];
|
||||||
|
|
||||||
for (const loc of locations) {
|
for (const loc of locations) {
|
||||||
const icon = L.divIcon({
|
const icon = L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
html: `<div class="map-marker">${loc.photoCount}</div>`,
|
html: `<div class="map-marker">${loc.photoCount}</div>`,
|
||||||
iconSize: [32, 32],
|
iconSize: [32, 32],
|
||||||
iconAnchor: [16, 16],
|
iconAnchor: [16, 16],
|
||||||
popupAnchor: [0, -20],
|
popupAnchor: [0, -20],
|
||||||
});
|
});
|
||||||
|
|
||||||
const albumLinks = loc.albums
|
const albumLinks = loc.albums
|
||||||
.map((a) => `<a href="${a.url}" class="map-popup__album-link">${a.name} →</a>`)
|
.map((a) => `<a href="${a.url}" class="map-popup__album-link">${a.name} →</a>`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
const popupHtml = `
|
const popupHtml = `
|
||||||
<div class="map-popup">
|
<div class="map-popup">
|
||||||
<img src="${loc.coverUrl}" alt="${loc.city}" class="map-popup__cover" loading="lazy" />
|
<img src="${loc.coverUrl}" alt="${loc.city}" class="map-popup__cover" loading="lazy" />
|
||||||
<div class="map-popup__body">
|
<div class="map-popup__body">
|
||||||
@@ -92,61 +93,61 @@ export function MapView() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const marker = L.marker([loc.lat, loc.lng], { icon })
|
const marker = L.marker([loc.lat, loc.lng], { icon })
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
.bindPopup(popupHtml, { maxWidth: 260, minWidth: 200 });
|
.bindPopup(popupHtml, { maxWidth: 260, minWidth: 200 });
|
||||||
|
|
||||||
markers.push(marker);
|
markers.push(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fit bounds to show all markers
|
// Fit bounds to show all markers
|
||||||
if (markers.length > 0) {
|
if (markers.length > 0) {
|
||||||
const group = L.featureGroup(markers);
|
const group = L.featureGroup(markers);
|
||||||
map.fitBounds(group.getBounds().pad(0.15));
|
map.fitBounds(group.getBounds().pad(0.15));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force Leaflet to recalculate tile grid
|
// Force Leaflet to recalculate tile grid
|
||||||
setTimeout(() => map.invalidateSize(), 100);
|
setTimeout(() => map.invalidateSize(), 100);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
|
||||||
|
|
||||||
init().catch((err) => {
|
|
||||||
console.error('[Map] Init error:', err);
|
|
||||||
if (!cancelled) {
|
|
||||||
setError('Failed to initialize map');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (mapRef.current) {
|
|
||||||
(mapRef.current as L.Map).remove();
|
|
||||||
mapRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="map-container__loading">
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init().catch((err) => {
|
||||||
|
console.error('[Map] Init error:', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setError('Failed to initialize map');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (mapRef.current) {
|
||||||
|
(mapRef.current as L.Map).remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div className="map-container__loading">
|
||||||
{loading && (
|
<p>{error}</p>
|
||||||
<div className="map-container__loading" style={{ position: 'absolute', inset: 0, zIndex: 1000 }}>
|
</div>
|
||||||
<p>Loading map…</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="map-container"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{loading && (
|
||||||
|
<div
|
||||||
|
className="map-container__loading"
|
||||||
|
style={{ position: 'absolute', inset: 0, zIndex: 1000 }}
|
||||||
|
>
|
||||||
|
<p>Loading map…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={containerRef} className="map-container" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,75 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Photo Grid', () => {
|
test.describe('Photo Grid', () => {
|
||||||
// Use a known public subpage that has photos
|
// Use a known public subpage that has photos
|
||||||
const GRID_URL = '/deutschland/kloster-chorin-2024';
|
const GRID_URL = '/deutschland/kloster-chorin-2024';
|
||||||
|
|
||||||
test('renders grid items with visible images', async ({ page }) => {
|
test('renders grid items with visible images', async ({ page }) => {
|
||||||
await page.goto(GRID_URL);
|
await page.goto(GRID_URL);
|
||||||
|
|
||||||
// Grid container should exist
|
// Grid container should exist
|
||||||
const grid = page.locator('.photo-grid');
|
const grid = page.locator('.photo-grid');
|
||||||
await expect(grid).toBeVisible();
|
await expect(grid).toBeVisible();
|
||||||
|
|
||||||
// Should have grid items
|
// Should have grid items
|
||||||
const items = grid.locator('.photo-grid__item');
|
const items = grid.locator('.photo-grid__item');
|
||||||
const count = await items.count();
|
const count = await items.count();
|
||||||
expect(count).toBeGreaterThan(0);
|
expect(count).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Scroll to trigger IntersectionObserver (FadeIn)
|
// Scroll to trigger IntersectionObserver (FadeIn)
|
||||||
await items.first().scrollIntoViewIfNeeded();
|
await items.first().scrollIntoViewIfNeeded();
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// First grid item should have real dimensions (not collapsed)
|
// First grid item should have real dimensions (not collapsed)
|
||||||
const firstItem = items.first();
|
const firstItem = items.first();
|
||||||
const box = await firstItem.boundingBox();
|
const box = await firstItem.boundingBox();
|
||||||
expect(box).not.toBeNull();
|
expect(box).not.toBeNull();
|
||||||
expect(box!.width).toBeGreaterThan(100);
|
expect(box!.width).toBeGreaterThan(100);
|
||||||
expect(box!.height).toBeGreaterThan(50);
|
expect(box!.height).toBeGreaterThan(50);
|
||||||
|
|
||||||
// Image inside should exist and have the fill layout
|
// Image inside should exist and have the fill layout
|
||||||
const img = firstItem.locator('img');
|
const img = firstItem.locator('img');
|
||||||
await expect(img).toBeAttached();
|
await expect(img).toBeAttached();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grid items have overflow hidden (CSS regression)', async ({ page }) => {
|
||||||
|
// This test guards against the CSS cascade bug where a later rule
|
||||||
|
// strips `overflow: hidden` from .photo-grid__item, which breaks
|
||||||
|
// next/image fill rendering.
|
||||||
|
await page.goto(GRID_URL);
|
||||||
|
|
||||||
|
const grid = page.locator('.photo-grid');
|
||||||
|
await expect(grid).toBeVisible();
|
||||||
|
|
||||||
|
const item = grid.locator('.photo-grid__item').first();
|
||||||
|
await item.scrollIntoViewIfNeeded();
|
||||||
|
|
||||||
|
const overflow = await item.evaluate((el) => getComputedStyle(el).overflow);
|
||||||
|
expect(overflow).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('images load with real content (not just blurhash)', async ({ page }) => {
|
||||||
|
await page.goto(GRID_URL);
|
||||||
|
|
||||||
|
// Scroll to trigger lazy loading
|
||||||
|
const grid = page.locator('.photo-grid');
|
||||||
|
await grid.scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Check that at least one image has loaded with real pixel data
|
||||||
|
const imgLoaded = await page.evaluate(() => {
|
||||||
|
const img = document.querySelector('.photo-grid__item img') as HTMLImageElement;
|
||||||
|
if (!img) return { found: false };
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
complete: img.complete,
|
||||||
|
naturalWidth: img.naturalWidth,
|
||||||
|
src: img.src.substring(0, 60),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
test('grid items have overflow hidden (CSS regression)', async ({ page }) => {
|
expect(imgLoaded.found).toBe(true);
|
||||||
// This test guards against the CSS cascade bug where a later rule
|
expect(imgLoaded.complete).toBe(true);
|
||||||
// strips `overflow: hidden` from .photo-grid__item, which breaks
|
expect(imgLoaded.naturalWidth).toBeGreaterThan(0);
|
||||||
// next/image fill rendering.
|
});
|
||||||
await page.goto(GRID_URL);
|
|
||||||
|
|
||||||
const grid = page.locator('.photo-grid');
|
|
||||||
await expect(grid).toBeVisible();
|
|
||||||
|
|
||||||
const item = grid.locator('.photo-grid__item').first();
|
|
||||||
await item.scrollIntoViewIfNeeded();
|
|
||||||
|
|
||||||
const overflow = await item.evaluate((el) => getComputedStyle(el).overflow);
|
|
||||||
expect(overflow).toBe('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('images load with real content (not just blurhash)', async ({ page }) => {
|
|
||||||
await page.goto(GRID_URL);
|
|
||||||
|
|
||||||
// Scroll to trigger lazy loading
|
|
||||||
const grid = page.locator('.photo-grid');
|
|
||||||
await grid.scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
// Check that at least one image has loaded with real pixel data
|
|
||||||
const imgLoaded = await page.evaluate(() => {
|
|
||||||
const img = document.querySelector('.photo-grid__item img') as HTMLImageElement;
|
|
||||||
if (!img) return { found: false };
|
|
||||||
return {
|
|
||||||
found: true,
|
|
||||||
complete: img.complete,
|
|
||||||
naturalWidth: img.naturalWidth,
|
|
||||||
src: img.src.substring(0, 60),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(imgLoaded.found).toBe(true);
|
|
||||||
expect(imgLoaded.complete).toBe(true);
|
|
||||||
expect(imgLoaded.naturalWidth).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getConfig, SubpageConfig } from './config';
|
|||||||
const TOKEN_EXPIRY_HOURS = 24;
|
const TOKEN_EXPIRY_HOURS = 24;
|
||||||
|
|
||||||
function hmac(data: string): string {
|
function hmac(data: string): string {
|
||||||
return crypto.createHmac('sha256', getConfig().immich.apiKey).update(data).digest('hex');
|
return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
function authToken(slug: string, password: string): string {
|
function authToken(slug: string, password: string): string {
|
||||||
|
|||||||
134
lib/config.ts
134
lib/config.ts
@@ -125,17 +125,17 @@ interface GalleryYaml {
|
|||||||
exifOnHover?: boolean;
|
exifOnHover?: boolean;
|
||||||
map?: boolean;
|
map?: boolean;
|
||||||
theme?:
|
theme?:
|
||||||
| string
|
| string
|
||||||
| {
|
| {
|
||||||
preset?: string;
|
preset?: string;
|
||||||
accent?: string;
|
accent?: string;
|
||||||
fonts?: { heading?: string; body?: string; caption?: string };
|
fonts?: { heading?: string; body?: string; caption?: string };
|
||||||
radius?: number;
|
radius?: number;
|
||||||
photoFrame?: string;
|
photoFrame?: string;
|
||||||
grain?: boolean;
|
grain?: boolean;
|
||||||
headerDot?: boolean;
|
headerDot?: boolean;
|
||||||
heroStyle?: string;
|
heroStyle?: string;
|
||||||
};
|
};
|
||||||
grid?: {
|
grid?: {
|
||||||
columns?: number;
|
columns?: number;
|
||||||
gap?: number;
|
gap?: number;
|
||||||
@@ -149,18 +149,18 @@ interface GalleryYaml {
|
|||||||
website?: string;
|
website?: string;
|
||||||
};
|
};
|
||||||
subpages?:
|
subpages?:
|
||||||
| Record<string, string[] | Array<string | Record<string, string>>>
|
| Record<string, string[] | Array<string | Record<string, string>>>
|
||||||
| Array<{
|
| Array<{
|
||||||
name: string;
|
name: string;
|
||||||
albums: Array<string | Record<string, string>>;
|
albums: Array<string | Record<string, string>>;
|
||||||
password?: string;
|
password?: string;
|
||||||
grid?: {
|
grid?: {
|
||||||
columns?: number;
|
columns?: number;
|
||||||
gap?: number;
|
gap?: number;
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
layout?: string;
|
layout?: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── YAML loading ───────────────────────────────────────────────
|
// ── YAML loading ───────────────────────────────────────────────
|
||||||
@@ -171,7 +171,7 @@ function loadGalleryYaml(): GalleryYaml {
|
|||||||
if (!fs.existsSync(yamlPath)) {
|
if (!fs.existsSync(yamlPath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Gallery config not found: ${yamlPath}\n` +
|
`Gallery config not found: ${yamlPath}\n` +
|
||||||
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
|
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +224,8 @@ export interface GridConfig {
|
|||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
immich: { apiUrl: string; apiKey: string };
|
immich: { apiUrl: string; apiKey: string };
|
||||||
|
/** Secret used for signing auth cookies and encrypting asset tokens. */
|
||||||
|
authSecret: string;
|
||||||
albums: string[];
|
albums: string[];
|
||||||
standaloneAlbums: string[];
|
standaloneAlbums: string[];
|
||||||
subpages: SubpageConfig[];
|
subpages: SubpageConfig[];
|
||||||
@@ -317,6 +319,15 @@ export function getConfig(): AppConfig {
|
|||||||
|
|
||||||
const apiUrl = env.IMMICH_API_URL;
|
const apiUrl = env.IMMICH_API_URL;
|
||||||
const apiKey = env.IMMICH_API_KEY;
|
const apiKey = env.IMMICH_API_KEY;
|
||||||
|
const authSecret = env.AUTH_SECRET || apiKey;
|
||||||
|
|
||||||
|
if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') {
|
||||||
|
console.warn(
|
||||||
|
'\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n' +
|
||||||
|
' Please set a long random string as AUTH_SECRET in your .env for better security.\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const gallery = loadGalleryYaml();
|
const gallery = loadGalleryYaml();
|
||||||
|
|
||||||
// Resolve theme: string shorthand → preset, object → merged with preset base
|
// Resolve theme: string shorthand → preset, object → merged with preset base
|
||||||
@@ -340,7 +351,13 @@ export function getConfig(): AppConfig {
|
|||||||
processAlbumEntry(entry, 'gallery.yaml albums'),
|
processAlbumEntry(entry, 'gallery.yaml albums'),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (standaloneAlbumIds.length === 0 && (!gallery.subpages || (Array.isArray(gallery.subpages) ? gallery.subpages.length === 0 : Object.keys(gallery.subpages).length === 0))) {
|
if (
|
||||||
|
standaloneAlbumIds.length === 0 &&
|
||||||
|
(!gallery.subpages ||
|
||||||
|
(Array.isArray(gallery.subpages)
|
||||||
|
? gallery.subpages.length === 0
|
||||||
|
: Object.keys(gallery.subpages).length === 0))
|
||||||
|
) {
|
||||||
throw new Error('gallery.yaml must define at least one album or subpage');
|
throw new Error('gallery.yaml must define at least one album or subpage');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,19 +379,19 @@ export function getConfig(): AppConfig {
|
|||||||
password: sp.password,
|
password: sp.password,
|
||||||
...(sp.grid
|
...(sp.grid
|
||||||
? {
|
? {
|
||||||
grid: {
|
grid: {
|
||||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||||
...(sp.grid.layout != null
|
...(sp.grid.layout != null
|
||||||
? {
|
? {
|
||||||
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
||||||
? sp.grid.layout
|
? sp.grid.layout
|
||||||
: 'masonry') as GridConfig['layout'],
|
: 'masonry') as GridConfig['layout'],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -400,19 +417,19 @@ export function getConfig(): AppConfig {
|
|||||||
password: sp.password,
|
password: sp.password,
|
||||||
...(sp.grid
|
...(sp.grid
|
||||||
? {
|
? {
|
||||||
grid: {
|
grid: {
|
||||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||||
...(sp.grid.layout != null
|
...(sp.grid.layout != null
|
||||||
? {
|
? {
|
||||||
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
||||||
? sp.grid.layout
|
? sp.grid.layout
|
||||||
: 'masonry') as GridConfig['layout'],
|
: 'masonry') as GridConfig['layout'],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -427,6 +444,7 @@ export function getConfig(): AppConfig {
|
|||||||
apiUrl: `${apiUrl}/api`,
|
apiUrl: `${apiUrl}/api`,
|
||||||
apiKey,
|
apiKey,
|
||||||
},
|
},
|
||||||
|
authSecret,
|
||||||
albums: allAlbumIds,
|
albums: allAlbumIds,
|
||||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||||
subpages,
|
subpages,
|
||||||
@@ -434,8 +452,8 @@ export function getConfig(): AppConfig {
|
|||||||
siteSubtitle: env.SITE_SUBTITLE,
|
siteSubtitle: env.SITE_SUBTITLE,
|
||||||
heroImages: gallery.hero
|
heroImages: gallery.hero
|
||||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
|
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
|
||||||
validateUuid(id, 'gallery.yaml hero'),
|
validateUuid(id, 'gallery.yaml hero'),
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
exifOnHover: gallery.exifOnHover !== false,
|
exifOnHover: gallery.exifOnHover !== false,
|
||||||
grid: {
|
grid: {
|
||||||
@@ -449,11 +467,11 @@ export function getConfig(): AppConfig {
|
|||||||
theme,
|
theme,
|
||||||
footer: gallery.footer
|
footer: gallery.footer
|
||||||
? {
|
? {
|
||||||
name: gallery.footer.name,
|
name: gallery.footer.name,
|
||||||
instagram: gallery.footer.instagram,
|
instagram: gallery.footer.instagram,
|
||||||
email: gallery.footer.email,
|
email: gallery.footer.email,
|
||||||
website: gallery.footer.website,
|
website: gallery.footer.website,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
map: gallery.map === true,
|
map: gallery.map === true,
|
||||||
albumOverrides,
|
albumOverrides,
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ const envSchema = z.object({
|
|||||||
|
|
||||||
/** Rate limit: max requests per minute per IP */
|
/** Rate limit: max requests per minute per IP */
|
||||||
RATE_LIMIT_RPM: z.coerce.number().int().min(1).default(120),
|
RATE_LIMIT_RPM: z.coerce.number().int().min(1).default(120),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secret used for signing auth cookies and encrypting asset tokens.
|
||||||
|
* If not provided, IMMICH_API_KEY will be used as a fallback (not recommended for production).
|
||||||
|
*/
|
||||||
|
AUTH_SECRET: z.string().min(16, 'AUTH_SECRET should be at least 16 characters').optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ let _key: Buffer | null = null;
|
|||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
if (!_key) {
|
if (!_key) {
|
||||||
const apiKey = getConfig().immich.apiKey;
|
const authSecret = getConfig().authSecret;
|
||||||
_key = crypto.createHash('sha256').update(apiKey).digest();
|
_key = crypto.createHash('sha256').update(authSecret).digest();
|
||||||
}
|
}
|
||||||
return _key;
|
return _key;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* docs/screenshots/theme-{preset}-{page}.png
|
* docs/screenshots/theme-{preset}-{page}.png
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync, spawn, type ChildProcess } from 'child_process';
|
import { spawn, type ChildProcess } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -28,122 +28,127 @@ const VIEWPORT = { width: 1440, height: 900 };
|
|||||||
const IMAGE_LOAD_TIMEOUT = 8000; // ms to wait for images after networkidle
|
const IMAGE_LOAD_TIMEOUT = 8000; // ms to wait for images after networkidle
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Ensure output directory exists
|
// Ensure output directory exists
|
||||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
|
||||||
// Read original gallery.yaml to restore later
|
// Read original gallery.yaml to restore later
|
||||||
const originalYaml = fs.readFileSync(GALLERY_YAML, 'utf8');
|
const originalYaml = fs.readFileSync(GALLERY_YAML, 'utf8');
|
||||||
|
|
||||||
// Dynamic import for Playwright (may not be installed globally)
|
// Dynamic import for Playwright (may not be installed globally)
|
||||||
const { chromium } = await import('playwright');
|
const { chromium } = await import('playwright');
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const theme of THEMES) {
|
for (const theme of THEMES) {
|
||||||
console.log(`\n🎨 Theme: ${theme}`);
|
console.log(`\n🎨 Theme: ${theme}`);
|
||||||
console.log('─'.repeat(50));
|
console.log('─'.repeat(50));
|
||||||
|
|
||||||
// Update gallery.yaml with the theme
|
// Update gallery.yaml with the theme
|
||||||
const yamlWithTheme = setThemeInYaml(originalYaml, theme);
|
const yamlWithTheme = setThemeInYaml(originalYaml, theme);
|
||||||
fs.writeFileSync(GALLERY_YAML, yamlWithTheme);
|
fs.writeFileSync(GALLERY_YAML, yamlWithTheme);
|
||||||
|
|
||||||
// Start dev server
|
// Start dev server
|
||||||
console.log(' ⏳ Starting dev server...');
|
console.log(' ⏳ Starting dev server...');
|
||||||
const server = startDevServer();
|
const server = startDevServer();
|
||||||
await waitForServer(BASE_URL, 15000);
|
await waitForServer(BASE_URL, 15000);
|
||||||
console.log(' ✅ Server ready');
|
console.log(' ✅ Server ready');
|
||||||
|
|
||||||
const context = await browser.newContext({ viewport: VIEWPORT });
|
const context = await browser.newContext({ viewport: VIEWPORT });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
// ── Homepage screenshot ──
|
// ── Homepage screenshot ──
|
||||||
console.log(' 📸 Capturing homepage...');
|
console.log(' 📸 Capturing homepage...');
|
||||||
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
|
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
|
||||||
await waitForImages(page);
|
await waitForImages(page);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: path.join(OUTPUT_DIR, `theme-${theme}-home.png`),
|
path: path.join(OUTPUT_DIR, `theme-${theme}-home.png`),
|
||||||
fullPage: false,
|
fullPage: false,
|
||||||
});
|
});
|
||||||
console.log(` ✅ theme-${theme}-home.png`);
|
console.log(` ✅ theme-${theme}-home.png`);
|
||||||
|
|
||||||
// ── Grid view screenshot ──
|
// ── Grid view screenshot ──
|
||||||
console.log(' 📸 Capturing grid view...');
|
console.log(' 📸 Capturing grid view...');
|
||||||
await page.goto(`${BASE_URL}${GRID_PATH}`, { waitUntil: 'networkidle' });
|
await page.goto(`${BASE_URL}${GRID_PATH}`, { waitUntil: 'networkidle' });
|
||||||
// Force all fade-in elements visible (don't scroll — keep album title in view)
|
// Force all fade-in elements visible (don't scroll — keep album title in view)
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
document.querySelectorAll('.fade-in').forEach(el => el.classList.add('fade-in--visible'));
|
document.querySelectorAll('.fade-in').forEach((el) => el.classList.add('fade-in--visible'));
|
||||||
// Trigger lazy images by switching to eager loading
|
// Trigger lazy images by switching to eager loading
|
||||||
document.querySelectorAll('img[loading="lazy"]').forEach(img => {
|
document.querySelectorAll('img[loading="lazy"]').forEach((img) => {
|
||||||
(img as HTMLImageElement).loading = 'eager';
|
(img as HTMLImageElement).loading = 'eager';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await waitForImages(page);
|
await waitForImages(page);
|
||||||
// Extra settle time for late-loading images in lower columns
|
// Extra settle time for late-loading images in lower columns
|
||||||
await page.waitForTimeout(3000);
|
await page.waitForTimeout(3000);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: path.join(OUTPUT_DIR, `theme-${theme}-grid.png`),
|
path: path.join(OUTPUT_DIR, `theme-${theme}-grid.png`),
|
||||||
fullPage: false,
|
fullPage: false,
|
||||||
});
|
});
|
||||||
console.log(` ✅ theme-${theme}-grid.png`);
|
console.log(` ✅ theme-${theme}-grid.png`);
|
||||||
|
|
||||||
await context.close();
|
await context.close();
|
||||||
|
|
||||||
// Stop dev server
|
// Stop dev server
|
||||||
server.kill('SIGTERM');
|
server.kill('SIGTERM');
|
||||||
await new Promise((resolve) => server.on('close', resolve));
|
await new Promise((resolve) => server.on('close', resolve));
|
||||||
console.log(' 🛑 Server stopped');
|
console.log(' 🛑 Server stopped');
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Always restore original gallery.yaml
|
|
||||||
fs.writeFileSync(GALLERY_YAML, originalYaml);
|
|
||||||
console.log('\n✅ Restored original gallery.yaml');
|
|
||||||
await browser.close();
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
// Always restore original gallery.yaml
|
||||||
|
fs.writeFileSync(GALLERY_YAML, originalYaml);
|
||||||
|
console.log('\n✅ Restored original gallery.yaml');
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`\n🎉 Done! Screenshots saved to ${OUTPUT_DIR}/`);
|
console.log(`\n🎉 Done! Screenshots saved to ${OUTPUT_DIR}/`);
|
||||||
console.log(` ${fs.readdirSync(OUTPUT_DIR).filter(f => f.endsWith('.png')).join(', ')}`);
|
console.log(
|
||||||
|
` ${fs
|
||||||
|
.readdirSync(OUTPUT_DIR)
|
||||||
|
.filter((f) => f.endsWith('.png'))
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function setThemeInYaml(yaml: string, theme: string): string {
|
function setThemeInYaml(yaml: string, theme: string): string {
|
||||||
// Replace existing theme line or insert after the header comment
|
// Replace existing theme line or insert after the header comment
|
||||||
if (/^theme:\s*.+$/m.test(yaml)) {
|
if (/^theme:\s*.+$/m.test(yaml)) {
|
||||||
return yaml.replace(/^theme:\s*.+$/m, `theme: ${theme}`);
|
return yaml.replace(/^theme:\s*.+$/m, `theme: ${theme}`);
|
||||||
}
|
}
|
||||||
// Insert theme after the header comments
|
// Insert theme after the header comments
|
||||||
const marker = '# Secrets (API key, server URL) stay in .env.local.';
|
const marker = '# Secrets (API key, server URL) stay in .env.local.';
|
||||||
return yaml.replace(marker, `${marker}\n\ntheme: ${theme}`);
|
return yaml.replace(marker, `${marker}\n\ntheme: ${theme}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startDevServer(): ChildProcess {
|
function startDevServer(): ChildProcess {
|
||||||
const server = spawn('npm', ['run', 'dev'], {
|
const server = spawn('npm', ['run', 'dev'], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Suppress server output unless DEBUG is set
|
// Suppress server output unless DEBUG is set
|
||||||
if (process.env.DEBUG) {
|
if (process.env.DEBUG) {
|
||||||
server.stdout?.pipe(process.stdout);
|
server.stdout?.pipe(process.stdout);
|
||||||
server.stderr?.pipe(process.stderr);
|
server.stderr?.pipe(process.stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (res.ok) return;
|
if (res.ok) return;
|
||||||
} catch {
|
} catch {
|
||||||
// Server not ready yet
|
// Server not ready yet
|
||||||
}
|
|
||||||
await sleep(500);
|
|
||||||
}
|
}
|
||||||
throw new Error(`Server did not start within ${timeoutMs}ms`);
|
await sleep(500);
|
||||||
|
}
|
||||||
|
throw new Error(`Server did not start within ${timeoutMs}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,50 +156,50 @@ async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
|||||||
* This prevents capturing blurhash placeholders instead of real photos.
|
* This prevents capturing blurhash placeholders instead of real photos.
|
||||||
*/
|
*/
|
||||||
async function waitForImages(page: import('playwright').Page): Promise<void> {
|
async function waitForImages(page: import('playwright').Page): Promise<void> {
|
||||||
await page.waitForTimeout(1000); // Initial settle
|
await page.waitForTimeout(1000); // Initial settle
|
||||||
|
|
||||||
await page.evaluate((timeout) => {
|
await page.evaluate((timeout) => {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
const images = Array.from(document.querySelectorAll('img'));
|
const images = Array.from(document.querySelectorAll('img'));
|
||||||
let pending = images.filter((img) => !img.complete).length;
|
let pending = images.filter((img) => !img.complete).length;
|
||||||
|
|
||||||
if (pending === 0) {
|
if (pending === 0) {
|
||||||
// All images already loaded, wait a bit for any CSS transitions
|
// All images already loaded, wait a bit for any CSS transitions
|
||||||
setTimeout(resolve, 500);
|
setTimeout(resolve, 500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const done = () => {
|
const done = () => {
|
||||||
pending--;
|
pending--;
|
||||||
if (pending <= 0) setTimeout(resolve, 500);
|
if (pending <= 0) setTimeout(resolve, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
images.forEach((img) => {
|
images.forEach((img) => {
|
||||||
if (!img.complete) {
|
if (!img.complete) {
|
||||||
img.addEventListener('load', done);
|
img.addEventListener('load', done);
|
||||||
img.addEventListener('error', done);
|
img.addEventListener('error', done);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Safety timeout
|
// Safety timeout
|
||||||
setTimeout(resolve, timeout);
|
setTimeout(resolve, timeout);
|
||||||
});
|
});
|
||||||
}, IMAGE_LOAD_TIMEOUT);
|
}, IMAGE_LOAD_TIMEOUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Run ──────────────────────────────────────────────────────────
|
// ── Run ──────────────────────────────────────────────────────────
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error('❌ Screenshot generation failed:', err);
|
console.error('❌ Screenshot generation failed:', err);
|
||||||
// Try to restore gallery.yaml on error
|
// Try to restore gallery.yaml on error
|
||||||
try {
|
try {
|
||||||
const original = fs.readFileSync(GALLERY_YAML + '.bak', 'utf8');
|
const original = fs.readFileSync(GALLERY_YAML + '.bak', 'utf8');
|
||||||
fs.writeFileSync(GALLERY_YAML, original);
|
fs.writeFileSync(GALLERY_YAML, original);
|
||||||
} catch {
|
} catch {
|
||||||
// Backup may not exist
|
// Backup may not exist
|
||||||
}
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user