feat: visual polish & UX — fade-in, theme toggle, scroll-to-top, footer, cursor, badge
- Add FadeIn component (IntersectionObserver scroll-reveal + load animation) - Add dark/light theme toggle (useSyncExternalStore + localStorage) - Add scroll-to-top button (appears after 300px scroll) - Add configurable footer with social links (gallery.yaml) - Add crosshair cursor on photo grid items - Add 'X photos' count badge on subpage cover images - Add light theme color palette in tokens.css
This commit is contained in:
@@ -6,16 +6,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { env } from './env';
|
||||
|
||||
// ── Env helpers ────────────────────────────────────────────────
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// ── Env helpers (now handled by lib/env.ts via Zod) ───────────
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +23,14 @@ export interface SubpageConfig {
|
||||
grid?: Partial<GridConfig>;
|
||||
}
|
||||
|
||||
/** Footer configuration for social links. */
|
||||
export interface FooterConfig {
|
||||
name?: string;
|
||||
instagram?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
/** Raw YAML structure (before validation). */
|
||||
interface GalleryYaml {
|
||||
hero?: string | string[];
|
||||
@@ -41,6 +42,12 @@ interface GalleryYaml {
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
footer?: {
|
||||
name?: string;
|
||||
instagram?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
};
|
||||
subpages?: Array<{
|
||||
name: string;
|
||||
albums: string[];
|
||||
@@ -62,7 +69,7 @@ function loadGalleryYaml(): GalleryYaml {
|
||||
if (!fs.existsSync(yamlPath)) {
|
||||
throw new Error(
|
||||
`Gallery config not found: ${yamlPath}\n` +
|
||||
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
|
||||
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,8 +106,8 @@ export function slugify(name: string): string {
|
||||
.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
|
||||
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens
|
||||
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
||||
}
|
||||
|
||||
// ── Config type ────────────────────────────────────────────────
|
||||
@@ -126,6 +133,8 @@ export interface AppConfig {
|
||||
exifOnHover: boolean;
|
||||
/** Photo grid layout configuration. */
|
||||
grid: GridConfig;
|
||||
/** Footer social links config. */
|
||||
footer: FooterConfig | null;
|
||||
cacheTtl: number;
|
||||
rateLimitRpm: number;
|
||||
}
|
||||
@@ -137,13 +146,14 @@ let _config: AppConfig | null = null;
|
||||
export function getConfig(): AppConfig {
|
||||
if (_config) return _config;
|
||||
|
||||
const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, '');
|
||||
const apiKey = requireEnv('IMMICH_API_KEY');
|
||||
const apiUrl = env.IMMICH_API_URL;
|
||||
const apiKey = env.IMMICH_API_KEY;
|
||||
const gallery = loadGalleryYaml();
|
||||
|
||||
// Parse standalone albums
|
||||
const standaloneAlbumIds = (gallery.albums ?? [])
|
||||
.map((id) => validateUuid(id, 'gallery.yaml albums'));
|
||||
const standaloneAlbumIds = (gallery.albums ?? []).map((id) =>
|
||||
validateUuid(id, 'gallery.yaml albums'),
|
||||
);
|
||||
|
||||
if (standaloneAlbumIds.length === 0 && (!gallery.subpages || gallery.subpages.length === 0)) {
|
||||
throw new Error('gallery.yaml must define at least one album or subpage');
|
||||
@@ -152,21 +162,31 @@ export function getConfig(): AppConfig {
|
||||
// Parse subpages
|
||||
const subpages: SubpageConfig[] = (gallery.subpages ?? []).map((sp) => {
|
||||
if (!sp.name || !sp.albums || sp.albums.length === 0) {
|
||||
throw new Error(`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`);
|
||||
throw new Error(
|
||||
`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: sp.name,
|
||||
slug: slugify(sp.name),
|
||||
albumIds: sp.albums.map((id) => validateUuid(id, `subpage "${sp.name}"`)),
|
||||
password: sp.password,
|
||||
...(sp.grid ? {
|
||||
grid: {
|
||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||
...(sp.grid.layout != null ? { layout: (sp.grid.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'] } : {}),
|
||||
},
|
||||
} : {}),
|
||||
...(sp.grid
|
||||
? {
|
||||
grid: {
|
||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||
...(sp.grid.layout != null
|
||||
? {
|
||||
layout: (sp.grid.layout === 'uniform'
|
||||
? 'uniform'
|
||||
: 'masonry') as GridConfig['layout'],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -182,11 +202,12 @@ export function getConfig(): AppConfig {
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||
subpages,
|
||||
siteTitle: process.env.SITE_TITLE || 'Gallery',
|
||||
siteSubtitle: process.env.SITE_SUBTITLE || '',
|
||||
siteTitle: env.SITE_TITLE,
|
||||
siteSubtitle: env.SITE_SUBTITLE,
|
||||
heroImages: gallery.hero
|
||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
|
||||
.map((id) => validateUuid(id, 'gallery.yaml hero'))
|
||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
|
||||
validateUuid(id, 'gallery.yaml hero'),
|
||||
)
|
||||
: [],
|
||||
exifOnHover: gallery.exifOnHover !== false,
|
||||
grid: {
|
||||
@@ -195,8 +216,16 @@ export function getConfig(): AppConfig {
|
||||
aspectRatio: gallery.grid?.aspectRatio ?? '1',
|
||||
layout: (gallery.grid?.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'],
|
||||
},
|
||||
cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000,
|
||||
rateLimitRpm: parseInt(process.env.RATE_LIMIT_RPM || '120', 10),
|
||||
footer: gallery.footer
|
||||
? {
|
||||
name: gallery.footer.name,
|
||||
instagram: gallery.footer.instagram,
|
||||
email: gallery.footer.email,
|
||||
website: gallery.footer.website,
|
||||
}
|
||||
: null,
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
};
|
||||
|
||||
return _config;
|
||||
|
||||
Reference in New Issue
Block a user