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>
229 lines
7.3 KiB
TypeScript
229 lines
7.3 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Editor for content/privacy.md (#699), in Settings → Legal.
|
|
*
|
|
* Folio writes no legal text. Next to the editor it lists what this
|
|
* installation processes, read off the configuration, and "Insert headings"
|
|
* adds the section headings that list implies, nothing more.
|
|
*
|
|
* Saves on its own, like the About page: the policy is a Markdown file, not a
|
|
* key in settings.yaml.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import * as Icons from './Icons';
|
|
import { reportIfSessionExpired } from './sessionExpiry';
|
|
import { useUnsavedGuard } from './useUnsavedGuard';
|
|
import { useContentRestored } from './contentRestored';
|
|
import { useVersionedSave } from './useVersionedSave';
|
|
import { useLatest } from './useLatest';
|
|
import { useDraft } from './useDraft';
|
|
import DraftNotice from './DraftNotice';
|
|
import type { ProcessingFact } from '@/lib/privacy';
|
|
|
|
interface PrivacyData {
|
|
body: string;
|
|
enabled: boolean;
|
|
facts: ProcessingFact[];
|
|
starter: string;
|
|
version?: string;
|
|
}
|
|
|
|
type Status = { kind: 'success' | 'error'; message: string } | null;
|
|
|
|
export default function PrivacyEditor() {
|
|
const [data, setData] = useState<PrivacyData | null>(null);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [body, setBody] = useState('');
|
|
const [dirty, setDirty] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [status, setStatus] = useState<Status>(null);
|
|
|
|
useUnsavedGuard(dirty);
|
|
// The editor lives in the Legal section and unmounts when another section
|
|
// opens; without a draft an unsaved policy was simply gone (#554, #592).
|
|
const draft = useDraft<string>('privacy', body, dirty);
|
|
const loadDraft = draft.load;
|
|
|
|
/** privacy.md as loaded, sent back on save so a change elsewhere is caught (#601). */
|
|
const versionRef = useRef<string | null>(null);
|
|
const versionedSave = useVersionedSave();
|
|
/** The text as last rendered: tells a finished save whether typing went on meanwhile. */
|
|
const latestBody = useLatest(body);
|
|
const dirtyRef = useRef(dirty);
|
|
useEffect(() => {
|
|
dirtyRef.current = dirty;
|
|
});
|
|
|
|
const load = useCallback(async (): Promise<void> => {
|
|
try {
|
|
const res = await fetch('/api/admin/privacy');
|
|
if (!res.ok) {
|
|
reportIfSessionExpired(res);
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
const d = (await res.json()) as PrivacyData;
|
|
versionRef.current = d.version ?? null;
|
|
setData(d);
|
|
const restored = loadDraft(d.body);
|
|
setBody(restored ?? d.body);
|
|
setDirty(restored !== null);
|
|
} catch (err) {
|
|
setLoadError(err instanceof Error ? err.message : 'Could not load.');
|
|
}
|
|
}, [loadDraft]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
// A restored privacy.md replaces what this editor shows — unless there are
|
|
// unsaved edits, which stay; their save then meets the conflict prompt.
|
|
useContentRestored(({ target }) => {
|
|
if (target === 'privacy' && !dirtyRef.current) void load();
|
|
});
|
|
|
|
async function save() {
|
|
setSaving(true);
|
|
setStatus(null);
|
|
const sent = body;
|
|
try {
|
|
const result = await versionedSave(
|
|
'/api/admin/privacy',
|
|
{ body: sent },
|
|
versionRef,
|
|
'The privacy policy',
|
|
);
|
|
if (result.kind === 'reload') {
|
|
draft.discard();
|
|
await load();
|
|
return;
|
|
}
|
|
if (result.kind === 'keep') {
|
|
setStatus({ kind: 'error', message: 'Not saved — the policy changed elsewhere.' });
|
|
return;
|
|
}
|
|
if (result.kind === 'failed') {
|
|
if (!reportIfSessionExpired(result.res)) {
|
|
setStatus({ kind: 'error', message: `Could not save (HTTP ${result.res.status}).` });
|
|
}
|
|
return;
|
|
}
|
|
// Typed while the request was out: that part is not saved yet.
|
|
const editedMeanwhile = latestBody.current !== sent;
|
|
setData((d) => (d ? { ...d, body: sent } : d));
|
|
draft.saved(sent, editedMeanwhile);
|
|
if (!editedMeanwhile) setDirty(false);
|
|
setStatus({ kind: 'success', message: 'Privacy policy saved.' });
|
|
} catch {
|
|
setStatus({ kind: 'error', message: 'Could not reach the server.' });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
function insertHeadings() {
|
|
if (!data) return;
|
|
setBody((b) => (b.trim() ? `${b.trimEnd()}\n\n${data.starter}\n` : `${data.starter}\n`));
|
|
setDirty(true);
|
|
}
|
|
|
|
if (loadError) {
|
|
// No editor over an unknown file: saving an empty one would erase the policy.
|
|
return (
|
|
<p className="admin-field-hint admin-field-hint--error">
|
|
The privacy policy could not be loaded ({loadError}). Reload the page to try again.
|
|
</p>
|
|
);
|
|
}
|
|
if (!data) return <div className="admin-spinner" />;
|
|
|
|
return (
|
|
<div className="privacy-editor">
|
|
<DraftNotice
|
|
status={draft.status}
|
|
subject="privacy policy"
|
|
onDiscard={() => {
|
|
draft.discard();
|
|
setBody(data.body);
|
|
setDirty(false);
|
|
setStatus(null);
|
|
}}
|
|
onRestore={() => {
|
|
const value = draft.takeConflicting();
|
|
if (value === null) return;
|
|
setBody(value);
|
|
setDirty(true);
|
|
}}
|
|
onDismiss={draft.dismiss}
|
|
/>
|
|
|
|
<div className="privacy-editor__facts">
|
|
<h4>What this site processes</h4>
|
|
<p className="admin-field-hint">
|
|
Read off your current configuration. A checklist for writing the policy, not text for the
|
|
page.
|
|
</p>
|
|
<ul>
|
|
{data.facts.map((f) => (
|
|
<li key={f.topic}>
|
|
<strong>{f.topic}</strong>
|
|
{f.thirdParty && <span className="privacy-editor__badge">third party</span>}
|
|
<span>{f.detail}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="admin-field">
|
|
<label htmlFor="privacy-body">Privacy policy (Markdown)</label>
|
|
<textarea
|
|
id="privacy-body"
|
|
value={body}
|
|
onChange={(e) => {
|
|
setBody(e.target.value);
|
|
setDirty(true);
|
|
}}
|
|
rows={18}
|
|
placeholder="## Controller Name, address, email…"
|
|
/>
|
|
<p className="admin-field-hint">
|
|
Headings with <code>##</code>, lists with <code>-</code>, <code>**bold**</code> and{' '}
|
|
<code>[links](https://…)</code>. Shown at /privacy and linked in the footer once there is
|
|
text. Earlier versions are kept under Backups.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="privacy-editor__actions">
|
|
<button type="button" className="admin-btn admin-btn-ghost" onClick={insertHeadings}>
|
|
<Icons.IconPlus size={14} /> Insert headings
|
|
</button>
|
|
{body.trim() && data.enabled && (
|
|
<a
|
|
className="admin-btn admin-btn-ghost"
|
|
href="/privacy"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
View /privacy ↗
|
|
</a>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-primary"
|
|
onClick={save}
|
|
disabled={!dirty || saving}
|
|
>
|
|
{saving ? 'Saving…' : 'Save privacy policy'}
|
|
</button>
|
|
{status && (
|
|
<span className={`save-message ${status.kind}`} role="status">
|
|
{status.message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|