release: v0.20.0
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user