release: v0.15.0 (#585)

Eighteen changes since v0.14.0. See CHANGELOG.md for the full list and the upgrade notes (bump IMAGE_CACHE_VERSION).
This commit is contained in:
Ralksta
2026-09-19 22:24:10 +02:00
committed by GitHub
parent e99d3f2f1b
commit c3f92ca4ec
74 changed files with 4791 additions and 2146 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import * as Icons from './Icons';
import SaveBar from './SaveBar';
@@ -486,11 +486,6 @@ const SETTINGS_SECTIONS = [
{ id: 'about', label: 'About' },
];
interface SettingsEditorProps {
/** Sub-section to show, taken from the /admin/settings/[section] route. */
section?: string;
}
/**
* One labelled block of related switches.
*
@@ -576,8 +571,11 @@ function SettingRow({
);
}
export default function SettingsEditor({ section }: SettingsEditorProps) {
export default function SettingsEditor() {
const router = useRouter();
// Read here rather than passed in: the editor is mounted by the settings
// layout, which does not receive the child route's params.
const section = useParams<{ section?: string }>()?.section;
// An unknown section in the URL falls back to General instead of an empty panel.
const activeSection = SETTINGS_SECTIONS.some((sec) => sec.id === section)
? (section as string)
@@ -609,6 +607,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
}>({});
const [aboutBody, setAboutBody] = useState('');
const [aboutLoading, setAboutLoading] = useState(false);
const [aboutLoaded, setAboutLoaded] = useState(false);
const [aboutSaving, setAboutSaving] = useState(false);
const [aboutDirty, setAboutDirty] = useState(false);
const [aboutMessage, setAboutMessage] = useState('');
@@ -636,11 +635,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
}
}, [settings.theme?.preset, settings.theme?.accent]);
// The About panel has its own route, so load its content when that section opens
// Loaded the first time the About section opens, and only then: the editor
// stays mounted across sections, so reloading on every visit would overwrite
// unsaved About edits with the file on disk.
useEffect(() => {
if (activeSection === 'about') loadAboutContent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSection]);
if (activeSection === 'about' && !aboutLoaded) loadAboutContent();
}, [activeSection, aboutLoaded]);
const toggleMode = (mode: 'dark' | 'light') => {
setCurrentMode(mode);
@@ -652,25 +652,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
const handleSaveRef = useCallback(() => {
if (activeSection === 'about') {
if (aboutDirty && !aboutSaving) saveAboutContent();
return;
}
if (dirty && !saving) {
handleSave();
}
saveAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dirty,
saving,
settings,
activeSection,
aboutDirty,
aboutSaving,
aboutMeta,
aboutBody,
aboutGearText,
]);
}, [dirty, saving, settings, aboutDirty, aboutSaving, aboutMeta, aboutBody, aboutGearText]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
@@ -683,7 +667,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSaveRef]);
useUnsavedGuard(dirty);
useUnsavedGuard(dirty || aboutDirty);
async function loadSettings() {
setLoading(true);
@@ -786,6 +770,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
setAboutMeta(data.meta || {});
setAboutBody(data.body || '');
setAboutGearText(data.meta?.gear?.join('\n') || '');
setAboutLoaded(true);
}
} catch (err) {
console.error('Failed to load about content:', err);
@@ -833,6 +818,16 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
}
}
/**
* Save whatever is unsaved, in either file. The owner edits one set of
* settings; that About lives in about.md and the rest in settings.yaml is
* not theirs to keep track of.
*/
function saveAll() {
if (dirty && !saving) handleSave();
if (aboutDirty && !aboutSaving) saveAboutContent();
}
function updateAboutMeta(key: string, value: unknown) {
setAboutMeta((m) => ({ ...m, [key]: value }));
setAboutDirty(true);
@@ -875,26 +870,21 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
'exif.caption': !anyMetadata,
});
// An error from either save wins over the other's success.
const saveBarMessage =
[saveMessage, aboutMessage].find((m) => m.startsWith('Error')) || saveMessage || aboutMessage;
return (
<div className="settings-editor">
{/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */}
{activeSection === 'about' ? (
<SaveBar
dirty={aboutDirty}
saving={aboutSaving}
saveMessage={aboutMessage}
onSave={saveAboutContent}
label="Save About"
/>
) : (
<SaveBar
dirty={dirty}
saving={saving}
saveMessage={saveMessage}
onSave={handleSave}
label="Save Settings"
/>
)}
{/* One bar for both files, on every section: an About edit stays visible
from Theme, and a settings edit from About. */}
<SaveBar
dirty={dirty || aboutDirty}
saving={saving || aboutSaving}
saveMessage={saveBarMessage}
onSave={saveAll}
label="Save Changes"
/>
<p className="settings-live-sync-note">
<Icons.IconRefresh size={13} /> Live Sync (No Docker restart required)
@@ -946,6 +936,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="admin-field">
<label>Language</label>
<select
aria-label="Language"
value={settings.lang || 'en'}
onChange={(e) => update('lang', e.target.value)}
>
@@ -1209,12 +1200,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="admin-field">
<label>Visitor Default Mode</label>
<select
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>
<option value="auto">Follow the visitor&apos;s system</option>
</select>
<p className="admin-field-hint">
What a first-time visitor sees. The light/dark toggle in the site header still
@@ -1880,6 +1872,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="admin-field">
<label>Position</label>
<select
aria-label="Watermark position"
value={settings.watermark?.position || 'bottom-right'}
onChange={(e) => update('watermark.position', e.target.value)}
>