release: v0.19.0
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>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Icons from '../Icons';
|
||||
import type { SaveStatus } from '../SaveBar';
|
||||
import { useDraft, readDraft } from '../useDraft';
|
||||
import { useContentRestored } from '../contentRestored';
|
||||
import { useVersionedSave } from '../useVersionedSave';
|
||||
import { useLatest } from '../useLatest';
|
||||
|
||||
interface AboutMeta {
|
||||
portrait?: string;
|
||||
@@ -54,6 +56,8 @@ export function useAboutEditor(active: boolean) {
|
||||
const [aboutStatus, setAboutStatus] = useState<SaveStatus>(null);
|
||||
const [aboutGearText, setAboutGearText] = useState('');
|
||||
const [serverAbout, setServerAbout] = useState<AboutDraft | null>(null);
|
||||
/** The fields as last rendered: tell a finished save whether editing went on meanwhile. */
|
||||
const latest = useLatest({ meta: aboutMeta, body: aboutBody, gearText: aboutGearText });
|
||||
|
||||
// Unsaved About edits survive a tab switch or Reload, like settings (#592).
|
||||
const draft = useDraft<AboutDraft>(
|
||||
@@ -62,6 +66,9 @@ export function useAboutEditor(active: boolean) {
|
||||
aboutDirty,
|
||||
);
|
||||
const loadDraft = draft.load;
|
||||
/** about.md as loaded, sent back on save so a change elsewhere is caught (#601). */
|
||||
const versionRef = useRef<string | null>(null);
|
||||
const versionedSave = useVersionedSave();
|
||||
|
||||
const applyAbout = (value: AboutDraft) => {
|
||||
setAboutMeta(value.meta);
|
||||
@@ -82,6 +89,7 @@ export function useAboutEditor(active: boolean) {
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
versionRef.current = typeof data.version === 'string' ? data.version : null;
|
||||
const loaded: AboutDraft = {
|
||||
meta: data.meta || {},
|
||||
body: data.body || '',
|
||||
@@ -128,22 +136,37 @@ export function useAboutEditor(active: boolean) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/about', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ meta: cleanedMeta, body: aboutBody }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const result = await versionedSave(
|
||||
'/api/admin/about',
|
||||
{ meta: cleanedMeta, body: aboutBody },
|
||||
versionRef,
|
||||
'The About page',
|
||||
);
|
||||
if (result.kind === 'reload') {
|
||||
draft.discard();
|
||||
await loadAboutContent();
|
||||
} else if (result.kind === 'keep') {
|
||||
setAboutStatus({ kind: 'error', message: 'Not saved — the About page changed elsewhere.' });
|
||||
} else if (result.kind === 'saved') {
|
||||
const data = result.data;
|
||||
const saved: AboutDraft = { meta: cleanedMeta, body: aboutBody, gearText: aboutGearText };
|
||||
// Edited while the request was out: those edits are not saved yet.
|
||||
const now = latest.current;
|
||||
const editedMeanwhile =
|
||||
now.meta !== aboutMeta || now.body !== aboutBody || now.gearText !== aboutGearText;
|
||||
setServerAbout(saved);
|
||||
draft.saved(aboutFingerprint(saved));
|
||||
setAboutDirty(false);
|
||||
setAboutStatus({ kind: 'success', message: data.message || 'Saved!' });
|
||||
draft.saved(aboutFingerprint(saved), editedMeanwhile);
|
||||
if (!editedMeanwhile) setAboutDirty(false);
|
||||
setAboutStatus({
|
||||
kind: 'success',
|
||||
message: typeof data.message === 'string' ? data.message : 'Saved!',
|
||||
});
|
||||
setTimeout(() => setAboutStatus(null), 4000);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
setAboutStatus({ kind: 'error', message: `Error: ${err.error}` });
|
||||
setAboutStatus({
|
||||
kind: 'error',
|
||||
message: `Error: ${result.data?.error ?? `HTTP ${result.res.status}`}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setAboutStatus({ kind: 'error', message: 'Error: Failed to save' });
|
||||
|
||||
@@ -137,6 +137,23 @@ export default function GeneralSection({ settings, update, updateMany }: Section
|
||||
onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false)}
|
||||
/>
|
||||
</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
|
||||
settable from here. */}
|
||||
<div className="admin-field">
|
||||
<label htmlFor="proofing-email">Proofing email</label>
|
||||
<input
|
||||
id="proofing-email"
|
||||
type="email"
|
||||
value={settings.proofing?.email || ''}
|
||||
onChange={(e) => update('proofing.email', e.target.value)}
|
||||
placeholder="Defaults to the footer contact email"
|
||||
/>
|
||||
<p className="admin-field-hint">
|
||||
Where “Email to photographer” sends a selection. Falls back to the footer
|
||||
contact email; when neither is set, the button is hidden.
|
||||
</p>
|
||||
</div>
|
||||
</FeatureGroup>
|
||||
|
||||
<FeatureGroup
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PHOTO_GRID_COLUMNS_MIN,
|
||||
PHOTO_GRID_GAP_MAX,
|
||||
} from '@/lib/config/schema';
|
||||
import { FieldError } from './fields';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
|
||||
@@ -131,7 +132,7 @@ function AspectRatioPreview({ value }: { value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function GridSection({ settings, update }: SectionProps) {
|
||||
export default function GridSection({ settings, update, fieldErrors }: SectionProps) {
|
||||
return (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
@@ -185,7 +186,10 @@ export default function GridSection({ settings, update }: SectionProps) {
|
||||
max={PHOTO_GRID_COLUMNS_MAX}
|
||||
value={settings.grid?.columns ?? 3}
|
||||
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
|
||||
aria-invalid={fieldErrors?.['grid.columns'] ? true : undefined}
|
||||
aria-describedby={fieldErrors?.['grid.columns'] ? 'grid-columns-error' : undefined}
|
||||
/>
|
||||
<FieldError id="grid-columns-error" message={fieldErrors?.['grid.columns']} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="grid-gap-spacing">Gap Spacing (px)</label>
|
||||
@@ -196,7 +200,10 @@ export default function GridSection({ settings, update }: SectionProps) {
|
||||
max={PHOTO_GRID_GAP_MAX}
|
||||
value={settings.grid?.gap ?? 12}
|
||||
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
|
||||
aria-invalid={fieldErrors?.['grid.gap'] ? true : undefined}
|
||||
aria-describedby={fieldErrors?.['grid.gap'] ? 'grid-gap-error' : undefined}
|
||||
/>
|
||||
<FieldError id="grid-gap-error" message={fieldErrors?.['grid.gap']} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import * as Icons from '../Icons';
|
||||
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 type { SectionProps } from './types';
|
||||
|
||||
export default function LegalSection({ settings, update }: SectionProps) {
|
||||
export default function LegalSection({ settings, update, envLocks }: 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.
|
||||
const contactUrl = settings.legal?.contactUrl?.trim();
|
||||
@@ -206,16 +208,19 @@ export default function LegalSection({ settings, update }: SectionProps) {
|
||||
value={settings.contact?.notifyUrl || ''}
|
||||
onChange={(e) => update('contact.notifyUrl', e.target.value)}
|
||||
placeholder="https://ntfy.sh/your-secret-topic"
|
||||
aria-invalid={notifyUrlInvalid || undefined}
|
||||
aria-describedby="contact-notify-url-hint"
|
||||
aria-invalid={(!notifyLock && notifyUrlInvalid) || undefined}
|
||||
aria-describedby={notifyLock ? 'contact-notify-url-lock' : 'contact-notify-url-hint'}
|
||||
disabled={!!notifyLock}
|
||||
/>
|
||||
{notifyLock && <EnvLockNote id="contact-notify-url-lock" variable={notifyLock} />}
|
||||
<p
|
||||
id="contact-notify-url-hint"
|
||||
className={`admin-field-hint${notifyUrlInvalid ? ' admin-field-hint--error' : ''}`}
|
||||
>
|
||||
{notifyUrlInvalid
|
||||
? 'Must start with https:// or http://.'
|
||||
: 'Gets a fixed "new message" push, with nothing about the sender. Pick a topic name nobody can guess. CONTACT_NOTIFY_URL overrides this.'}
|
||||
: 'Gets a fixed "new message" push, with nothing about the sender. Pick a topic name nobody can guess.' +
|
||||
(notifyLock ? '' : ' CONTACT_NOTIFY_URL overrides this.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import * as Icons from '../Icons';
|
||||
import ToggleCard from '../fields/ToggleCard';
|
||||
import PasswordField from '../fields/PasswordField';
|
||||
import EnvLockNote from '../fields/EnvLockNote';
|
||||
import { resolveWatermarkOpacity } from '@/lib/config/schema';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export default function SecuritySection({ settings, update }: SectionProps) {
|
||||
export default function SecuritySection({ settings, update, envLocks }: SectionProps) {
|
||||
const passwordLock = envLocks?.sitePassword;
|
||||
return (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
@@ -27,11 +29,18 @@ export default function SecuritySection({ settings, update }: SectionProps) {
|
||||
onChange={(password) => update('sitePassword', password ?? '')}
|
||||
placeholder="Leave empty for a public site"
|
||||
label="Site password"
|
||||
disabled={!!passwordLock}
|
||||
/>
|
||||
{passwordLock && <EnvLockNote variable={passwordLock} />}
|
||||
<p className="admin-field-hint">
|
||||
Stored as a <code>scrypt:…</code> hash in <code>settings.yaml</code>, never as typed. The{' '}
|
||||
<code>SITE_PASSWORD</code> environment variable overrides this field. The admin panel
|
||||
keeps its own password and is never behind this gate.
|
||||
Stored as a <code>scrypt:…</code> hash in <code>settings.yaml</code>, never as typed.
|
||||
{!passwordLock && (
|
||||
<>
|
||||
{' '}
|
||||
The <code>SITE_PASSWORD</code> environment variable overrides this field.
|
||||
</>
|
||||
)}{' '}
|
||||
The admin panel keeps its own password and is never behind this gate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import * as Icons from '../Icons';
|
||||
import ToggleCard from '../fields/ToggleCard';
|
||||
import { FieldError } from './fields';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export default function SeoSection({
|
||||
settings,
|
||||
update,
|
||||
fieldErrors,
|
||||
siteUrlInfo,
|
||||
}: SectionProps & {
|
||||
/** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */
|
||||
@@ -52,7 +54,10 @@ export default function SeoSection({
|
||||
value={settings.url || ''}
|
||||
onChange={(e) => update('url', e.target.value)}
|
||||
placeholder="https://folio.example"
|
||||
aria-invalid={fieldErrors?.url ? true : undefined}
|
||||
aria-describedby={fieldErrors?.url ? 'seo-site-url-error' : undefined}
|
||||
/>
|
||||
<FieldError id="seo-site-url-error" message={fieldErrors?.url} />
|
||||
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
|
||||
Needed for <code>sitemap.xml</code>, the feed and structured data — those are generated
|
||||
without a request, so the address cannot be derived from it. Leave empty and the sitemap
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 } from './fields';
|
||||
import { effectiveTheme, FeatureRow, FieldError } from './fields';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
@@ -264,7 +264,7 @@ function PresetSpecs({ value }: { value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ThemeSection({ settings, update }: SectionProps) {
|
||||
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'>(() =>
|
||||
@@ -417,8 +417,11 @@ export default function ThemeSection({ settings, update }: SectionProps) {
|
||||
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>
|
||||
|
||||
|
||||
@@ -9,6 +9,20 @@ import { resolveTheme } from '@/lib/config/theme';
|
||||
import type { SettingsYaml } from '@/lib/config/schema';
|
||||
import type { Settings } from './types';
|
||||
|
||||
/**
|
||||
* The message under an input whose value the site would ignore (`fieldErrors`).
|
||||
* Not a live region: it changes as you type, and announcing every keystroke
|
||||
* would drown the field itself. The input points at it with aria-describedby.
|
||||
*/
|
||||
export function FieldError({ id, message }: { id: string; message?: string }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p id={id} className="admin-field-hint admin-field-hint--error">
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One labelled block of related switches.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { EnvLocks } from '@/lib/admin/envLocks';
|
||||
|
||||
/**
|
||||
* Shared shape of the settings form (#554). The sections edit one object
|
||||
* that mirrors settings.yaml; SettingsEditor owns it and hands each section
|
||||
@@ -76,6 +78,7 @@ export interface Settings {
|
||||
proofing?: {
|
||||
enabled?: boolean;
|
||||
allowMailto?: boolean;
|
||||
email?: string;
|
||||
};
|
||||
watermark?: {
|
||||
enabled?: boolean;
|
||||
@@ -92,4 +95,12 @@ export interface SectionProps {
|
||||
update: (path: string, value: unknown) => void;
|
||||
/** Set several paths in one state update. */
|
||||
updateMany: (entries: Record<string, unknown>) => void;
|
||||
/** Fields an environment variable overrides; rendered locked (#605). */
|
||||
envLocks?: EnvLocks;
|
||||
/**
|
||||
* Dotted path → message, for values the site would ignore (the save route
|
||||
* rejects them). Checked as the form changes, so the message stands next to
|
||||
* the input before a save is tried.
|
||||
*/
|
||||
fieldErrors?: Record<string, string>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user