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