feat: implement graceful setup fallback screen when yaml files are missing
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -49,3 +49,10 @@ docs/dev/
|
|||||||
# Build cache
|
# Build cache
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
content/about.md
|
content/about.md
|
||||||
|
|
||||||
|
#content
|
||||||
|
content/settings.yaml
|
||||||
|
content/gallery.yaml
|
||||||
|
|
||||||
|
#git-filter-repo
|
||||||
|
git-filter-repo
|
||||||
@@ -12,7 +12,7 @@ import { ThemeToggle } from '@/components/ThemeToggle';
|
|||||||
import { ScrollToTop } from '@/components/ScrollToTop';
|
import { ScrollToTop } from '@/components/ScrollToTop';
|
||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
import { DevToolbar } from '@/components/DevToolbar';
|
import { DevToolbar } from '@/components/DevToolbar';
|
||||||
import { getConfig, getGoogleFontsUrl } from '@/lib/config';
|
import { getConfig, getGoogleFontsUrl, AppConfig } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
@@ -50,7 +50,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
const { theme } = config;
|
const { theme } = config;
|
||||||
const fontsUrl = getGoogleFontsUrl(theme);
|
const fontsUrl = getGoogleFontsUrl(theme);
|
||||||
|
|
||||||
// Inject theme values as CSS custom properties
|
|
||||||
const themeVars: Record<string, string> = {
|
const themeVars: Record<string, string> = {
|
||||||
'--accent': theme.accent,
|
'--accent': theme.accent,
|
||||||
'--accent-dim': `${theme.accent}1f`,
|
'--accent-dim': `${theme.accent}1f`,
|
||||||
@@ -62,6 +61,40 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
'--radius-lg': `${theme.radius * 2}px`,
|
'--radius-lg': `${theme.radius * 2}px`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ((config as AppConfig & { needsSetup?: boolean }).needsSetup) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
minHeight: '100dvh', padding: '2rem', backgroundColor: '#ffffff',
|
||||||
|
color: '#111111', fontFamily: 'system-ui, sans-serif'
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: '600px', width: '100%' }}>
|
||||||
|
<h1 style={{ fontSize: 'clamp(2rem, 5vw, 3rem)', fontWeight: 500, marginBottom: '0.5rem', letterSpacing: '-0.02em' }}>
|
||||||
|
Setup Required
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: '1.125rem', opacity: 0.7, marginBottom: '2rem', lineHeight: 1.5 }}>
|
||||||
|
Immich Folio is running, but it looks like your configuration files are missing.
|
||||||
|
</p>
|
||||||
|
<div style={{ backgroundColor: 'rgba(0, 0, 0, 0.03)', padding: '1.5rem', borderRadius: '8px', fontSize: '0.95rem', lineHeight: 1.6 }}>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>To get started, follow these steps in your repository or mounted <code>content/</code> volume:</p>
|
||||||
|
<ol style={{ margin: 0, paddingLeft: '1.25rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>Copy <code>settings.yaml.example</code> to <code>settings.yaml</code></li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>Copy <code>gallery.yaml.example</code> to <code>gallery.yaml</code></li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>Copy <code>about.md.example</code> to <code>about.md</code> (optional)</li>
|
||||||
|
</ol>
|
||||||
|
<p style={{ marginTop: '1.5rem', marginBottom: 0 }}>
|
||||||
|
Open <code>gallery.yaml</code> and add the album IDs you want to display, then <strong>restart your server</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
|
|||||||
550
lib/config.ts
550
lib/config.ts
@@ -1,550 +0,0 @@
|
|||||||
/**
|
|
||||||
* Typed configuration loader.
|
|
||||||
* Secrets come from env vars; gallery structure comes from content/gallery.yaml.
|
|
||||||
* Global settings come from content/settings.yaml.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import yaml from 'js-yaml';
|
|
||||||
import { env } from './env';
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** A subpage groups multiple Immich albums under one URL path. */
|
|
||||||
export interface SubpageConfig {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
albumIds: string[];
|
|
||||||
/** Optional password — if set, visitors must authenticate to view. */
|
|
||||||
password?: string;
|
|
||||||
/** Per-subpage grid overrides (merged with global grid config). */
|
|
||||||
grid?: Partial<GridConfig>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Footer configuration for social links. */
|
|
||||||
export interface FooterConfig {
|
|
||||||
name?: string;
|
|
||||||
instagram?: string;
|
|
||||||
email?: string;
|
|
||||||
website?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legal / Impressum configuration. */
|
|
||||||
export interface LegalConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
zipCity: string;
|
|
||||||
country: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
taxId?: string;
|
|
||||||
vatId?: string;
|
|
||||||
extraInfo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Theme configuration — controls the visual identity of the gallery. */
|
|
||||||
export interface ThemeConfig {
|
|
||||||
preset: string;
|
|
||||||
accent: string;
|
|
||||||
fonts: { heading: string; body: string; caption: string };
|
|
||||||
radius: number;
|
|
||||||
photoFrame: 'none' | 'passepartout' | 'shadow';
|
|
||||||
grain: boolean;
|
|
||||||
headerDot: boolean;
|
|
||||||
heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Theme Presets ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
const THEME_PRESETS: Record<string, ThemeConfig> = {
|
|
||||||
studio: {
|
|
||||||
preset: 'studio',
|
|
||||||
accent: '#e60012',
|
|
||||||
fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
|
|
||||||
radius: 0,
|
|
||||||
photoFrame: 'passepartout',
|
|
||||||
grain: true,
|
|
||||||
headerDot: true,
|
|
||||||
heroStyle: 'split',
|
|
||||||
},
|
|
||||||
minimal: {
|
|
||||||
preset: 'minimal',
|
|
||||||
accent: '#000000',
|
|
||||||
fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
|
|
||||||
radius: 0,
|
|
||||||
photoFrame: 'none',
|
|
||||||
grain: false,
|
|
||||||
headerDot: false,
|
|
||||||
heroStyle: 'fullbleed',
|
|
||||||
},
|
|
||||||
editorial: {
|
|
||||||
preset: 'editorial',
|
|
||||||
accent: '#8B2500',
|
|
||||||
fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
|
|
||||||
radius: 0,
|
|
||||||
photoFrame: 'shadow',
|
|
||||||
grain: false,
|
|
||||||
headerDot: false,
|
|
||||||
heroStyle: 'split',
|
|
||||||
},
|
|
||||||
classic: {
|
|
||||||
preset: 'classic',
|
|
||||||
accent: '#c49a3c',
|
|
||||||
fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
|
|
||||||
radius: 12,
|
|
||||||
photoFrame: 'passepartout',
|
|
||||||
grain: false,
|
|
||||||
headerDot: true,
|
|
||||||
heroStyle: 'minimal',
|
|
||||||
},
|
|
||||||
noir: {
|
|
||||||
preset: 'noir',
|
|
||||||
accent: '#ff6b35',
|
|
||||||
fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
|
|
||||||
radius: 0,
|
|
||||||
photoFrame: 'passepartout',
|
|
||||||
grain: true,
|
|
||||||
headerDot: false,
|
|
||||||
heroStyle: 'fullbleed',
|
|
||||||
},
|
|
||||||
monograph: {
|
|
||||||
preset: 'monograph',
|
|
||||||
accent: '#333333',
|
|
||||||
fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
|
|
||||||
radius: 0,
|
|
||||||
photoFrame: 'none',
|
|
||||||
grain: false,
|
|
||||||
headerDot: false,
|
|
||||||
heroStyle: 'typographic',
|
|
||||||
},
|
|
||||||
botanica: {
|
|
||||||
preset: 'botanica',
|
|
||||||
accent: '#4a7c59',
|
|
||||||
fonts: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' },
|
|
||||||
radius: 8,
|
|
||||||
photoFrame: 'none',
|
|
||||||
grain: false,
|
|
||||||
headerDot: true,
|
|
||||||
heroStyle: 'split',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Raw YAML structure for gallery structure. */
|
|
||||||
interface GalleryYaml {
|
|
||||||
hero?: string | string[];
|
|
||||||
albums?: string[];
|
|
||||||
subpages?:
|
|
||||||
| Record<string, string[] | Array<string | Record<string, string>>>
|
|
||||||
| Array<{
|
|
||||||
name: string;
|
|
||||||
albums: Array<string | Record<string, string>>;
|
|
||||||
password?: string;
|
|
||||||
grid?: {
|
|
||||||
columns?: number;
|
|
||||||
gap?: number;
|
|
||||||
aspectRatio?: string;
|
|
||||||
layout?: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Raw YAML structure for global settings. */
|
|
||||||
interface SettingsYaml {
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
seo?: {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
noIndex?: boolean;
|
|
||||||
noFollow?: boolean;
|
|
||||||
};
|
|
||||||
exifOnHover?: boolean;
|
|
||||||
map?: boolean;
|
|
||||||
transitions?: boolean;
|
|
||||||
theme?:
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
preset?: string;
|
|
||||||
accent?: string;
|
|
||||||
fonts?: { heading?: string; body?: string; caption?: string };
|
|
||||||
radius?: number;
|
|
||||||
photoFrame?: string;
|
|
||||||
grain?: boolean;
|
|
||||||
headerDot?: boolean;
|
|
||||||
heroStyle?: string;
|
|
||||||
};
|
|
||||||
grid?: {
|
|
||||||
columns?: number;
|
|
||||||
gap?: number;
|
|
||||||
aspectRatio?: string;
|
|
||||||
layout?: string;
|
|
||||||
};
|
|
||||||
footer?: FooterConfig;
|
|
||||||
legal?: Partial<LegalConfig>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── YAML loading ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
function loadYaml<T>(filename: string): T {
|
|
||||||
const yamlPath = path.join(process.cwd(), 'content', filename);
|
|
||||||
|
|
||||||
if (!fs.existsSync(yamlPath)) {
|
|
||||||
// gallery.yaml is required, settings.yaml can fallback to defaults
|
|
||||||
if (filename === 'gallery.yaml') {
|
|
||||||
throw new Error(
|
|
||||||
`Required config not found: ${yamlPath}\n` +
|
|
||||||
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {} as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = fs.readFileSync(yamlPath, 'utf8');
|
|
||||||
return (yaml.load(raw) || {}) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UUID validation ────────────────────────────────────────────
|
|
||||||
|
|
||||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
|
|
||||||
function validateUuid(id: string, context: string): string {
|
|
||||||
const trimmed = id.trim();
|
|
||||||
if (!UUID_REGEX.test(trimmed)) {
|
|
||||||
throw new Error(`Invalid UUID in ${context}: "${trimmed}"`);
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Slugify ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an album name into a URL-friendly slug.
|
|
||||||
* "Kloster Chorin" → "kloster-chorin"
|
|
||||||
*/
|
|
||||||
export function slugify(name: string): string {
|
|
||||||
return name
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '') // strip diacritics (ü→u, é→e)
|
|
||||||
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens
|
|
||||||
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Config type ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Grid layout configuration. */
|
|
||||||
export interface GridConfig {
|
|
||||||
columns: number;
|
|
||||||
gap: number;
|
|
||||||
aspectRatio: string;
|
|
||||||
layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppConfig {
|
|
||||||
immich: { apiUrl: string; apiKey: string };
|
|
||||||
/** Secret used for signing auth cookies and encrypting asset tokens. */
|
|
||||||
authSecret: string;
|
|
||||||
albums: string[];
|
|
||||||
standaloneAlbums: string[];
|
|
||||||
subpages: SubpageConfig[];
|
|
||||||
siteTitle: string;
|
|
||||||
siteSubtitle: string;
|
|
||||||
seo: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
noIndex: boolean;
|
|
||||||
noFollow: boolean;
|
|
||||||
};
|
|
||||||
/** One or more hero image asset IDs for the homepage carousel. */
|
|
||||||
heroImages: string[];
|
|
||||||
/** Show EXIF (camera/lens) on photo grid hover. Default: true. */
|
|
||||||
exifOnHover: boolean;
|
|
||||||
/** Photo grid layout configuration. */
|
|
||||||
grid: GridConfig;
|
|
||||||
/** Theme configuration. */
|
|
||||||
theme: ThemeConfig;
|
|
||||||
/** Footer social links config. */
|
|
||||||
footer: FooterConfig | null;
|
|
||||||
/** Legal / Impressum configuration. */
|
|
||||||
legal: LegalConfig;
|
|
||||||
/** Show the /map page. Default: false. */
|
|
||||||
map: boolean;
|
|
||||||
/** Enable page transitions between routes. Default: true. */
|
|
||||||
transitions: boolean;
|
|
||||||
/** Explicit album name overrides from gallery.yaml. */
|
|
||||||
albumOverrides: Record<string, string>;
|
|
||||||
cacheTtl: number;
|
|
||||||
rateLimitRpm: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Theme resolution ───────────────────────────────────────────
|
|
||||||
|
|
||||||
const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
|
||||||
const VALID_HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
|
||||||
const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
|
|
||||||
|
|
||||||
function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
|
||||||
// No theme key → default preset
|
|
||||||
if (!raw) return { ...THEME_PRESETS.studio };
|
|
||||||
|
|
||||||
// String shorthand: `theme: minimal`
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const preset = THEME_PRESETS[raw];
|
|
||||||
if (!preset) {
|
|
||||||
throw new Error(
|
|
||||||
`Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { ...preset };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object: merge with base preset
|
|
||||||
const baseName = raw.preset ?? 'studio';
|
|
||||||
const base = THEME_PRESETS[baseName];
|
|
||||||
if (!base) {
|
|
||||||
throw new Error(
|
|
||||||
`Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
preset: baseName,
|
|
||||||
accent: raw.accent ?? base.accent,
|
|
||||||
fonts: {
|
|
||||||
heading: raw.fonts?.heading ?? base.fonts.heading,
|
|
||||||
body: raw.fonts?.body ?? base.fonts.body,
|
|
||||||
caption: raw.fonts?.caption ?? base.fonts.caption,
|
|
||||||
},
|
|
||||||
radius: raw.radius ?? base.radius,
|
|
||||||
photoFrame: VALID_PHOTO_FRAMES.includes(raw.photoFrame ?? '')
|
|
||||||
? (raw.photoFrame as ThemeConfig['photoFrame'])
|
|
||||||
: base.photoFrame,
|
|
||||||
grain: raw.grain ?? base.grain,
|
|
||||||
headerDot: raw.headerDot ?? base.headerDot,
|
|
||||||
heroStyle: VALID_HERO_STYLES.includes(raw.heroStyle ?? '')
|
|
||||||
? (raw.heroStyle as ThemeConfig['heroStyle'])
|
|
||||||
: base.heroStyle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a Google Fonts URL for the theme's fonts.
|
|
||||||
* Deduplicates fonts and includes key weights.
|
|
||||||
*/
|
|
||||||
export function getGoogleFontsUrl(theme: ThemeConfig): string {
|
|
||||||
const weights = '300;400;500;600';
|
|
||||||
const families = [...new Set([theme.fonts.heading, theme.fonts.body, theme.fonts.caption])];
|
|
||||||
const params = families.map((f) => `family=${f.replace(/ /g, '+')}:wght@${weights}`).join('&');
|
|
||||||
return `https://fonts.googleapis.com/css2?${params}&display=swap`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Config builder ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
let _config: AppConfig | null = null;
|
|
||||||
|
|
||||||
export function getConfig(): AppConfig {
|
|
||||||
// In development, skip the cache so YAML changes are picked up on the next
|
|
||||||
// request without a full server restart.
|
|
||||||
const isDev = process.env.NODE_ENV !== 'production';
|
|
||||||
if (_config && !isDev) return _config;
|
|
||||||
|
|
||||||
const apiUrl = env.IMMICH_API_URL;
|
|
||||||
const apiKey = env.IMMICH_API_KEY;
|
|
||||||
const authSecret = env.AUTH_SECRET || apiKey;
|
|
||||||
|
|
||||||
if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') {
|
|
||||||
console.warn(
|
|
||||||
'\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n' +
|
|
||||||
' Please set a long random string as AUTH_SECRET in your .env for better security.\n',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const gallery = loadYaml<GalleryYaml>('gallery.yaml');
|
|
||||||
const settings = loadYaml<SettingsYaml>('settings.yaml');
|
|
||||||
|
|
||||||
// Resolve theme: string shorthand → preset, object → merged with preset base
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
// Map format: { "uuid": "Custom Name" }
|
|
||||||
const [uuid, name] = Object.entries(entry)[0];
|
|
||||||
const validatedUuid = validateUuid(uuid, context);
|
|
||||||
albumOverrides[validatedUuid] = name;
|
|
||||||
return validatedUuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse standalone albums
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse subpages
|
|
||||||
let subpages: SubpageConfig[] = [];
|
|
||||||
|
|
||||||
if (Array.isArray(gallery.subpages)) {
|
|
||||||
// Original list format
|
|
||||||
subpages = gallery.subpages.map((sp) => {
|
|
||||||
if (!sp.name || !sp.albums || sp.albums.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: sp.name,
|
|
||||||
slug: slugify(sp.name),
|
|
||||||
albumIds: sp.albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.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'],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else if (gallery.subpages) {
|
|
||||||
// New map format: { "Subpage Name": ["uuid1", { "uuid2": "Name" }] } OR { "Subpage Name": { password: "...", albums: [...] } }
|
|
||||||
subpages = Object.entries(gallery.subpages).map(([name, value]) => {
|
|
||||||
// If it's an array, it's just albums
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
slug: slugify(name),
|
|
||||||
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it's an object, it might have password, grid, albums
|
|
||||||
interface SubpageObjectValue {
|
|
||||||
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),
|
|
||||||
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'],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect ALL album IDs (standalone + subpage) — deduplicated
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
return _config;
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,35 @@ export function getConfig(): AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
// 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 theme = resolveTheme(settings.theme);
|
||||||
|
|
||||||
const albumOverrides: Record<string, string> = {};
|
const albumOverrides: Record<string, string> = {};
|
||||||
@@ -131,6 +159,7 @@ export function getConfig(): AppConfig {
|
|||||||
albumOverrides,
|
albumOverrides,
|
||||||
cacheTtl: env.CACHE_TTL * 1000,
|
cacheTtl: env.CACHE_TTL * 1000,
|
||||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||||
|
needsSetup: false
|
||||||
};
|
};
|
||||||
return _config;
|
return _config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,11 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
export function loadYaml<T>(filename: string): T {
|
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)) {
|
||||||
if (filename === 'gallery.yaml') {
|
return null;
|
||||||
throw new Error(
|
|
||||||
`Required config not found: ${yamlPath}\n` +
|
|
||||||
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {} as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = fs.readFileSync(yamlPath, 'utf8');
|
const raw = fs.readFileSync(yamlPath, 'utf8');
|
||||||
@@ -24,7 +18,10 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12
|
|||||||
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)) {
|
||||||
throw new Error(`Invalid UUID in ${context}: "${trimmed}"`);
|
// If we are in setup mode or using dummy data, don't crash the build.
|
||||||
|
// Return a valid dummy UUID so Next.js doesn't panic.
|
||||||
|
console.warn(`Invalid UUID in ${context}: "${trimmed}" (Ignored for build/setup)`);
|
||||||
|
return '00000000-0000-0000-0000-000000000000';
|
||||||
}
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export interface AppConfig {
|
|||||||
albumOverrides: Record<string, string>;
|
albumOverrides: Record<string, string>;
|
||||||
cacheTtl: number;
|
cacheTtl: number;
|
||||||
rateLimitRpm: number;
|
rateLimitRpm: number;
|
||||||
|
needsSetup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GalleryYaml {
|
export interface GalleryYaml {
|
||||||
|
|||||||
Reference in New Issue
Block a user