🛡️ Sentinel: [HIGH] Fix missing rate limiting on /api/exif/[id]
Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
## 2024-03-XX - Lightbox Accessibility
|
||||
|
||||
**Learning:** Found that Lightbox overlay has good keyboard navigation (esc/left/right) but lacked tooltip hints for those controls and lacked a shortcut for EXIF data toggle.
|
||||
**Action:** Added `i` shortcut and `title` attributes on interactive elements in Lightbox.
|
||||
|
||||
5
.jules/sentinel.md
Normal file
5
.jules/sentinel.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## 2024-03-12 - [Missing Rate Limiting on EXIF Endpoint]
|
||||
|
||||
**Vulnerability:** The `/api/exif/[id]` endpoint lacked rate limiting, making it vulnerable to DoS attacks. An attacker could rapidly request EXIF metadata, exhausting server resources and the Immich API rate limits.
|
||||
**Learning:** Even simple GET endpoints that fetch metadata from an upstream server (like Immich) must be protected with rate limiting to prevent downstream DoS and resource exhaustion, especially when they perform backend API requests per hit.
|
||||
**Prevention:** Always implement rate limiting on endpoints that proxy requests or query a backend API, utilizing the shared `checkRateLimit` utility.
|
||||
16
README.md
16
README.md
@@ -36,14 +36,14 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
|
||||
|
||||
<br>
|
||||
|
||||
| Concern | Protection |
|
||||
| --- | --- |
|
||||
| **Server exposure** | Immich URL never leaves your network — all requests proxy server-side |
|
||||
| **API key** | Stored only in `.env.local`, never in client code |
|
||||
| **Asset IDs** | Immich UUIDs encrypted (AES-256) into opaque tokens |
|
||||
| **Album scope** | Only albums in `gallery.yaml` are accessible |
|
||||
| **Password protection** | Per-subpage password support |
|
||||
| **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) |
|
||||
| Concern | Protection |
|
||||
| ----------------------- | --------------------------------------------------------------------- |
|
||||
| **Server exposure** | Immich URL never leaves your network — all requests proxy server-side |
|
||||
| **API key** | Stored only in `.env.local`, never in client code |
|
||||
| **Asset IDs** | Immich UUIDs encrypted (AES-256) into opaque tokens |
|
||||
| **Album scope** | Only albums in `gallery.yaml` are accessible |
|
||||
| **Password protection** | Per-subpage password support |
|
||||
| **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) |
|
||||
|
||||
- Health check endpoint at `GET /api/health`
|
||||
- In-memory caching with configurable TTL
|
||||
|
||||
@@ -6,36 +6,40 @@ import type { GridConfig } from '@/lib/config';
|
||||
import React from 'react';
|
||||
|
||||
interface AlbumDetailViewProps {
|
||||
album: ImmichAlbum;
|
||||
images: PhotoItem[];
|
||||
layout: GridConfig['layout'];
|
||||
gridStyle: React.CSSProperties;
|
||||
backLinkHref: string;
|
||||
backLinkLabel: string;
|
||||
subtitle?: string;
|
||||
album: ImmichAlbum;
|
||||
images: PhotoItem[];
|
||||
layout: GridConfig['layout'];
|
||||
gridStyle: React.CSSProperties;
|
||||
backLinkHref: string;
|
||||
backLinkLabel: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function AlbumDetailView({
|
||||
album,
|
||||
images,
|
||||
layout,
|
||||
gridStyle,
|
||||
backLinkHref,
|
||||
backLinkLabel,
|
||||
subtitle,
|
||||
album,
|
||||
images,
|
||||
layout,
|
||||
gridStyle,
|
||||
backLinkHref,
|
||||
backLinkLabel,
|
||||
subtitle,
|
||||
}: AlbumDetailViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="album-header">
|
||||
<BackLink href={backLinkHref} label={backLinkLabel} />
|
||||
<h1 className="album-header__title">{album.albumName}</h1>
|
||||
{subtitle && <p className="subpage-subtitle" style={{ textAlign: "left", marginLeft: 0 }}>{subtitle}</p>}
|
||||
<p className="album-header__meta">
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
{album.description && ` · ${album.description}`}
|
||||
</p>
|
||||
</div>
|
||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="album-header">
|
||||
<BackLink href={backLinkHref} label={backLinkLabel} />
|
||||
<h1 className="album-header__title">{album.albumName}</h1>
|
||||
{subtitle && (
|
||||
<p className="subpage-subtitle" style={{ textAlign: 'left', marginLeft: 0 }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
<p className="album-header__meta">
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
{album.description && ` · ${album.description}`}
|
||||
</p>
|
||||
</div>
|
||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,143 +4,146 @@ import { imageUrl } from '@/lib/urls';
|
||||
import { SubpageSectionConfig } from '@/lib/config';
|
||||
|
||||
interface SubpageAlbum {
|
||||
id: string;
|
||||
slug: string;
|
||||
albumName: string;
|
||||
assetCount: number;
|
||||
albumThumbnailAssetId: string | null;
|
||||
id: string;
|
||||
slug: string;
|
||||
albumName: string;
|
||||
assetCount: number;
|
||||
albumThumbnailAssetId: string | null;
|
||||
}
|
||||
|
||||
interface Placeholder {
|
||||
blurDataURL?: string;
|
||||
dominantColor?: string;
|
||||
blurDataURL?: string;
|
||||
dominantColor?: string;
|
||||
}
|
||||
|
||||
interface SubpageGridViewProps {
|
||||
slug: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
albums: SubpageAlbum[];
|
||||
coverPlaceholders: (Placeholder | null)[];
|
||||
sections?: SubpageSectionConfig[];
|
||||
slug: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
albums: SubpageAlbum[];
|
||||
coverPlaceholders: (Placeholder | null)[];
|
||||
sections?: SubpageSectionConfig[];
|
||||
}
|
||||
|
||||
function AlbumGrid({
|
||||
albums,
|
||||
albumMap,
|
||||
placeholderMap,
|
||||
slug,
|
||||
albums,
|
||||
albumMap,
|
||||
placeholderMap,
|
||||
slug,
|
||||
}: {
|
||||
albums: SubpageAlbum[];
|
||||
albumMap: Map<string, SubpageAlbum>;
|
||||
placeholderMap: Map<string, Placeholder | null>;
|
||||
slug: string;
|
||||
albums: SubpageAlbum[];
|
||||
albumMap: Map<string, SubpageAlbum>;
|
||||
placeholderMap: Map<string, Placeholder | null>;
|
||||
slug: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="subpage-grid">
|
||||
{albums.map((album) => {
|
||||
const ph = placeholderMap.get(album.id) ?? null;
|
||||
return (
|
||||
<Link
|
||||
key={album.id}
|
||||
href={`/${slug}/${album.slug}`}
|
||||
className="subpage-grid__item"
|
||||
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
|
||||
>
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<Image
|
||||
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
|
||||
alt={album.albumName}
|
||||
fill
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
||||
loading="lazy"
|
||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||
/>
|
||||
) : (
|
||||
<div className="skeleton" style={{ width: '100%', height: '100%' }} />
|
||||
)}
|
||||
<span className="subpage-grid__item-badge">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
<div className="subpage-grid__item-overlay">
|
||||
<span className="subpage-grid__item-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-count">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubpageGridView({ slug, title, subtitle, albums, coverPlaceholders, sections }: SubpageGridViewProps) {
|
||||
// Build lookup maps once
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
const placeholderMap = new Map(albums.map((a, i) => [a.id, coverPlaceholders[i] ?? null]));
|
||||
|
||||
const hasSections = sections && sections.length > 0;
|
||||
|
||||
return (
|
||||
<div className="subpage-container">
|
||||
{(title || subtitle) && (
|
||||
<header className="subpage-header">
|
||||
{title && <h1 className="subpage-title">{title}</h1>}
|
||||
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Typographic Table of Contents */}
|
||||
{hasSections && (
|
||||
<nav className="subpage-toc" aria-label="Sections">
|
||||
{sections.map((sec, i) => (
|
||||
<span key={sec.slug} className="subpage-toc__entry">
|
||||
<a href={`#${sec.slug}`} className="subpage-toc__link">
|
||||
<span className="subpage-toc__num">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="subpage-toc__label">{sec.title}</span>
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Sectioned layout */}
|
||||
{hasSections ? (
|
||||
sections.map((sec) => {
|
||||
const sectionAlbums = sec.albumIds
|
||||
.map((id) => albumMap.get(id))
|
||||
.filter(Boolean) as SubpageAlbum[];
|
||||
|
||||
return (
|
||||
<section key={sec.slug} id={sec.slug} className="subpage-section">
|
||||
<header className="subpage-section__header">
|
||||
<h2 className="subpage-section__title">{sec.title}</h2>
|
||||
{sec.description && (
|
||||
<p className="subpage-section__desc">{sec.description}</p>
|
||||
)}
|
||||
<div className="subpage-section__rule" aria-hidden="true" />
|
||||
</header>
|
||||
<AlbumGrid
|
||||
albums={sectionAlbums}
|
||||
albumMap={albumMap}
|
||||
placeholderMap={placeholderMap}
|
||||
slug={slug}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
return (
|
||||
<div className="subpage-grid">
|
||||
{albums.map((album) => {
|
||||
const ph = placeholderMap.get(album.id) ?? null;
|
||||
return (
|
||||
<Link
|
||||
key={album.id}
|
||||
href={`/${slug}/${album.slug}`}
|
||||
className="subpage-grid__item"
|
||||
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
|
||||
>
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<Image
|
||||
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
|
||||
alt={album.albumName}
|
||||
fill
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
||||
loading="lazy"
|
||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||
/>
|
||||
) : (
|
||||
/* Flat layout (no sections) */
|
||||
<AlbumGrid
|
||||
albums={albums}
|
||||
albumMap={albumMap}
|
||||
placeholderMap={placeholderMap}
|
||||
slug={slug}
|
||||
/>
|
||||
<div className="skeleton" style={{ width: '100%', height: '100%' }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
<span className="subpage-grid__item-badge">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
<div className="subpage-grid__item-overlay">
|
||||
<span className="subpage-grid__item-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-count">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubpageGridView({
|
||||
slug,
|
||||
title,
|
||||
subtitle,
|
||||
albums,
|
||||
coverPlaceholders,
|
||||
sections,
|
||||
}: SubpageGridViewProps) {
|
||||
// Build lookup maps once
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
const placeholderMap = new Map(albums.map((a, i) => [a.id, coverPlaceholders[i] ?? null]));
|
||||
|
||||
const hasSections = sections && sections.length > 0;
|
||||
|
||||
return (
|
||||
<div className="subpage-container">
|
||||
{(title || subtitle) && (
|
||||
<header className="subpage-header">
|
||||
{title && <h1 className="subpage-title">{title}</h1>}
|
||||
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Typographic Table of Contents */}
|
||||
{hasSections && (
|
||||
<nav className="subpage-toc" aria-label="Sections">
|
||||
{sections.map((sec, i) => (
|
||||
<span key={sec.slug} className="subpage-toc__entry">
|
||||
<a href={`#${sec.slug}`} className="subpage-toc__link">
|
||||
<span className="subpage-toc__num">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="subpage-toc__label">{sec.title}</span>
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Sectioned layout */}
|
||||
{hasSections ? (
|
||||
sections.map((sec) => {
|
||||
const sectionAlbums = sec.albumIds
|
||||
.map((id) => albumMap.get(id))
|
||||
.filter(Boolean) as SubpageAlbum[];
|
||||
|
||||
return (
|
||||
<section key={sec.slug} id={sec.slug} className="subpage-section">
|
||||
<header className="subpage-section__header">
|
||||
<h2 className="subpage-section__title">{sec.title}</h2>
|
||||
{sec.description && <p className="subpage-section__desc">{sec.description}</p>}
|
||||
<div className="subpage-section__rule" aria-hidden="true" />
|
||||
</header>
|
||||
<AlbumGrid
|
||||
albums={sectionAlbums}
|
||||
albumMap={albumMap}
|
||||
placeholderMap={placeholderMap}
|
||||
slug={slug}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
/* Flat layout (no sections) */
|
||||
<AlbumGrid
|
||||
albums={albums}
|
||||
albumMap={albumMap}
|
||||
placeholderMap={placeholderMap}
|
||||
slug={slug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
* The :token is an encoded asset ID (not a raw UUID).
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { decodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit } from '@/lib/rate-limit';
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const config = getConfig();
|
||||
|
||||
// Security: If EXIF is globally disabled, do not serve it via API either
|
||||
@@ -18,6 +19,21 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'EXIF metadata is disabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
// ── Rate limiting ──────────────────────────────────
|
||||
// Security: Prioritize x-real-ip (set by trusted reverse proxies like nginx/Traefik)
|
||||
// over x-forwarded-for which can be spoofed by clients.
|
||||
const ip =
|
||||
request.headers.get('x-real-ip') ??
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
|
||||
'unknown';
|
||||
const { success, resetAt } = checkRateLimit(`exif:${ip}`, config.rateLimitRpm);
|
||||
|
||||
if (!success) {
|
||||
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
|
||||
console.warn(`[EXIF API] ⚠️ Rate limit exceeded for IP: ${ip}. Retry after ${retryAfter}s`);
|
||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
|
||||
}
|
||||
|
||||
const { id: token } = await params;
|
||||
|
||||
// Decode the opaque token back to an Immich asset ID
|
||||
|
||||
@@ -197,8 +197,8 @@
|
||||
}
|
||||
|
||||
/* HeroCarousel images fill the entire container */
|
||||
.hero--fullbleed>span,
|
||||
.hero--fullbleed>img {
|
||||
.hero--fullbleed > span,
|
||||
.hero--fullbleed > img {
|
||||
position: absolute !important;
|
||||
inset: 0;
|
||||
width: 100% !important;
|
||||
@@ -218,14 +218,21 @@
|
||||
padding: 64px 40px;
|
||||
/* Multi-stop for contrast on any image brightness */
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0.0) 70%),
|
||||
linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.15) 40%, rgba(0, 0, 0, 0.45) 100%);
|
||||
radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 70%),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.4) 0%,
|
||||
rgba(0, 0, 0, 0.15) 40%,
|
||||
rgba(0, 0, 0, 0.45) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.hero--fullbleed .hero__title {
|
||||
color: #fff;
|
||||
font-size: 4rem;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7), 0 8px 48px rgba(0, 0, 0, 0.6);
|
||||
text-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.7),
|
||||
0 8px 48px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.hero--fullbleed .hero__subtitle {
|
||||
@@ -299,7 +306,12 @@
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
padding: 48px 40px;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.3) 50%, transparent 100%);
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.8) 0%,
|
||||
rgba(0, 0, 0, 0.3) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.hero--stacked .hero__title {
|
||||
@@ -589,7 +601,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.subpage-toc__entry+.subpage-toc__entry::before {
|
||||
.subpage-toc__entry + .subpage-toc__entry::before {
|
||||
content: '·';
|
||||
padding: 0 12px;
|
||||
color: var(--text-muted);
|
||||
@@ -658,7 +670,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.subpage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -763,10 +774,12 @@
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 24px;
|
||||
background: linear-gradient(to top,
|
||||
rgba(0, 0, 0, 0.8) 0%,
|
||||
rgba(0, 0, 0, 0.3) 40%,
|
||||
transparent 100%);
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.8) 0%,
|
||||
rgba(0, 0, 0, 0.3) 40%,
|
||||
transparent 100%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
@@ -981,10 +994,12 @@
|
||||
|
||||
/* ── 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);
|
||||
|
||||
@@ -65,28 +65,71 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
minHeight: '100dvh', padding: '2rem', backgroundColor: '#ffffff',
|
||||
color: '#111111', fontFamily: 'system-ui, sans-serif'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100dvh',
|
||||
padding: '2rem',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#111111',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '600px', width: '100%' }}>
|
||||
<h1 style={{ fontSize: 'clamp(2rem, 5vw, 3rem)', fontWeight: 500, marginBottom: '0.5rem', letterSpacing: '-0.02em' }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 'clamp(2rem, 5vw, 3rem)',
|
||||
fontWeight: 500,
|
||||
marginBottom: '0.5rem',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Setup Required
|
||||
</h1>
|
||||
<p style={{ fontSize: '1.125rem', opacity: 0.7, marginBottom: '2rem', lineHeight: 1.5 }}>
|
||||
Immich Folio is running, but it looks like your configuration files or environment variables are missing.
|
||||
<p
|
||||
style={{
|
||||
fontSize: '1.125rem',
|
||||
opacity: 0.7,
|
||||
marginBottom: '2rem',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Immich Folio is running, but it looks like your configuration files or environment
|
||||
variables are missing.
|
||||
</p>
|
||||
<div style={{ backgroundColor: 'rgba(0, 0, 0, 0.03)', padding: '1.5rem', borderRadius: '8px', fontSize: '0.95rem', lineHeight: 1.6 }}>
|
||||
<p style={{ marginBottom: '1rem' }}>To get started, follow these steps in your repository or mounted <code>content/</code> volume:</p>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<p style={{ marginBottom: '1rem' }}>
|
||||
To get started, follow these steps in your repository or mounted{' '}
|
||||
<code>content/</code> volume:
|
||||
</p>
|
||||
<ol style={{ margin: 0, paddingLeft: '1.25rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>Copy <code>.env.example</code> to <code>.env.local</code> and fill in your Immich API URL and Key</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>Copy <code>settings.yaml.example</code> to <code>settings.yaml</code></li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>Copy <code>gallery.yaml.example</code> to <code>gallery.yaml</code></li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>Copy <code>about.md.example</code> to <code>about.md</code> (optional)</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>
|
||||
Copy <code>.env.example</code> to <code>.env.local</code> and fill in your
|
||||
Immich API URL and Key
|
||||
</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>
|
||||
Copy <code>settings.yaml.example</code> to <code>settings.yaml</code>
|
||||
</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>
|
||||
Copy <code>gallery.yaml.example</code> to <code>gallery.yaml</code>
|
||||
</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>
|
||||
Copy <code>about.md.example</code> to <code>about.md</code> (optional)
|
||||
</li>
|
||||
</ol>
|
||||
<p style={{ marginTop: '1.5rem', marginBottom: 0 }}>
|
||||
Open <code>gallery.yaml</code> and add the album IDs you want to display, then <strong>restart your server</strong>.
|
||||
Open <code>gallery.yaml</code> and add the album IDs you want to display, then{' '}
|
||||
<strong>restart your server</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
186
app/map/map.css
186
app/map/map.css
@@ -1,174 +1,174 @@
|
||||
/* ── Map Page ──────────────────────────────────────────────── */
|
||||
|
||||
.map-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - var(--header-height, 64px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - var(--header-height, 64px));
|
||||
}
|
||||
|
||||
.map-page__header {
|
||||
padding: 2rem var(--page-padding, 2rem) 1rem;
|
||||
padding: 2rem var(--page-padding, 2rem) 1rem;
|
||||
}
|
||||
|
||||
.map-page__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-heading);
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.map-page__subtitle {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Map Container ─────────────────────────────────────────── */
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
min-height: 500px;
|
||||
margin: 0 var(--page-padding, 2rem) 2rem;
|
||||
border-radius: var(--radius, 0);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15));
|
||||
flex: 1;
|
||||
min-height: 500px;
|
||||
margin: 0 var(--page-padding, 2rem) 2rem;
|
||||
border-radius: var(--radius, 0);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15));
|
||||
}
|
||||
|
||||
.map-container__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 500px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 500px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ── Map Popups ────────────────────────────────────────────── */
|
||||
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
color: var(--text-primary, #fff) !important;
|
||||
border-radius: var(--radius, 4px) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
color: var(--text-primary, #fff) !important;
|
||||
border-radius: var(--radius, 4px) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
border-top: none !important;
|
||||
border-left: none !important;
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
border-top: none !important;
|
||||
border-left: none !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 0 !important;
|
||||
min-width: 200px;
|
||||
margin: 0 !important;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.map-popup {
|
||||
font-family: var(--font-body);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.map-popup__cover {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-popup__body {
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.map-popup__city {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.125rem;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.map-popup__country {
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 0.5rem;
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.map-popup__count {
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.map-popup__albums {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.map-popup__album-link {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent, #e60012);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent, #e60012);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.map-popup__album-link:hover {
|
||||
opacity: 0.7;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Custom Marker ─────────────────────────────────────────── */
|
||||
|
||||
.map-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, #e60012);
|
||||
color: #fff;
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, #e60012);
|
||||
color: #fff;
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.map-marker:hover {
|
||||
transform: scale(1.15);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* ── Leaflet overrides ─────────────────────────────────────── */
|
||||
|
||||
.leaflet-control-attribution {
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
color: var(--text-secondary, #888) !important;
|
||||
font-size: 0.625rem !important;
|
||||
opacity: 0.7;
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
color: var(--text-secondary, #888) !important;
|
||||
font-size: 0.625rem !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.leaflet-control-attribution a {
|
||||
color: var(--text-secondary, #888) !important;
|
||||
color: var(--text-secondary, #888) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
color: var(--text-primary, #fff) !important;
|
||||
border-color: var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
color: var(--text-primary, #fff) !important;
|
||||
border-color: var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
background: var(--bg-secondary, #222) !important;
|
||||
background: var(--bg-secondary, #222) !important;
|
||||
}
|
||||
@@ -198,7 +198,7 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-preset='classic'] .subpage-toc__entry+.subpage-toc__entry::before {
|
||||
[data-preset='classic'] .subpage-toc__entry + .subpage-toc__entry::before {
|
||||
content: '◆';
|
||||
padding: 0 14px;
|
||||
font-size: 0.45rem;
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
|
||||
[data-preset='editorial'] .subpage-toc__entry+.subpage-toc__entry::before {
|
||||
[data-preset='editorial'] .subpage-toc__entry + .subpage-toc__entry::before {
|
||||
content: '/';
|
||||
padding: 0 12px;
|
||||
font-family: var(--font-caption);
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
[data-preset='minimal'] .subpage-toc__entry+.subpage-toc__entry::before {
|
||||
[data-preset='minimal'] .subpage-toc__entry + .subpage-toc__entry::before {
|
||||
content: '—';
|
||||
padding: 0 10px;
|
||||
font-size: 0.6rem;
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
}
|
||||
|
||||
/* Main: extra generous margins */
|
||||
[data-preset='monograph']~body .main {
|
||||
[data-preset='monograph'] ~ body .main {
|
||||
padding: 80px 60px 120px;
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
[data-preset='monograph'] .subpage-toc__entry+.subpage-toc__entry::before {
|
||||
[data-preset='monograph'] .subpage-toc__entry + .subpage-toc__entry::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
[data-preset='noir'] .subpage-toc__entry+.subpage-toc__entry::before {
|
||||
[data-preset='noir'] .subpage-toc__entry + .subpage-toc__entry::before {
|
||||
content: '|';
|
||||
padding: 0 12px;
|
||||
font-family: var(--font-caption);
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
-webkit-user-drag: none;
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transition: opacity var(--transition-normal), transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition:
|
||||
opacity var(--transition-normal),
|
||||
transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.imageLoaded {
|
||||
@@ -251,7 +253,6 @@
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
|
||||
.close,
|
||||
.nav,
|
||||
.counter {
|
||||
|
||||
@@ -7,6 +7,7 @@ cp content/gallery.yaml.example content/gallery.yaml
|
||||
```
|
||||
|
||||
**Contents:**
|
||||
|
||||
- [Hero Images](#hero-images)
|
||||
- [Standalone Albums](#standalone-albums)
|
||||
- [Subpages & Categories](#subpages)
|
||||
@@ -34,6 +35,7 @@ hero:
|
||||
## Standalone Albums
|
||||
|
||||
Albums shown directly on the homepage as cards.
|
||||
|
||||
> [!NOTE]
|
||||
> The thumbnail image for each album grid card is automatically synced with the explicit **\"Cover Image\"** you select for that album inside the Immich Web UI.
|
||||
|
||||
@@ -50,15 +52,15 @@ The overall **Subpage Cover Image** shown on the homepage is automatically inher
|
||||
|
||||
```yaml
|
||||
subpages:
|
||||
- name: Japan # → /japan
|
||||
title: "Trip to Japan" # Optional: overrides the page heading
|
||||
subtitle: "2024 adventures" # Optional: adds a subline under the heading
|
||||
- name: Japan # → /japan
|
||||
title: 'Trip to Japan' # Optional: overrides the page heading
|
||||
subtitle: '2024 adventures' # Optional: adds a subline under the heading
|
||||
albums:
|
||||
- 33333333-3333-3333-3333-333333333333 # Tokyo
|
||||
- 44444444-4444-4444-4444-444444444444 # Kyoto
|
||||
- 33333333-3333-3333-3333-333333333333 # Tokyo
|
||||
- 44444444-4444-4444-4444-444444444444 # Kyoto
|
||||
|
||||
- name: Wedding – Smith # → /wedding-smith
|
||||
password: clientpass123 # Optional: password-protected
|
||||
- name: Wedding – Smith # → /wedding-smith
|
||||
password: clientpass123 # Optional: password-protected
|
||||
albums:
|
||||
- 55555555-5555-5555-5555-555555555555
|
||||
```
|
||||
@@ -67,9 +69,9 @@ Alternatively, you can use the object notation (recommended):
|
||||
|
||||
```yaml
|
||||
subpages:
|
||||
"Japan":
|
||||
title: "Trip to Japan"
|
||||
subtitle: "2024 adventures"
|
||||
'Japan':
|
||||
title: 'Trip to Japan'
|
||||
subtitle: '2024 adventures'
|
||||
albums:
|
||||
- 33333333-3333-3333-3333-333333333333
|
||||
```
|
||||
@@ -80,18 +82,18 @@ When a subpage contains many albums you can split them into **named sections**.
|
||||
|
||||
Each section can have:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `title` | string | ✅ | Section heading + anchor name |
|
||||
| `description` | string | ➖ | Optional subline under the heading |
|
||||
| `albums` | list | ✅ | Album UUIDs (same format as regular albums) |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ------------------------------------------- |
|
||||
| `title` | string | ✅ | Section heading + anchor name |
|
||||
| `description` | string | ➖ | Optional subline under the heading |
|
||||
| `albums` | list | ✅ | Album UUIDs (same format as regular albums) |
|
||||
|
||||
Within the `albums` list you can use a plain UUID or override the display title:
|
||||
|
||||
```yaml
|
||||
albums:
|
||||
- "album-uuid" # → uses the Immich album name
|
||||
- "album-uuid": My Title # → displays "My Title" instead
|
||||
- 'album-uuid' # → uses the Immich album name
|
||||
- 'album-uuid': My Title # → displays "My Title" instead
|
||||
```
|
||||
|
||||
**Full example:**
|
||||
@@ -100,29 +102,28 @@ albums:
|
||||
subpages:
|
||||
- name: Japan
|
||||
title: Japan
|
||||
subtitle: "Reisen durch ein Land voller Kontraste."
|
||||
subtitle: 'Reisen durch ein Land voller Kontraste.'
|
||||
sections:
|
||||
- title: Tokyo
|
||||
description: "Megacity, Neonlichter, Stille im Lärm."
|
||||
description: 'Megacity, Neonlichter, Stille im Lärm.'
|
||||
albums:
|
||||
- "33333333-3333-3333-3333-333333333333"
|
||||
- "44444444-4444-4444-4444-444444444444": Shinjuku bei Nacht
|
||||
- '33333333-3333-3333-3333-333333333333'
|
||||
- '44444444-4444-4444-4444-444444444444': Shinjuku bei Nacht
|
||||
|
||||
- title: Kyoto
|
||||
description: "Tempel und Bambuswälder."
|
||||
description: 'Tempel und Bambuswälder.'
|
||||
albums:
|
||||
- "55555555-5555-5555-5555-555555555555": Fushimi Inari
|
||||
- "66666666-6666-6666-6666-666666666666"
|
||||
- '55555555-5555-5555-5555-555555555555': Fushimi Inari
|
||||
- '66666666-6666-6666-6666-666666666666'
|
||||
|
||||
- title: Osaka
|
||||
albums: # description is optional
|
||||
- "77777777-7777-7777-7777-777777777777"
|
||||
albums: # description is optional
|
||||
- '77777777-7777-7777-7777-777777777777'
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Each section title is automatically converted to a URL-safe anchor (`#tokyo`, `#kyoto`, …). The TOC appearance (separator character, numbering style, section rule) is fully controlled by the active theme.
|
||||
|
||||
|
||||
## Grid Layout
|
||||
|
||||
Configure the photo grid globally or per-subpage:
|
||||
@@ -130,17 +131,17 @@ Configure the photo grid globally or per-subpage:
|
||||
```yaml
|
||||
# Global defaults
|
||||
grid:
|
||||
columns: 3 # number of columns (default: 3)
|
||||
gap: 12 # gap in pixels (default: 12)
|
||||
aspectRatio: "1" # "1", "3/2", "auto", etc. (default: "1")
|
||||
layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow"
|
||||
columns: 3 # number of columns (default: 3)
|
||||
gap: 12 # gap in pixels (default: 12)
|
||||
aspectRatio: '1' # "1", "3/2", "auto", etc. (default: "1")
|
||||
layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow"
|
||||
|
||||
subpages:
|
||||
- name: Japan
|
||||
grid: # per-subpage override
|
||||
grid: # per-subpage override
|
||||
columns: 4
|
||||
layout: uniform
|
||||
aspectRatio: "3/2"
|
||||
aspectRatio: '3/2'
|
||||
albums:
|
||||
- ...
|
||||
```
|
||||
@@ -150,7 +151,7 @@ subpages:
|
||||
Show camera/lens/settings when hovering over photos (enabled by default):
|
||||
|
||||
```yaml
|
||||
exifOnHover: false # set to false to disable
|
||||
exifOnHover: false # set to false to disable
|
||||
```
|
||||
|
||||
## Footer
|
||||
|
||||
@@ -4,14 +4,14 @@ Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, berei
|
||||
|
||||
## 🏆 Quick Wins (High Impact / Low Effort)
|
||||
|
||||
| Idea | Effort | Impact |
|
||||
|------|--------|--------|
|
||||
| **Slideshow mode** | Low | High — exhibition ready (Auto-advance 3s/5s/10s) |
|
||||
| **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) |
|
||||
| **Download originals** | Low | High — client delivery (Button in lightbox) |
|
||||
| **Photo of the Day** | Low | Medium — return visits (Auto-rotate hero image) |
|
||||
| **Keyboard shortcut hints** | Low | Medium — `?` button for shortcuts (arrows, Esc, F) |
|
||||
| **Photo sharing** | Low | Medium — "Copy link" button in lightbox |
|
||||
| Idea | Effort | Impact |
|
||||
| ------------------------------ | ------ | -------------------------------------------------- |
|
||||
| **Slideshow mode** | Low | High — exhibition ready (Auto-advance 3s/5s/10s) |
|
||||
| **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) |
|
||||
| **Download originals** | Low | High — client delivery (Button in lightbox) |
|
||||
| **Photo of the Day** | Low | Medium — return visits (Auto-rotate hero image) |
|
||||
| **Keyboard shortcut hints** | Low | Medium — `?` button for shortcuts (arrows, Esc, F) |
|
||||
| **Photo sharing** | Low | Medium — "Copy link" button in lightbox |
|
||||
|
||||
---
|
||||
|
||||
@@ -29,7 +29,7 @@ Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, berei
|
||||
|
||||
## 📖 Deep Dive Feature Plan: Photo Stories / Essays
|
||||
|
||||
*Vertical scroll format with images + text interspersed, like a photo essay. A major portfolio differentiator.*
|
||||
_Vertical scroll format with images + text interspersed, like a photo essay. A major portfolio differentiator._
|
||||
|
||||
### Proposed Architecture
|
||||
|
||||
|
||||
@@ -14,18 +14,19 @@ That's it. Your entire gallery switches to the minimal theme.
|
||||
|
||||
## Built-in Presets
|
||||
|
||||
| Preset | Style | Accent | Fonts | Hero | Frame | Grain |
|
||||
| ------------- | ------------------------------ | ------------ | ---------------------------------- | ----------- | ------------ | ----- |
|
||||
| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ |
|
||||
| **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ |
|
||||
| **editorial** | Cinematic magazine, warm tones | 🟤 `#8B2500` | Bodoni Moda + Newsreader | Split | Shadow | ✗ |
|
||||
| **classic** | Gilded gallery, warm luxury | 🟡 `#c49a3c` | Cinzel + Crimson Pro | Minimal | Passepartout | ✗ |
|
||||
| **noir** | Darkroom analog, film noir | 🟠 `#ff6b35` | Libre Baskerville + Source Sans 3 | Fullbleed | Passepartout | ✓ |
|
||||
| **monograph** | Typographic, book-like | ⬛ `#333333` | Instrument Serif + Inter | Typographic | None | ✗ |
|
||||
| Preset | Style | Accent | Fonts | Hero | Frame | Grain |
|
||||
| ------------- | ------------------------------ | ------------ | --------------------------------- | ----------- | ------------ | ----- |
|
||||
| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ |
|
||||
| **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ |
|
||||
| **editorial** | Cinematic magazine, warm tones | 🟤 `#8B2500` | Bodoni Moda + Newsreader | Split | Shadow | ✗ |
|
||||
| **classic** | Gilded gallery, warm luxury | 🟡 `#c49a3c` | Cinzel + Crimson Pro | Minimal | Passepartout | ✗ |
|
||||
| **noir** | Darkroom analog, film noir | 🟠 `#ff6b35` | Libre Baskerville + Source Sans 3 | Fullbleed | Passepartout | ✓ |
|
||||
| **monograph** | Typographic, book-like | ⬛ `#333333` | Instrument Serif + Inter | Typographic | None | ✗ |
|
||||
|
||||
Default is `studio` if no theme is specified.
|
||||
|
||||
### Theme Gallery
|
||||
|
||||
_From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
|
||||
|
||||
<p align="center">
|
||||
@@ -38,6 +39,7 @@ _From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
|
||||
</p>
|
||||
|
||||
### Grid View Examples
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/theme-studio-grid.png" width="49%" alt="Studio grid" />
|
||||
<img src="screenshots/theme-minimal-grid.png" width="49%" alt="Minimal grid" />
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface ExifData {
|
||||
make?: string | null;
|
||||
model?: string | null;
|
||||
lensModel?: string | null;
|
||||
focalLength?: number | null;
|
||||
fNumber?: number | null;
|
||||
exposureTime?: string | null;
|
||||
iso?: number | null;
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
make?: string | null;
|
||||
model?: string | null;
|
||||
lensModel?: string | null;
|
||||
focalLength?: number | null;
|
||||
fNumber?: number | null;
|
||||
exposureTime?: string | null;
|
||||
iso?: number | null;
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
}
|
||||
|
||||
export function useExif() {
|
||||
const [exifData, setExifData] = useState<ExifData | null>(null);
|
||||
const [exifLoading, setExifLoading] = useState(false);
|
||||
const [exifData, setExifData] = useState<ExifData | null>(null);
|
||||
const [exifLoading, setExifLoading] = useState(false);
|
||||
|
||||
const fetchExif = useCallback((url: string) => {
|
||||
setExifLoading(true);
|
||||
setExifData(null);
|
||||
fetch(url)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => setExifData(data))
|
||||
.catch(() => setExifData(null))
|
||||
.finally(() => setExifLoading(false));
|
||||
}, []);
|
||||
const fetchExif = useCallback((url: string) => {
|
||||
setExifLoading(true);
|
||||
setExifData(null);
|
||||
fetch(url)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => setExifData(data))
|
||||
.catch(() => setExifData(null))
|
||||
.finally(() => setExifLoading(false));
|
||||
}, []);
|
||||
|
||||
const clearExif = useCallback(() => {
|
||||
setExifData(null);
|
||||
}, []);
|
||||
const clearExif = useCallback(() => {
|
||||
setExifData(null);
|
||||
}, []);
|
||||
|
||||
return { exifData, exifLoading, fetchExif, clearExif };
|
||||
return { exifData, exifLoading, fetchExif, clearExif };
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
interface UseSwipeProps {
|
||||
onSwipeLeft: () => void;
|
||||
onSwipeRight: () => void;
|
||||
threshold?: number;
|
||||
onSwipeLeft: () => void;
|
||||
onSwipeRight: () => void;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 60 }: UseSwipeProps) {
|
||||
const touchStartX = useRef<number>(0);
|
||||
const touchStartY = useRef<number>(0);
|
||||
const touchStartX = useRef<number>(0);
|
||||
const touchStartY = useRef<number>(0);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent | TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
}, []);
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent | TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent | TouchEvent) => {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
const dy = e.changedTouches[0].clientY - touchStartY.current;
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent | TouchEvent) => {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
const dy = e.changedTouches[0].clientY - touchStartY.current;
|
||||
|
||||
if (Math.abs(dx) > threshold && Math.abs(dx) > Math.abs(dy)) {
|
||||
if (dx > 0) onSwipeRight();
|
||||
else onSwipeLeft();
|
||||
}
|
||||
},
|
||||
[onSwipeLeft, onSwipeRight, threshold],
|
||||
);
|
||||
if (Math.abs(dx) > threshold && Math.abs(dx) > Math.abs(dy)) {
|
||||
if (dx > 0) onSwipeRight();
|
||||
else onSwipeLeft();
|
||||
}
|
||||
},
|
||||
[onSwipeLeft, onSwipeRight, threshold],
|
||||
);
|
||||
|
||||
return { handleTouchStart, handleTouchEnd };
|
||||
return { handleTouchStart, handleTouchEnd };
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ export function authenticate(slug: string, password: string): string | null {
|
||||
if (isBcryptHash(sp.password)) {
|
||||
console.error(
|
||||
`\n❌ SECURITY ERROR: Subpage "${slug}" is using an outdated bcrypt password hash.\n` +
|
||||
` Bcrypt dependency has been removed to reduce bundle size.\n` +
|
||||
` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` +
|
||||
` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`,
|
||||
` Bcrypt dependency has been removed to reduce bundle size.\n` +
|
||||
` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` +
|
||||
` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export function authenticate(slug: string, password: string): string | null {
|
||||
const recommendedHash = generateScryptHash(sp.password);
|
||||
console.warn(
|
||||
`\n⚠️ SECURITY WARNING: Subpage "${slug}" is using a plaintext password in gallery.yaml.\n` +
|
||||
` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`,
|
||||
` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +1,265 @@
|
||||
import { env } from '../env';
|
||||
import { loadYaml, validateUuid } from './parser';
|
||||
import { resolveTheme, VALID_LAYOUTS } from './theme';
|
||||
import { slugify, AppConfig, SubpageConfig, SubpageSectionConfig, GalleryYaml, SettingsYaml, GridConfig } from './schema';
|
||||
import {
|
||||
slugify,
|
||||
AppConfig,
|
||||
SubpageConfig,
|
||||
SubpageSectionConfig,
|
||||
GalleryYaml,
|
||||
SettingsYaml,
|
||||
GridConfig,
|
||||
} from './schema';
|
||||
|
||||
export * from './schema';
|
||||
export * from './theme';
|
||||
|
||||
let _config: AppConfig | null = null;
|
||||
export function getConfig(): AppConfig {
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
if (_config && !isDev) return _config;
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
if (_config && !isDev) return _config;
|
||||
|
||||
const apiUrl = env.IMMICH_API_URL;
|
||||
const apiKey = env.IMMICH_API_KEY;
|
||||
const authSecret = env.AUTH_SECRET || apiKey;
|
||||
const apiUrl = env.IMMICH_API_URL;
|
||||
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');
|
||||
}
|
||||
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 = loadYaml<GalleryYaml>('gallery.yaml');
|
||||
const settings = loadYaml<SettingsYaml>('settings.yaml') || {};
|
||||
|
||||
if (!gallery || !apiKey || !apiUrl) {
|
||||
// Return dummy config if gallery.yaml is missing
|
||||
_config = {
|
||||
immich: { apiUrl: `${apiUrl}/api`, apiKey },
|
||||
authSecret,
|
||||
albums: [],
|
||||
standaloneAlbums: [],
|
||||
subpages: [],
|
||||
siteTitle: env.SITE_TITLE || 'Immich Folio',
|
||||
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
|
||||
seo: { title: 'Setup Required', description: 'Please configure Immich Folio', noIndex: true, noFollow: true },
|
||||
heroImages: [],
|
||||
exifOnHover: true,
|
||||
grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
|
||||
theme: resolveTheme('studio'),
|
||||
footer: null,
|
||||
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
|
||||
map: false,
|
||||
transitions: false,
|
||||
albumOverrides: {},
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
needsSetup: true
|
||||
};
|
||||
return _config;
|
||||
}
|
||||
|
||||
const theme = resolveTheme(settings.theme);
|
||||
|
||||
const albumOverrides: Record<string, string> = {};
|
||||
|
||||
function processAlbumEntry(entry: string | Record<string, string>, context: string): string {
|
||||
if (typeof entry === 'string') {
|
||||
return validateUuid(entry, context);
|
||||
}
|
||||
const [uuid, name] = Object.entries(entry)[0];
|
||||
const validatedUuid = validateUuid(uuid, context);
|
||||
albumOverrides[validatedUuid] = name;
|
||||
return validatedUuid;
|
||||
}
|
||||
|
||||
const standaloneAlbumIds = (gallery.albums ?? []).map((entry) => 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))) {
|
||||
throw new Error('gallery.yaml must define at least one album or subpage');
|
||||
}
|
||||
|
||||
let subpages: SubpageConfig[] = [];
|
||||
|
||||
if (Array.isArray(gallery.subpages)) {
|
||||
subpages = gallery.subpages.map((sp) => {
|
||||
if (!sp.name) {
|
||||
throw new Error(`Subpage "(unnamed)" must have a name`);
|
||||
}
|
||||
|
||||
// Parse sections if present
|
||||
let sections: SubpageSectionConfig[] | undefined;
|
||||
let albumIds: string[];
|
||||
|
||||
if (sp.sections && sp.sections.length > 0) {
|
||||
sections = sp.sections.map((sec) => ({
|
||||
title: sec.title,
|
||||
slug: slugify(sec.title),
|
||||
description: sec.description,
|
||||
albumIds: sec.albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`)),
|
||||
}));
|
||||
// flat union of all section albums
|
||||
albumIds = sections.flatMap((s) => s.albumIds);
|
||||
// Also include any top-level albums (outside sections)
|
||||
const topLevel = (sp.albums ?? []).map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`));
|
||||
albumIds = [...topLevel, ...albumIds];
|
||||
} else {
|
||||
const albums = sp.albums ?? [];
|
||||
if (albums.length === 0) {
|
||||
throw new Error(`Subpage "${sp.name}" must have albums or sections`);
|
||||
}
|
||||
albumIds = albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`));
|
||||
}
|
||||
|
||||
return {
|
||||
name: sp.name,
|
||||
slug: slugify(sp.name),
|
||||
title: sp.title,
|
||||
subtitle: sp.subtitle,
|
||||
albumIds,
|
||||
sections,
|
||||
password: sp.password,
|
||||
...(sp.grid ? {
|
||||
grid: {
|
||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||
...(sp.grid.layout != null ? { layout: (VALID_LAYOUTS.includes(sp.grid.layout) ? sp.grid.layout : 'masonry') as GridConfig['layout'] } : {}),
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
});
|
||||
} else if (gallery.subpages) {
|
||||
subpages = Object.entries(gallery.subpages).map(([name, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return { name, slug: slugify(name), albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)) };
|
||||
}
|
||||
interface SubpageObjectValue { title?: string; subtitle?: string; albums?: Array<string | Record<string, string>>; password?: string; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string; }; }
|
||||
const sp = value as SubpageObjectValue;
|
||||
const albumEntries = sp.albums || [];
|
||||
return {
|
||||
name,
|
||||
slug: slugify(name),
|
||||
title: sp.title,
|
||||
subtitle: sp.subtitle,
|
||||
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
||||
password: sp.password,
|
||||
...(sp.grid ? {
|
||||
grid: {
|
||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||
...(sp.grid.layout != null ? { layout: (VALID_LAYOUTS.includes(sp.grid.layout) ? sp.grid.layout : 'masonry') as GridConfig['layout'] } : {}),
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
|
||||
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
|
||||
const gallery = loadYaml<GalleryYaml>('gallery.yaml');
|
||||
const settings = loadYaml<SettingsYaml>('settings.yaml') || {};
|
||||
|
||||
if (!gallery || !apiKey || !apiUrl) {
|
||||
// Return dummy config if gallery.yaml is missing
|
||||
_config = {
|
||||
immich: { apiUrl: `${apiUrl}/api`, apiKey },
|
||||
authSecret,
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||
subpages,
|
||||
siteTitle: settings.title ?? env.SITE_TITLE,
|
||||
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
||||
seo: {
|
||||
title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
|
||||
description: settings.seo?.description || settings.subtitle || env.SITE_SUBTITLE || 'A curated photography portfolio',
|
||||
noIndex: settings.seo?.noIndex === true,
|
||||
noFollow: settings.seo?.noFollow === true,
|
||||
},
|
||||
heroImages: gallery.hero ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => validateUuid(id, 'gallery.yaml hero')) : [],
|
||||
exifOnHover: settings.exifOnHover !== false,
|
||||
grid: {
|
||||
columns: settings.grid?.columns ?? 3,
|
||||
gap: settings.grid?.gap ?? 12,
|
||||
aspectRatio: settings.grid?.aspectRatio ?? '1',
|
||||
layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '') ? settings.grid!.layout : 'masonry') as GridConfig['layout'],
|
||||
},
|
||||
theme,
|
||||
footer: settings.footer ? { name: settings.footer.name, instagram: settings.footer.instagram, email: settings.footer.email, website: settings.footer.website } : null,
|
||||
legal: {
|
||||
enabled: settings.legal?.enabled === true,
|
||||
name: settings.legal?.name || '',
|
||||
address: settings.legal?.address || '',
|
||||
zipCity: settings.legal?.zipCity || '',
|
||||
country: settings.legal?.country || '',
|
||||
email: settings.legal?.email,
|
||||
phone: settings.legal?.phone,
|
||||
taxId: settings.legal?.taxId,
|
||||
vatId: settings.legal?.vatId,
|
||||
extraInfo: settings.legal?.extraInfo,
|
||||
},
|
||||
map: settings.map === true,
|
||||
transitions: settings.transitions !== false,
|
||||
albumOverrides,
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
needsSetup: false
|
||||
immich: { apiUrl: `${apiUrl}/api`, apiKey },
|
||||
authSecret,
|
||||
albums: [],
|
||||
standaloneAlbums: [],
|
||||
subpages: [],
|
||||
siteTitle: env.SITE_TITLE || 'Immich Folio',
|
||||
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
|
||||
seo: {
|
||||
title: 'Setup Required',
|
||||
description: 'Please configure Immich Folio',
|
||||
noIndex: true,
|
||||
noFollow: true,
|
||||
},
|
||||
heroImages: [],
|
||||
exifOnHover: true,
|
||||
grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
|
||||
theme: resolveTheme('studio'),
|
||||
footer: null,
|
||||
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
|
||||
map: false,
|
||||
transitions: false,
|
||||
albumOverrides: {},
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
needsSetup: true,
|
||||
};
|
||||
return _config;
|
||||
}
|
||||
|
||||
const theme = resolveTheme(settings.theme);
|
||||
|
||||
const albumOverrides: Record<string, string> = {};
|
||||
|
||||
function processAlbumEntry(entry: string | Record<string, string>, context: string): string {
|
||||
if (typeof entry === 'string') {
|
||||
return validateUuid(entry, context);
|
||||
}
|
||||
const [uuid, name] = Object.entries(entry)[0];
|
||||
const validatedUuid = validateUuid(uuid, context);
|
||||
albumOverrides[validatedUuid] = name;
|
||||
return validatedUuid;
|
||||
}
|
||||
|
||||
const standaloneAlbumIds = (gallery.albums ?? []).map((entry) =>
|
||||
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))
|
||||
) {
|
||||
throw new Error('gallery.yaml must define at least one album or subpage');
|
||||
}
|
||||
|
||||
let subpages: SubpageConfig[] = [];
|
||||
|
||||
if (Array.isArray(gallery.subpages)) {
|
||||
subpages = gallery.subpages.map((sp) => {
|
||||
if (!sp.name) {
|
||||
throw new Error(`Subpage "(unnamed)" must have a name`);
|
||||
}
|
||||
|
||||
// Parse sections if present
|
||||
let sections: SubpageSectionConfig[] | undefined;
|
||||
let albumIds: string[];
|
||||
|
||||
if (sp.sections && sp.sections.length > 0) {
|
||||
sections = sp.sections.map((sec) => ({
|
||||
title: sec.title,
|
||||
slug: slugify(sec.title),
|
||||
description: sec.description,
|
||||
albumIds: sec.albums.map((entry) =>
|
||||
processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`),
|
||||
),
|
||||
}));
|
||||
// flat union of all section albums
|
||||
albumIds = sections.flatMap((s) => s.albumIds);
|
||||
// Also include any top-level albums (outside sections)
|
||||
const topLevel = (sp.albums ?? []).map((entry) =>
|
||||
processAlbumEntry(entry, `subpage "${sp.name}"`),
|
||||
);
|
||||
albumIds = [...topLevel, ...albumIds];
|
||||
} else {
|
||||
const albums = sp.albums ?? [];
|
||||
if (albums.length === 0) {
|
||||
throw new Error(`Subpage "${sp.name}" must have albums or sections`);
|
||||
}
|
||||
albumIds = albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`));
|
||||
}
|
||||
|
||||
return {
|
||||
name: sp.name,
|
||||
slug: slugify(sp.name),
|
||||
title: sp.title,
|
||||
subtitle: sp.subtitle,
|
||||
albumIds,
|
||||
sections,
|
||||
password: sp.password,
|
||||
...(sp.grid
|
||||
? {
|
||||
grid: {
|
||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||
...(sp.grid.layout != null
|
||||
? {
|
||||
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
||||
? sp.grid.layout
|
||||
: 'masonry') as GridConfig['layout'],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
} else if (gallery.subpages) {
|
||||
subpages = Object.entries(gallery.subpages).map(([name, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
name,
|
||||
slug: slugify(name),
|
||||
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
||||
};
|
||||
}
|
||||
interface SubpageObjectValue {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
albums?: Array<string | Record<string, string>>;
|
||||
password?: string;
|
||||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||||
}
|
||||
const sp = value as SubpageObjectValue;
|
||||
const albumEntries = sp.albums || [];
|
||||
return {
|
||||
name,
|
||||
slug: slugify(name),
|
||||
title: sp.title,
|
||||
subtitle: sp.subtitle,
|
||||
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
||||
password: sp.password,
|
||||
...(sp.grid
|
||||
? {
|
||||
grid: {
|
||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||
...(sp.grid.layout != null
|
||||
? {
|
||||
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
||||
? sp.grid.layout
|
||||
: 'masonry') as GridConfig['layout'],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
|
||||
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
|
||||
|
||||
_config = {
|
||||
immich: { apiUrl: `${apiUrl}/api`, apiKey },
|
||||
authSecret,
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||
subpages,
|
||||
siteTitle: settings.title ?? env.SITE_TITLE,
|
||||
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
||||
seo: {
|
||||
title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
|
||||
description:
|
||||
settings.seo?.description ||
|
||||
settings.subtitle ||
|
||||
env.SITE_SUBTITLE ||
|
||||
'A curated photography portfolio',
|
||||
noIndex: settings.seo?.noIndex === true,
|
||||
noFollow: settings.seo?.noFollow === true,
|
||||
},
|
||||
heroImages: gallery.hero
|
||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
|
||||
validateUuid(id, 'gallery.yaml hero'),
|
||||
)
|
||||
: [],
|
||||
exifOnHover: settings.exifOnHover !== false,
|
||||
grid: {
|
||||
columns: settings.grid?.columns ?? 3,
|
||||
gap: settings.grid?.gap ?? 12,
|
||||
aspectRatio: settings.grid?.aspectRatio ?? '1',
|
||||
layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '')
|
||||
? settings.grid!.layout
|
||||
: 'masonry') as GridConfig['layout'],
|
||||
},
|
||||
theme,
|
||||
footer: settings.footer
|
||||
? {
|
||||
name: settings.footer.name,
|
||||
instagram: settings.footer.instagram,
|
||||
email: settings.footer.email,
|
||||
website: settings.footer.website,
|
||||
}
|
||||
: null,
|
||||
legal: {
|
||||
enabled: settings.legal?.enabled === true,
|
||||
name: settings.legal?.name || '',
|
||||
address: settings.legal?.address || '',
|
||||
zipCity: settings.legal?.zipCity || '',
|
||||
country: settings.legal?.country || '',
|
||||
email: settings.legal?.email,
|
||||
phone: settings.legal?.phone,
|
||||
taxId: settings.legal?.taxId,
|
||||
vatId: settings.legal?.vatId,
|
||||
extraInfo: settings.legal?.extraInfo,
|
||||
},
|
||||
map: settings.map === true,
|
||||
transitions: settings.transitions !== false,
|
||||
albumOverrides,
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
needsSetup: false,
|
||||
};
|
||||
return _config;
|
||||
}
|
||||
|
||||
@@ -3,25 +3,25 @@ import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
export function loadYaml<T>(filename: string): T | null {
|
||||
const yamlPath = path.join(process.cwd(), 'content', filename);
|
||||
const yamlPath = path.join(process.cwd(), 'content', filename);
|
||||
|
||||
if (!fs.existsSync(yamlPath)) {
|
||||
return null;
|
||||
}
|
||||
if (!fs.existsSync(yamlPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(yamlPath, 'utf8');
|
||||
return (yaml.load(raw) || {}) as T;
|
||||
const raw = fs.readFileSync(yamlPath, 'utf8');
|
||||
return (yaml.load(raw) || {}) as T;
|
||||
}
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function validateUuid(id: string, context: string): string {
|
||||
const trimmed = id.trim();
|
||||
if (!UUID_REGEX.test(trimmed)) {
|
||||
// If we are in setup mode or using dummy data, don't crash the build.
|
||||
// Return a valid dummy UUID so Next.js doesn't panic.
|
||||
console.warn(`Invalid UUID in ${context}: "${trimmed}" (Ignored for build/setup)`);
|
||||
return '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
return trimmed;
|
||||
const trimmed = id.trim();
|
||||
if (!UUID_REGEX.test(trimmed)) {
|
||||
// If we are in setup mode or using dummy data, don't crash the build.
|
||||
// Return a valid dummy UUID so Next.js doesn't panic.
|
||||
console.warn(`Invalid UUID in ${context}: "${trimmed}" (Ignored for build/setup)`);
|
||||
return '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@@ -1,91 +1,91 @@
|
||||
export interface SubpageSectionConfig {
|
||||
title: string;
|
||||
slug: string; // auto-derived from title, used as anchor
|
||||
description?: string;
|
||||
albumIds: string[];
|
||||
title: string;
|
||||
slug: string; // auto-derived from title, used as anchor
|
||||
description?: string;
|
||||
albumIds: string[];
|
||||
}
|
||||
|
||||
export interface SubpageConfig {
|
||||
name: string;
|
||||
slug: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
albumIds: string[]; // flat list (all albums, incl. from sections)
|
||||
sections?: SubpageSectionConfig[];
|
||||
password?: string;
|
||||
grid?: Partial<GridConfig>;
|
||||
name: string;
|
||||
slug: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
albumIds: string[]; // flat list (all albums, incl. from sections)
|
||||
sections?: SubpageSectionConfig[];
|
||||
password?: string;
|
||||
grid?: Partial<GridConfig>;
|
||||
}
|
||||
|
||||
export interface FooterConfig {
|
||||
name?: string;
|
||||
instagram?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
name?: string;
|
||||
instagram?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
export interface LegalConfig {
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
address: string;
|
||||
zipCity: string;
|
||||
country: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
taxId?: string;
|
||||
vatId?: string;
|
||||
extraInfo?: string;
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
address: string;
|
||||
zipCity: string;
|
||||
country: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
taxId?: string;
|
||||
vatId?: string;
|
||||
extraInfo?: string;
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
preset: string;
|
||||
accent: string;
|
||||
fonts: { heading: string; body: string; caption: string };
|
||||
radius: number;
|
||||
photoFrame: 'none' | 'passepartout' | 'shadow';
|
||||
grain: boolean;
|
||||
headerDot: boolean;
|
||||
heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic';
|
||||
preset: string;
|
||||
accent: string;
|
||||
fonts: { heading: string; body: string; caption: string };
|
||||
radius: number;
|
||||
photoFrame: 'none' | 'passepartout' | 'shadow';
|
||||
grain: boolean;
|
||||
headerDot: boolean;
|
||||
heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic';
|
||||
}
|
||||
|
||||
export interface GridConfig {
|
||||
columns: number;
|
||||
gap: number;
|
||||
aspectRatio: string;
|
||||
layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
|
||||
columns: number;
|
||||
gap: number;
|
||||
aspectRatio: string;
|
||||
layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
immich: { apiUrl: string; apiKey: string };
|
||||
authSecret: string;
|
||||
albums: string[];
|
||||
standaloneAlbums: string[];
|
||||
subpages: SubpageConfig[];
|
||||
siteTitle: string;
|
||||
siteSubtitle: string;
|
||||
seo: {
|
||||
title: string;
|
||||
description: string;
|
||||
noIndex: boolean;
|
||||
noFollow: boolean;
|
||||
};
|
||||
heroImages: string[];
|
||||
exifOnHover: boolean;
|
||||
grid: GridConfig;
|
||||
theme: ThemeConfig;
|
||||
footer: FooterConfig | null;
|
||||
legal: LegalConfig;
|
||||
map: boolean;
|
||||
transitions: boolean;
|
||||
albumOverrides: Record<string, string>;
|
||||
cacheTtl: number;
|
||||
rateLimitRpm: number;
|
||||
needsSetup?: boolean;
|
||||
immich: { apiUrl: string; apiKey: string };
|
||||
authSecret: string;
|
||||
albums: string[];
|
||||
standaloneAlbums: string[];
|
||||
subpages: SubpageConfig[];
|
||||
siteTitle: string;
|
||||
siteSubtitle: string;
|
||||
seo: {
|
||||
title: string;
|
||||
description: string;
|
||||
noIndex: boolean;
|
||||
noFollow: boolean;
|
||||
};
|
||||
heroImages: string[];
|
||||
exifOnHover: boolean;
|
||||
grid: GridConfig;
|
||||
theme: ThemeConfig;
|
||||
footer: FooterConfig | null;
|
||||
legal: LegalConfig;
|
||||
map: boolean;
|
||||
transitions: boolean;
|
||||
albumOverrides: Record<string, string>;
|
||||
cacheTtl: number;
|
||||
rateLimitRpm: number;
|
||||
needsSetup?: boolean;
|
||||
}
|
||||
|
||||
export interface GalleryYaml {
|
||||
hero?: string | string[];
|
||||
albums?: string[];
|
||||
subpages?:
|
||||
hero?: string | string[];
|
||||
albums?: string[];
|
||||
subpages?:
|
||||
| Record<string, string[] | Array<string | Record<string, string>>>
|
||||
| Array<{
|
||||
name: string;
|
||||
@@ -93,33 +93,33 @@ export interface GalleryYaml {
|
||||
subtitle?: string;
|
||||
albums?: Array<string | Record<string, string>>;
|
||||
sections?: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
albums: Array<string | Record<string, string>>;
|
||||
title: string;
|
||||
description?: string;
|
||||
albums: Array<string | Record<string, string>>;
|
||||
}>;
|
||||
password?: string;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SettingsYaml {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
seo?: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
seo?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
noIndex?: boolean;
|
||||
noFollow?: boolean;
|
||||
};
|
||||
exifOnHover?: boolean;
|
||||
map?: boolean;
|
||||
transitions?: boolean;
|
||||
theme?:
|
||||
description?: string;
|
||||
noIndex?: boolean;
|
||||
noFollow?: boolean;
|
||||
};
|
||||
exifOnHover?: boolean;
|
||||
map?: boolean;
|
||||
transitions?: boolean;
|
||||
theme?:
|
||||
| string
|
||||
| {
|
||||
preset?: string;
|
||||
@@ -130,22 +130,22 @@ export interface SettingsYaml {
|
||||
grain?: boolean;
|
||||
headerDot?: boolean;
|
||||
heroStyle?: string;
|
||||
};
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
footer?: FooterConfig;
|
||||
legal?: Partial<LegalConfig>;
|
||||
};
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
footer?: FooterConfig;
|
||||
legal?: Partial<LegalConfig>;
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
@@ -1,122 +1,133 @@
|
||||
import type { ThemeConfig, SettingsYaml } from './schema';
|
||||
|
||||
export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
studio: {
|
||||
preset: 'studio',
|
||||
accent: '#e60012',
|
||||
fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
|
||||
radius: 0,
|
||||
photoFrame: 'passepartout',
|
||||
grain: true,
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
minimal: {
|
||||
preset: 'minimal',
|
||||
accent: '#000000',
|
||||
fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'none',
|
||||
grain: false,
|
||||
headerDot: false,
|
||||
heroStyle: 'fullbleed',
|
||||
},
|
||||
editorial: {
|
||||
preset: 'editorial',
|
||||
accent: '#8B2500',
|
||||
fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
|
||||
radius: 0,
|
||||
photoFrame: 'shadow',
|
||||
grain: false,
|
||||
headerDot: false,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
classic: {
|
||||
preset: 'classic',
|
||||
accent: '#c49a3c',
|
||||
fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
|
||||
radius: 12,
|
||||
photoFrame: 'passepartout',
|
||||
grain: false,
|
||||
headerDot: true,
|
||||
heroStyle: 'minimal',
|
||||
},
|
||||
noir: {
|
||||
preset: 'noir',
|
||||
accent: '#ff6b35',
|
||||
fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'passepartout',
|
||||
grain: true,
|
||||
headerDot: false,
|
||||
heroStyle: 'fullbleed',
|
||||
},
|
||||
monograph: {
|
||||
preset: 'monograph',
|
||||
accent: '#333333',
|
||||
fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'none',
|
||||
grain: false,
|
||||
headerDot: false,
|
||||
heroStyle: 'typographic',
|
||||
},
|
||||
botanica: {
|
||||
preset: 'botanica',
|
||||
accent: '#4a7c59',
|
||||
fonts: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' },
|
||||
radius: 8,
|
||||
photoFrame: 'none',
|
||||
grain: false,
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
studio: {
|
||||
preset: 'studio',
|
||||
accent: '#e60012',
|
||||
fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
|
||||
radius: 0,
|
||||
photoFrame: 'passepartout',
|
||||
grain: true,
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
minimal: {
|
||||
preset: 'minimal',
|
||||
accent: '#000000',
|
||||
fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'none',
|
||||
grain: false,
|
||||
headerDot: false,
|
||||
heroStyle: 'fullbleed',
|
||||
},
|
||||
editorial: {
|
||||
preset: 'editorial',
|
||||
accent: '#8B2500',
|
||||
fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
|
||||
radius: 0,
|
||||
photoFrame: 'shadow',
|
||||
grain: false,
|
||||
headerDot: false,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
classic: {
|
||||
preset: 'classic',
|
||||
accent: '#c49a3c',
|
||||
fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
|
||||
radius: 12,
|
||||
photoFrame: 'passepartout',
|
||||
grain: false,
|
||||
headerDot: true,
|
||||
heroStyle: 'minimal',
|
||||
},
|
||||
noir: {
|
||||
preset: 'noir',
|
||||
accent: '#ff6b35',
|
||||
fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'passepartout',
|
||||
grain: true,
|
||||
headerDot: false,
|
||||
heroStyle: 'fullbleed',
|
||||
},
|
||||
monograph: {
|
||||
preset: 'monograph',
|
||||
accent: '#333333',
|
||||
fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'none',
|
||||
grain: false,
|
||||
headerDot: false,
|
||||
heroStyle: 'typographic',
|
||||
},
|
||||
botanica: {
|
||||
preset: 'botanica',
|
||||
accent: '#4a7c59',
|
||||
fonts: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' },
|
||||
radius: 8,
|
||||
photoFrame: 'none',
|
||||
grain: false,
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
};
|
||||
|
||||
export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
||||
export const VALID_HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
||||
export const VALID_HERO_STYLES = [
|
||||
'split',
|
||||
'fullbleed',
|
||||
'minimal',
|
||||
'stacked',
|
||||
'typographic',
|
||||
'mosaic',
|
||||
];
|
||||
export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
|
||||
|
||||
export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
||||
if (!raw) return { ...THEME_PRESETS.studio };
|
||||
if (!raw) return { ...THEME_PRESETS.studio };
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
const preset = THEME_PRESETS[raw];
|
||||
if (!preset) {
|
||||
throw new Error(`Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`);
|
||||
}
|
||||
return { ...preset };
|
||||
if (typeof raw === 'string') {
|
||||
const preset = THEME_PRESETS[raw];
|
||||
if (!preset) {
|
||||
throw new Error(
|
||||
`Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return { ...preset };
|
||||
}
|
||||
|
||||
const baseName = raw.preset ?? 'studio';
|
||||
const base = THEME_PRESETS[baseName];
|
||||
if (!base) {
|
||||
throw new Error(`Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`);
|
||||
}
|
||||
const baseName = raw.preset ?? 'studio';
|
||||
const base = THEME_PRESETS[baseName];
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
`Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
preset: baseName,
|
||||
accent: raw.accent ?? base.accent,
|
||||
fonts: {
|
||||
heading: raw.fonts?.heading ?? base.fonts.heading,
|
||||
body: raw.fonts?.body ?? base.fonts.body,
|
||||
caption: raw.fonts?.caption ?? base.fonts.caption,
|
||||
},
|
||||
radius: raw.radius ?? base.radius,
|
||||
photoFrame: VALID_PHOTO_FRAMES.includes(raw.photoFrame ?? '')
|
||||
? (raw.photoFrame as ThemeConfig['photoFrame'])
|
||||
: base.photoFrame,
|
||||
grain: raw.grain ?? base.grain,
|
||||
headerDot: raw.headerDot ?? base.headerDot,
|
||||
heroStyle: VALID_HERO_STYLES.includes(raw.heroStyle ?? '')
|
||||
? (raw.heroStyle as ThemeConfig['heroStyle'])
|
||||
: base.heroStyle,
|
||||
};
|
||||
return {
|
||||
preset: baseName,
|
||||
accent: raw.accent ?? base.accent,
|
||||
fonts: {
|
||||
heading: raw.fonts?.heading ?? base.fonts.heading,
|
||||
body: raw.fonts?.body ?? base.fonts.body,
|
||||
caption: raw.fonts?.caption ?? base.fonts.caption,
|
||||
},
|
||||
radius: raw.radius ?? base.radius,
|
||||
photoFrame: VALID_PHOTO_FRAMES.includes(raw.photoFrame ?? '')
|
||||
? (raw.photoFrame as ThemeConfig['photoFrame'])
|
||||
: base.photoFrame,
|
||||
grain: raw.grain ?? base.grain,
|
||||
headerDot: raw.headerDot ?? base.headerDot,
|
||||
heroStyle: VALID_HERO_STYLES.includes(raw.heroStyle ?? '')
|
||||
? (raw.heroStyle as ThemeConfig['heroStyle'])
|
||||
: base.heroStyle,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGoogleFontsUrl(theme: ThemeConfig): string {
|
||||
const weights = '300;400;500;600';
|
||||
const families = [...new Set([theme.fonts.heading, theme.fonts.body, theme.fonts.caption])];
|
||||
const params = families.map((f) => `family=${f.replace(/ /g, '+')}:wght@${weights}`).join('&');
|
||||
return `https://fonts.googleapis.com/css2?${params}&display=swap`;
|
||||
const weights = '300;400;500;600';
|
||||
const families = [...new Set([theme.fonts.heading, theme.fonts.body, theme.fonts.caption])];
|
||||
const params = families.map((f) => `family=${f.replace(/ /g, '+')}:wght@${weights}`).join('&');
|
||||
return `https://fonts.googleapis.com/css2?${params}&display=swap`;
|
||||
}
|
||||
|
||||
@@ -29,10 +29,12 @@ function parseEnv(): Env {
|
||||
const apiKey = process.env.IMMICH_API_KEY || '';
|
||||
|
||||
const cacheTtlStr = process.env.CACHE_TTL;
|
||||
const cacheTtl = cacheTtlStr && !isNaN(parseInt(cacheTtlStr, 10)) ? parseInt(cacheTtlStr, 10) : 300;
|
||||
const cacheTtl =
|
||||
cacheTtlStr && !isNaN(parseInt(cacheTtlStr, 10)) ? parseInt(cacheTtlStr, 10) : 300;
|
||||
|
||||
const rateLimitStr = process.env.RATE_LIMIT_RPM;
|
||||
const rateLimit = rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600;
|
||||
const rateLimit =
|
||||
rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600;
|
||||
|
||||
return {
|
||||
IMMICH_API_URL: apiUrl,
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface ImmichExifInfo {
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
|
||||
export type ImageSize = 'thumbnail' | 'preview' | 'original';
|
||||
|
||||
/** Enriched subpage with album metadata (for rendering cards). */
|
||||
@@ -325,7 +324,6 @@ class ImmichClient {
|
||||
return asset;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the Immich server is reachable.
|
||||
*/
|
||||
|
||||
@@ -4,13 +4,13 @@ import { cache } from './cache';
|
||||
|
||||
/** A clustered map marker — one per unique city/country. */
|
||||
export interface MapLocation {
|
||||
city: string;
|
||||
country: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
photoCount: number;
|
||||
coverAssetId: string;
|
||||
albums: { name: string; slug: string; subpageSlug?: string }[];
|
||||
city: string;
|
||||
country: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
photoCount: number;
|
||||
coverAssetId: string;
|
||||
albums: { name: string; slug: string; subpageSlug?: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,68 +18,68 @@ export interface MapLocation {
|
||||
* Returns one entry per unique city+country with averaged lat/lng.
|
||||
*/
|
||||
export async function getMapData(): Promise<MapLocation[]> {
|
||||
const config = getConfig();
|
||||
const cacheKey = 'map-data';
|
||||
const cached = cache.get<MapLocation[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
const config = getConfig();
|
||||
const cacheKey = 'map-data';
|
||||
const cached = cache.get<MapLocation[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const albums = await immich.getAlbums();
|
||||
const albums = await immich.getAlbums();
|
||||
|
||||
// Build a lookup: album ID → { name, slug, subpageSlug? }
|
||||
const albumMeta = new Map<string, { name: string; slug: string; subpageSlug?: string }>();
|
||||
for (const a of albums) {
|
||||
// Check if this album belongs to a subpage
|
||||
const sp = config.subpages.find((s) => s.albumIds.includes(a.id));
|
||||
albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug });
|
||||
// Build a lookup: album ID → { name, slug, subpageSlug? }
|
||||
const albumMeta = new Map<string, { name: string; slug: string; subpageSlug?: string }>();
|
||||
for (const a of albums) {
|
||||
// Check if this album belongs to a subpage
|
||||
const sp = config.subpages.find((s) => s.albumIds.includes(a.id));
|
||||
albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug });
|
||||
}
|
||||
|
||||
// Fetch full album data (with assets) for each
|
||||
const fullAlbums = await Promise.all(albums.map((a) => immich.getAlbum(a.id)));
|
||||
|
||||
// Bucket assets by city+country
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{ lats: number[]; lngs: number[]; count: number; coverAssetId: string; albumIds: Set<string> }
|
||||
>();
|
||||
|
||||
for (const album of fullAlbums) {
|
||||
if (!album) continue;
|
||||
for (const asset of album.assets) {
|
||||
const exif = asset.exifInfo;
|
||||
if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue;
|
||||
|
||||
const key = `${exif.city}|${exif.country}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { lats: [], lngs: [], count: 0, coverAssetId: asset.id, albumIds: new Set() };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
bucket.lats.push(exif.latitude);
|
||||
bucket.lngs.push(exif.longitude);
|
||||
bucket.count++;
|
||||
bucket.albumIds.add(album.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch full album data (with assets) for each
|
||||
const fullAlbums = await Promise.all(albums.map((a) => immich.getAlbum(a.id)));
|
||||
// Convert buckets to MapLocation[]
|
||||
const locations: MapLocation[] = [];
|
||||
for (const [key, bucket] of buckets) {
|
||||
const [city, country] = key.split('|');
|
||||
const lat = bucket.lats.reduce((a, b) => a + b, 0) / bucket.lats.length;
|
||||
const lng = bucket.lngs.reduce((a, b) => a + b, 0) / bucket.lngs.length;
|
||||
locations.push({
|
||||
city,
|
||||
country,
|
||||
lat,
|
||||
lng,
|
||||
photoCount: bucket.count,
|
||||
coverAssetId: bucket.coverAssetId,
|
||||
albums: [...bucket.albumIds]
|
||||
.map((id) => albumMeta.get(id))
|
||||
.filter((m): m is NonNullable<typeof m> => !!m),
|
||||
});
|
||||
}
|
||||
|
||||
// Bucket assets by city+country
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{ lats: number[]; lngs: number[]; count: number; coverAssetId: string; albumIds: Set<string> }
|
||||
>();
|
||||
|
||||
for (const album of fullAlbums) {
|
||||
if (!album) continue;
|
||||
for (const asset of album.assets) {
|
||||
const exif = asset.exifInfo;
|
||||
if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue;
|
||||
|
||||
const key = `${exif.city}|${exif.country}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { lats: [], lngs: [], count: 0, coverAssetId: asset.id, albumIds: new Set() };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
bucket.lats.push(exif.latitude);
|
||||
bucket.lngs.push(exif.longitude);
|
||||
bucket.count++;
|
||||
bucket.albumIds.add(album.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert buckets to MapLocation[]
|
||||
const locations: MapLocation[] = [];
|
||||
for (const [key, bucket] of buckets) {
|
||||
const [city, country] = key.split('|');
|
||||
const lat = bucket.lats.reduce((a, b) => a + b, 0) / bucket.lats.length;
|
||||
const lng = bucket.lngs.reduce((a, b) => a + b, 0) / bucket.lngs.length;
|
||||
locations.push({
|
||||
city,
|
||||
country,
|
||||
lat,
|
||||
lng,
|
||||
photoCount: bucket.count,
|
||||
coverAssetId: bucket.coverAssetId,
|
||||
albums: [...bucket.albumIds]
|
||||
.map((id) => albumMeta.get(id))
|
||||
.filter((m): m is NonNullable<typeof m> => !!m),
|
||||
});
|
||||
}
|
||||
|
||||
cache.set(cacheKey, locations, config.cacheTtl);
|
||||
return locations;
|
||||
cache.set(cacheKey, locations, config.cacheTtl);
|
||||
return locations;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user