Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState, type CSSProperties } from 'react';
|
|
import * as Icons from '../Icons';
|
|
import OptionGrid, { toOptions } from '../fields/OptionGrid';
|
|
import { DEFAULT_PRESET } from '@/lib/config/theme';
|
|
import { effectiveTheme, FeatureRow, FieldError } from './fields';
|
|
import type { SectionProps } from './types';
|
|
|
|
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
|
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
|
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover'];
|
|
|
|
const PHOTO_FRAME_INFO: Record<string, { label: string; desc: string }> = {
|
|
none: { label: 'None', desc: 'Flush image with crisp edges' },
|
|
passepartout: {
|
|
label: 'Passepartout',
|
|
desc: 'Classic gallery matting border',
|
|
},
|
|
shadow: { label: 'Shadow', desc: 'Soft floating drop shadow' },
|
|
};
|
|
const PHOTO_FRAME_OPTIONS = toOptions(PHOTO_FRAMES, PHOTO_FRAME_INFO);
|
|
|
|
function PhotoFramePreview({ value }: { value: string }) {
|
|
return (
|
|
<div className="frame-card-preview">
|
|
<div className={`mini-frame-demo frame-${value}`}>
|
|
<div className="mini-frame-photo" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const HERO_STYLE_INFO: Record<string, { label: string; desc: string }> = {
|
|
split: { label: 'Split', desc: 'Side-by-side title & photo' },
|
|
fullbleed: { label: 'Fullbleed', desc: 'Edge-to-edge full width banner' },
|
|
minimal: { label: 'Minimal', desc: 'Centered title with subtle photo' },
|
|
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
|
|
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
|
|
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
|
|
cover: {
|
|
label: 'Cover (Experimental)',
|
|
desc: 'Fullscreen splash with a single Enter link',
|
|
},
|
|
};
|
|
const HERO_STYLE_OPTIONS = toOptions(HERO_STYLES, HERO_STYLE_INFO);
|
|
|
|
function HeroStylePreview({ value }: { value: string }) {
|
|
return (
|
|
<div className="hero-card-preview">
|
|
<div className={`mini-hero-demo hero-demo-${value}`}>
|
|
{value === 'split' && (
|
|
<>
|
|
<div className="hero-demo-text">
|
|
<div className="demo-line title" />
|
|
<div className="demo-line sub" />
|
|
</div>
|
|
<div className="hero-demo-photo" />
|
|
</>
|
|
)}
|
|
{value === 'fullbleed' && (
|
|
<div className="hero-demo-full">
|
|
<div className="demo-line title light" />
|
|
</div>
|
|
)}
|
|
{value === 'minimal' && (
|
|
<>
|
|
<div className="hero-demo-center-text">
|
|
<div className="demo-line title short" />
|
|
</div>
|
|
<div className="hero-demo-photo small" />
|
|
</>
|
|
)}
|
|
{value === 'stacked' && (
|
|
<>
|
|
<div className="hero-demo-text">
|
|
<div className="demo-line title" />
|
|
</div>
|
|
<div className="hero-demo-photo banner" />
|
|
</>
|
|
)}
|
|
{value === 'typographic' && (
|
|
<>
|
|
<div className="demo-line title giant" />
|
|
<div className="hero-demo-grid2">
|
|
<div className="hero-demo-photo" />
|
|
<div className="hero-demo-photo" />
|
|
</div>
|
|
</>
|
|
)}
|
|
{value === 'mosaic' && (
|
|
<div className="hero-demo-mosaic">
|
|
<div className="hero-demo-photo big" />
|
|
<div className="hero-demo-photo-col">
|
|
<div className="hero-demo-photo" />
|
|
<div className="hero-demo-photo" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Card metadata for the theme picker. `font`, `radius` and `frame` mirror the
|
|
* real preset definitions in lib/config/theme.ts so the mini mockups show what
|
|
* the preset actually does; `gap` is the visual density of its gallery grid.
|
|
*/
|
|
const THEME_INFO: Record<
|
|
string,
|
|
{
|
|
desc: string;
|
|
label: string;
|
|
accent: string;
|
|
bg: string;
|
|
tile: string;
|
|
font: string;
|
|
type: 'serif' | 'sans' | 'mono';
|
|
radius: number;
|
|
frame: 'none' | 'passepartout' | 'shadow';
|
|
gap: number;
|
|
}
|
|
> = {
|
|
studio: {
|
|
label: 'Studio',
|
|
desc: 'Clean, high-contrast grid with sans-serif type.',
|
|
bg: '#141414',
|
|
tile: '#242424',
|
|
accent: '#e60012',
|
|
font: 'Playfair Display',
|
|
type: 'serif',
|
|
radius: 0,
|
|
frame: 'passepartout',
|
|
gap: 4,
|
|
},
|
|
'studio-modern': {
|
|
label: 'Studio Modern',
|
|
desc: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.',
|
|
bg: '#121212',
|
|
tile: '#191919',
|
|
accent: '#e60012',
|
|
font: 'Archivo',
|
|
type: 'sans',
|
|
radius: 0,
|
|
frame: 'none',
|
|
gap: 3,
|
|
},
|
|
minimal: {
|
|
label: 'Minimal',
|
|
desc: 'Pure raw layouts with tiny gaps and high whitespace.',
|
|
bg: '#ffffff',
|
|
tile: '#f3f3f3',
|
|
accent: '#111111',
|
|
font: 'Geist',
|
|
type: 'sans',
|
|
radius: 0,
|
|
frame: 'none',
|
|
gap: 2,
|
|
},
|
|
editorial: {
|
|
label: 'Editorial',
|
|
desc: 'Warm backgrounds, elegant serifs and large headers.',
|
|
bg: '#fbf9f4',
|
|
tile: '#e5dfd4',
|
|
accent: '#b89053',
|
|
font: 'Bodoni Moda',
|
|
type: 'serif',
|
|
radius: 0,
|
|
frame: 'shadow',
|
|
gap: 8,
|
|
},
|
|
classic: {
|
|
label: 'Classic',
|
|
desc: 'Soft traditional photographer portfolio with shadows.',
|
|
bg: '#f7f7f7',
|
|
tile: '#ffffff',
|
|
accent: '#444444',
|
|
font: 'Cinzel',
|
|
type: 'serif',
|
|
radius: 12,
|
|
frame: 'passepartout',
|
|
gap: 7,
|
|
},
|
|
noir: {
|
|
label: 'Noir',
|
|
desc: 'High drama absolute pitch black, stark high-fashion look.',
|
|
bg: '#000000',
|
|
tile: '#151515',
|
|
accent: '#ffffff',
|
|
font: 'Libre Baskerville',
|
|
type: 'serif',
|
|
radius: 0,
|
|
frame: 'passepartout',
|
|
gap: 6,
|
|
},
|
|
monograph: {
|
|
label: 'Monograph',
|
|
desc: 'Typewriter monospace font, grid borders and document feel.',
|
|
bg: '#f4f4f6',
|
|
tile: '#ffffff',
|
|
accent: '#555555',
|
|
font: 'Instrument Serif',
|
|
type: 'mono',
|
|
radius: 0,
|
|
frame: 'none',
|
|
gap: 5,
|
|
},
|
|
};
|
|
|
|
/* The preset group sits below THEME_INFO on purpose. PRESET_OPTIONS is built
|
|
at module evaluation and reads it through themeInfo(); declared above, it
|
|
hit the temporal dead zone and the whole Settings page failed to load with
|
|
"Cannot access 'THEME_INFO' before initialization". tsc does not catch it
|
|
because the read goes through a hoisted function, and a production build
|
|
succeeds because the module is only evaluated in the browser (#542). */
|
|
/** The theme picker's own metadata, with the fallback the card grid needs. */
|
|
function themeInfo(value: string) {
|
|
return (
|
|
THEME_INFO[value] || {
|
|
label: value,
|
|
desc: '',
|
|
bg: '#fff',
|
|
tile: '#eee',
|
|
accent: '#333',
|
|
font: 'System',
|
|
type: 'sans' as const,
|
|
radius: 0,
|
|
frame: 'none' as const,
|
|
gap: 4,
|
|
}
|
|
);
|
|
}
|
|
|
|
const PRESET_OPTIONS = PRESETS.map((value) => ({
|
|
value,
|
|
label: themeInfo(value).label,
|
|
desc: themeInfo(value).desc,
|
|
}));
|
|
|
|
function PresetPreview({ value }: { value: string }) {
|
|
const i = themeInfo(value);
|
|
return (
|
|
<div className="preset-card-preview" data-frame={i.frame}>
|
|
<div className="mini-header">
|
|
<span className={`mini-specimen type-${i.type}`}>Aa</span>
|
|
<span className="mini-line" />
|
|
</div>
|
|
<div className="mini-grid">
|
|
<div className="mini-tile" />
|
|
<div className="mini-tile" />
|
|
<div className="mini-tile" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PresetSpecs({ value }: { value: string }) {
|
|
const i = themeInfo(value);
|
|
return (
|
|
<span className="preset-card-specs">
|
|
{i.font} · radius {i.radius} · {i.frame}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function ThemeSection({ settings, update, fieldErrors }: SectionProps) {
|
|
// Read off the page on mount: the toggle previews light and dark on the
|
|
// admin itself, and the page may already be in either.
|
|
const [currentMode, setCurrentMode] = useState<'dark' | 'light'>(() =>
|
|
typeof document !== 'undefined' &&
|
|
document.documentElement.getAttribute('data-theme') === 'light'
|
|
? 'light'
|
|
: 'dark',
|
|
);
|
|
|
|
const toggleMode = (mode: 'dark' | 'light') => {
|
|
setCurrentMode(mode);
|
|
if (typeof document !== 'undefined') {
|
|
document.documentElement.setAttribute('data-theme', mode);
|
|
localStorage.setItem('theme', mode);
|
|
}
|
|
};
|
|
|
|
// Same idea for the theme: an unset field shows the preset's own value, not
|
|
// a fallback of this form's — Classic used to read "split hero, no frame".
|
|
const theme = effectiveTheme(settings.theme);
|
|
|
|
return (
|
|
<div className="settings-panel theme-settings-panel">
|
|
<div className="settings-section-header">
|
|
<h3>
|
|
<Icons.IconPalette size={18} /> Theme Presets & Color Mode
|
|
</h3>
|
|
<p className="settings-section-sub">
|
|
Choose a typography preset and preview in Light or Dark mode.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="admin-field">
|
|
<label htmlFor="theme-visitor-default-mode">Visitor Default Mode</label>
|
|
<select
|
|
id="theme-visitor-default-mode"
|
|
aria-label="Visitor default mode"
|
|
value={settings.mode || 'dark'}
|
|
onChange={(e) => update('mode', e.target.value)}
|
|
>
|
|
<option value="dark">Dark</option>
|
|
<option value="light">Light</option>
|
|
<option value="auto">Follow the visitor's system</option>
|
|
</select>
|
|
<p className="admin-field-hint">
|
|
What a first-time visitor sees. The light/dark toggle in the site header still lets them
|
|
choose, and their choice is remembered on their device. The preview below is your own, and
|
|
does not change this.
|
|
</p>
|
|
</div>
|
|
|
|
<OptionGrid
|
|
label="Preset"
|
|
options={PRESET_OPTIONS}
|
|
value={settings.theme?.preset || DEFAULT_PRESET}
|
|
onSelect={(v) => update('theme.preset', v)}
|
|
cardClassName="theme-preset-card"
|
|
renderPreview={(o) => <PresetPreview value={o.value} />}
|
|
renderSpecs={(o) => <PresetSpecs value={o.value} />}
|
|
cardStyle={(o) => {
|
|
const i = themeInfo(o.value);
|
|
return {
|
|
'--preset-accent': i.accent,
|
|
'--preset-bg': i.bg,
|
|
'--preset-tile': i.tile,
|
|
'--preset-radius': `${i.radius}px`,
|
|
'--preset-gap': `${i.gap}px`,
|
|
} as CSSProperties;
|
|
}}
|
|
/>
|
|
|
|
<div className="admin-field">
|
|
<span className="admin-field-label">Color Mode</span>
|
|
<p
|
|
style={{
|
|
fontSize: '0.78rem',
|
|
color: 'var(--admin-text-muted)',
|
|
margin: '0.25rem 0 0.6rem',
|
|
lineHeight: '1.4',
|
|
}}
|
|
>
|
|
Presets like <strong>Editorial</strong>, <strong>Minimal</strong> &{' '}
|
|
<strong>Classic</strong> feature warm light/cream backgrounds in Light Mode and charcoal
|
|
in Dark Mode.
|
|
</p>
|
|
<div className="segmented-control">
|
|
<button
|
|
type="button"
|
|
className={`segment-btn ${currentMode === 'light' ? 'active' : ''}`}
|
|
onClick={() => toggleMode('light')}
|
|
>
|
|
<Icons.IconSun size={14} /> Light Mode (Cream / Beige)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`segment-btn ${currentMode === 'dark' ? 'active' : ''}`}
|
|
onClick={() => toggleMode('dark')}
|
|
>
|
|
<Icons.IconMoon size={14} /> Dark Mode (Charcoal)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="settings-section-divider" />
|
|
|
|
<div className="settings-section-header">
|
|
<h3>
|
|
<Icons.IconTarget size={18} /> Accent Color
|
|
</h3>
|
|
<p className="settings-section-sub">
|
|
Pick a primary accent color for links, buttons, and highlights.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="admin-field">
|
|
<div className="accent-picker-wrapper">
|
|
<div className="color-swatches-row">
|
|
{[
|
|
{ hex: '#e60012', name: 'Studio Red' },
|
|
{ hex: '#b89053', name: 'Editorial Gold' },
|
|
{ hex: '#10b981', name: 'Emerald' },
|
|
{ hex: '#3b82f6', name: 'Sapphire' },
|
|
{ hex: '#8b5cf6', name: 'Violet' },
|
|
{ hex: '#ffffff', name: 'Monochrome White' },
|
|
{ hex: '#000000', name: 'Obsidian Black' },
|
|
].map((swatch) => {
|
|
const isSelected = theme.accent.toLowerCase() === swatch.hex.toLowerCase();
|
|
return (
|
|
<button
|
|
key={swatch.hex}
|
|
type="button"
|
|
className={`color-swatch-btn ${isSelected ? 'active' : ''}`}
|
|
style={{ backgroundColor: swatch.hex }}
|
|
onClick={() => update('theme.accent', swatch.hex)}
|
|
title={swatch.name}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="color-field">
|
|
<input
|
|
type="color"
|
|
aria-label="Accent colour"
|
|
value={theme.accent}
|
|
onChange={(e) => update('theme.accent', e.target.value)}
|
|
/>
|
|
<input
|
|
aria-label="Accent colour (hex)"
|
|
type="text"
|
|
value={settings.theme?.accent || ''}
|
|
onChange={(e) => update('theme.accent', e.target.value)}
|
|
placeholder={theme.accent}
|
|
aria-invalid={fieldErrors?.['theme.accent'] ? true : undefined}
|
|
aria-describedby={fieldErrors?.['theme.accent'] ? 'theme-accent-error' : undefined}
|
|
/>
|
|
</div>
|
|
<FieldError id="theme-accent-error" message={fieldErrors?.['theme.accent']} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="settings-section-divider" />
|
|
|
|
<div className="settings-section-header">
|
|
<h3>
|
|
<Icons.IconFrame size={18} /> Photo Frame & Layout
|
|
</h3>
|
|
<p className="settings-section-sub">
|
|
Customize image presentation borders and hero layouts.
|
|
</p>
|
|
</div>
|
|
|
|
<OptionGrid
|
|
label="Photo Frame"
|
|
options={PHOTO_FRAME_OPTIONS}
|
|
value={theme.photoFrame}
|
|
onSelect={(v) => update('theme.photoFrame', v)}
|
|
cardClassName="frame-card"
|
|
renderPreview={(o) => <PhotoFramePreview value={o.value} />}
|
|
/>
|
|
|
|
<OptionGrid
|
|
label="Hero Style"
|
|
options={HERO_STYLE_OPTIONS}
|
|
value={theme.heroStyle}
|
|
onSelect={(v) => update('theme.heroStyle', v)}
|
|
cardClassName="hero-card"
|
|
renderPreview={(o) => <HeroStylePreview value={o.value} />}
|
|
/>
|
|
|
|
<div className="settings-section-divider" />
|
|
|
|
<div className="settings-section-header">
|
|
<h3>
|
|
<Icons.IconSparkles size={18} /> Look & motion
|
|
</h3>
|
|
<p className="settings-section-sub">Effects and small controls on every page.</p>
|
|
</div>
|
|
|
|
<div className="feature-list">
|
|
<FeatureRow
|
|
icon={<Icons.IconSparkles size={15} />}
|
|
title="Page transitions"
|
|
description="A short fade between pages"
|
|
checked={settings.transitions !== false}
|
|
onToggle={() => update('transitions', settings.transitions === false)}
|
|
/>
|
|
<FeatureRow
|
|
icon={<Icons.IconArrowUp size={15} />}
|
|
title="Scroll-to-top button"
|
|
description="A floating arrow on long pages"
|
|
checked={settings.scrollToTop !== false}
|
|
onToggle={() => update('scrollToTop', settings.scrollToTop === false)}
|
|
/>
|
|
<FeatureRow
|
|
icon={<Icons.IconFilm size={15} />}
|
|
title="Film grain"
|
|
description="Analog noise over the background"
|
|
checked={theme.grain}
|
|
onToggle={() => update('theme.grain', !theme.grain)}
|
|
/>
|
|
<FeatureRow
|
|
icon={<Icons.IconTarget size={15} />}
|
|
title="Header accent dot"
|
|
description="A dot beside the active section heading"
|
|
checked={theme.headerDot}
|
|
onToggle={() => update('theme.headerDot', !theme.headerDot)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|