release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -1,9 +1,10 @@
'use client';
import * as Icons from '../Icons';
import { FieldError } from './fields';
import type { SectionProps } from './types';
export default function FooterSection({ settings, update }: SectionProps) {
export default function FooterSection({ settings, update, fieldErrors }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
@@ -67,9 +68,13 @@ export default function FooterSection({ settings, update }: SectionProps) {
{(settings.navLinks || []).map((link, i) => (
<div className="admin-field-row" key={i}>
<div className="admin-field">
<label htmlFor="footer-label">Label</label>
<label htmlFor={`navlink-${i}-label`}>Label</label>
<input
id="footer-label"
id={`navlink-${i}-label`}
aria-invalid={fieldErrors?.[`navLinks.${i}.label`] ? true : undefined}
aria-describedby={
fieldErrors?.[`navLinks.${i}.label`] ? `navlink-${i}-label-error` : undefined
}
value={link.label || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
@@ -78,11 +83,19 @@ export default function FooterSection({ settings, update }: SectionProps) {
}}
placeholder="Shop"
/>
<FieldError
id={`navlink-${i}-label-error`}
message={fieldErrors?.[`navLinks.${i}.label`]}
/>
</div>
<div className="admin-field">
<label htmlFor="footer-url">URL</label>
<label htmlFor={`navlink-${i}-url`}>URL</label>
<input
id="footer-url"
id={`navlink-${i}-url`}
aria-invalid={fieldErrors?.[`navLinks.${i}.url`] ? true : undefined}
aria-describedby={
fieldErrors?.[`navLinks.${i}.url`] ? `navlink-${i}-url-error` : undefined
}
value={link.url || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
@@ -91,6 +104,10 @@ export default function FooterSection({ settings, update }: SectionProps) {
}}
placeholder="https://shop.example.com"
/>
<FieldError
id={`navlink-${i}-url-error`}
message={fieldErrors?.[`navLinks.${i}.url`]}
/>
</div>
<button
type="button"

View File

@@ -4,18 +4,28 @@ import { useState } from 'react';
import * as Icons from '../Icons';
import type { SaveStatus } from '../SaveBar';
import { resolveExifDisplay } from '@/lib/config/schema';
import { SUPPORTED_LOCALES } from '@/lib/i18n';
import { SUPPORTED_LOCALES, getDictionary, resolveLocale } from '@/lib/i18n';
import { FeatureGroup, FeatureRow, SettingRow } from './fields';
import type { SectionProps } from './types';
import { useConfirm } from '../ConfirmDialog';
export default function GeneralSection({ settings, update, updateMany }: SectionProps) {
export default function GeneralSection({
settings,
update,
updateMany,
siteTitleEnv,
}: SectionProps & { siteTitleEnv?: string | null }) {
const confirm = useConfirm();
// What the site shows while the field is empty, resolved as getConfig does:
// SITE_TITLE, else the site language's word for a gallery (QA A-15).
const fallbackTitle = siteTitleEnv || getDictionary(resolveLocale(settings.lang)).common.gallery;
// Collapsed by default: the four metadata switches are a detail of one
// decision, and showing them permanently is what made the section a wall (#510).
const [metadataOpen, setMetadataOpen] = useState(false);
const [faviconUploading, setFaviconUploading] = useState(false);
const [faviconStatus, setFaviconStatus] = useState<SaveStatus>(null);
/** Bumped after an upload or reset so the preview fetches the new icon. */
const [faviconVersion, setFaviconVersion] = useState(0);
// Resolved the same way the site resolves it, so the switches show what a
// visitor actually sees — including a config that only ever set the older
@@ -62,8 +72,14 @@ export default function GeneralSection({ settings, update, updateMany }: Section
id="general-site-title"
value={settings.title || ''}
onChange={(e) => update('title', e.target.value)}
placeholder="My Portfolio"
placeholder={fallbackTitle}
aria-describedby="general-site-title-hint"
/>
<p id="general-site-title-hint" className="admin-field-hint">
{settings.title
? 'Shown in the header, the browser tab and link previews.'
: `Empty, so the site shows “${fallbackTitle}”${siteTitleEnv ? ', from SITE_TITLE' : ''}.`}
</p>
</div>
<div className="admin-field">
<label htmlFor="general-subtitle">Subtitle</label>
@@ -136,6 +152,13 @@ export default function GeneralSection({ settings, update, updateMany }: Section
checked={settings.proofing?.enabled !== false}
onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false)}
/>
<FeatureRow
icon={<Icons.IconSearch size={15} />}
title="Photo zoom"
description="Visitors zoom into a photo to full resolution (1:1) to check sharpness. Pages and albums can override this"
checked={settings.zoom === true}
onToggle={() => update('zoom', !settings.zoom)}
/>
</div>
{/* Shown even while proofing is off globally: a subpage can switch it
on for itself (`proofing: true`), so the address still has to be
@@ -249,12 +272,30 @@ export default function GeneralSection({ settings, update, updateMany }: Section
</FeatureGroup>
<div className="admin-field favicon-field">
<span className="admin-field-label">Favicon</span>
<span className="admin-field-label" id="favicon-label">
Favicon
</span>
<div className="favicon-row">
{/* eslint-disable-next-line @next/next/no-img-element -- the site's own icon route, a 32px preview */}
<img
className="favicon-preview"
src={`/api/favicon?v=${faviconVersion}`}
alt="Current favicon"
width={32}
height={32}
/>
{/* The native file input stays the control — focusable, keyboard
operable, announced as a file picker — but is visually hidden;
its label is drawn as the admin's secondary button, with the
focus ring passed through (QA A-18). */}
<input
id="favicon-file"
className="favicon-file-input"
type="file"
accept=".svg,.png,.ico,.jpg,.jpeg"
disabled={faviconUploading}
aria-labelledby="favicon-label favicon-file-button"
aria-describedby="favicon-hint"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -273,6 +314,7 @@ export default function GeneralSection({ settings, update, updateMany }: Section
? { kind: 'success', message: data.message }
: { kind: 'error', message: `Error: ${data.error}` },
);
if (res.ok) setFaviconVersion((v) => v + 1);
} catch {
setFaviconStatus({ kind: 'error', message: 'Error: Upload failed' });
} finally {
@@ -281,9 +323,16 @@ export default function GeneralSection({ settings, update, updateMany }: Section
}
}}
/>
<label
htmlFor="favicon-file"
id="favicon-file-button"
className="admin-btn admin-btn-sm admin-btn-secondary favicon-upload-btn"
>
<Icons.IconImage size={14} /> Upload icon
</label>
<button
type="button"
className="admin-btn"
className="admin-btn admin-btn-sm admin-btn-secondary"
disabled={faviconUploading}
onClick={async () => {
// Deletes the uploaded file at once, outside the staged form.
@@ -304,6 +353,7 @@ export default function GeneralSection({ settings, update, updateMany }: Section
? { kind: 'success', message: data.message }
: { kind: 'error', message: `Error: ${data.error}` },
);
if (res.ok) setFaviconVersion((v) => v + 1);
} catch {
setFaviconStatus({ kind: 'error', message: 'Error: Reset failed' });
} finally {
@@ -318,7 +368,7 @@ export default function GeneralSection({ settings, update, updateMany }: Section
{faviconStatus && (
<p className={`save-message ${faviconStatus.kind}`}>{faviconStatus.message}</p>
)}
<span className="admin-field-hint">
<span id="favicon-hint" className="admin-field-hint">
SVG, PNG, ICO, or JPEG — max 512 kB. Stored in the content volume. Reset restores the
bundled default.
</span>

View File

@@ -5,9 +5,10 @@ import ToggleCard from '../fields/ToggleCard';
import PrivacyEditor from '../PrivacyEditor';
import EnvLockNote from '../fields/EnvLockNote';
import { CONTACT_RETENTION_DEFAULT, CONTACT_RETENTION_MAX, isHttpUrl } from '@/lib/config/schema';
import { FieldError } from './fields';
import type { SectionProps } from './types';
export default function LegalSection({ settings, update, envLocks }: SectionProps) {
export default function LegalSection({ settings, update, envLocks, fieldErrors }: SectionProps) {
const notifyLock = envLocks?.['contact.notifyUrl'];
// The Impressum drops a non-http(s) contact URL with a warning in the server
// log, where nobody looks; say it here instead.
@@ -237,6 +238,14 @@ export default function LegalSection({ settings, update, envLocks }: SectionProp
parseInt(e.target.value) || CONTACT_RETENTION_DEFAULT,
)
}
aria-invalid={fieldErrors?.['contact.retentionDays'] ? true : undefined}
aria-describedby={
fieldErrors?.['contact.retentionDays'] ? 'contact-retention-error' : undefined
}
/>
<FieldError
id="contact-retention-error"
message={fieldErrors?.['contact.retentionDays']}
/>
</div>
</div>

View File

@@ -4,10 +4,10 @@ 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 { PRESET_IDS, PRESET_REGISTRY } from '@/lib/config/presets';
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'];
@@ -36,7 +36,7 @@ const HERO_STYLE_INFO: Record<string, { label: string; desc: string }> = {
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' },
typographic: { label: 'Typographic', desc: 'Oversized text masthead, no photos' },
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
cover: {
label: 'Cover (Experimental)',
@@ -79,15 +79,26 @@ function HeroStylePreview({ value }: { value: string }) {
<div className="hero-demo-photo banner" />
</>
)}
{/* Text only, like the hero it stands for: the typographic hero
renders no photo, so the card must not promise any. */}
{value === 'typographic' && (
<>
<div className="demo-line title giant" />
<div className="hero-demo-grid2">
<div className="hero-demo-photo" />
<div className="hero-demo-photo" />
<div className="demo-line sub" />
<div className="hero-demo-divider" />
<div className="hero-demo-nav">
<div className="demo-line" />
<div className="demo-line" />
<div className="demo-line" />
</div>
</>
)}
{value === 'cover' && (
<div className="hero-demo-full hero-demo-cover-splash">
<div className="demo-line title light" />
<div className="hero-demo-enter" />
</div>
)}
{value === 'mosaic' && (
<div className="hero-demo-mosaic">
<div className="hero-demo-photo big" />
@@ -103,9 +114,10 @@ function HeroStylePreview({ value }: { value: string }) {
}
/**
* 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.
* Card metadata for the theme picker, from the preset registry
* (lib/config/presets.ts). `font`, `radius` and `frame` are the preset's own
* values so the mini mockups show what the preset actually does; `gap` is the
* visual density of its gallery grid.
*/
const THEME_INFO: Record<
string,
@@ -121,92 +133,19 @@ const THEME_INFO: Record<
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,
},
};
> = Object.fromEntries(
PRESET_REGISTRY.map((p) => [
p.id,
{
label: p.label,
desc: p.description,
...p.card,
font: p.theme.fonts.heading,
radius: p.theme.radius,
frame: p.theme.photoFrame,
},
]),
);
/* The preset group sits below THEME_INFO on purpose. PRESET_OPTIONS is built
at module evaluation and reads it through themeInfo(); declared above, it
@@ -232,7 +171,7 @@ function themeInfo(value: string) {
);
}
const PRESET_OPTIONS = PRESETS.map((value) => ({
const PRESET_OPTIONS = PRESET_IDS.map((value) => ({
value,
label: themeInfo(value).label,
desc: themeInfo(value).desc,

View File

@@ -25,6 +25,8 @@ export interface Settings {
transitions?: boolean;
scrollToTop?: boolean;
analytics?: boolean;
/** Lightbox zoom to full resolution (#467); off unless true. */
zoom?: boolean;
theme?: {
preset?: string;
accent?: string;