'use client'; import { useCallback, useEffect, useState } from 'react'; import * as Icons from '../Icons'; import type { SaveStatus } from '../SaveBar'; import { useDraft, readDraft } from '../useDraft'; import { useContentRestored } from '../contentRestored'; interface AboutMeta { portrait?: string; name?: string; location?: string; gear?: string[]; } /** The About editor's state, as kept in a draft. */ interface AboutDraft { meta: AboutMeta; body: string; gearText: string; } /** * What a draft of the About page is compared against: the file's content, not * the textarea's. `gearText` is derived from `meta.gear` and would make a * trailing newline read as a change on disk. */ function aboutFingerprint(about: AboutDraft): string { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { gear: _gear, ...meta } = about.meta; const gear = about.gearText .split('\n') .map((line) => line.trim()) .filter(Boolean); return JSON.stringify({ meta, gear, body: about.body }); } /** * The About page editor's state: content/about.md, loaded over its own * endpoint and saved on its own, but reached through the settings nav and * saved by the same save bar and ⌘S (#554). * * A hook rather than state inside the section, because the section unmounts * when another one is opened while an About edit is still unsaved, and the * save bar has to keep offering it. */ export function useAboutEditor(active: boolean) { const [aboutMeta, setAboutMeta] = useState({}); 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 [aboutStatus, setAboutStatus] = useState(null); const [aboutGearText, setAboutGearText] = useState(''); const [serverAbout, setServerAbout] = useState(null); // Unsaved About edits survive a tab switch or Reload, like settings (#592). const draft = useDraft( 'about', { meta: aboutMeta, body: aboutBody, gearText: aboutGearText }, aboutDirty, ); const loadDraft = draft.load; const applyAbout = (value: AboutDraft) => { setAboutMeta(value.meta); setAboutBody(value.body); setAboutGearText(value.gearText); }; const loadAboutContent = useCallback(async () => { setAboutLoading(true); setAboutStatus(null); try { const res = await fetch('/api/admin/about'); if (!res.ok) { throw new Error( res.status === 401 ? 'Your session has expired. Sign in again to continue.' : `The server answered ${res.status}.`, ); } const data = await res.json(); const loaded: AboutDraft = { meta: data.meta || {}, body: data.body || '', gearText: data.meta?.gear?.join('\n') || '', }; setServerAbout(loaded); const restored = loadDraft(aboutFingerprint(loaded)); const value = restored ?? loaded; setAboutMeta(value.meta); setAboutBody(value.body); setAboutGearText(value.gearText); setAboutDirty(restored !== null); setAboutLoaded(true); } catch (err) { console.error('Failed to load about content:', err); // aboutLoaded stays false, which is what blocks the save below: an empty // editor written to about.md would replace the page with nothing. setAboutStatus({ kind: 'error', message: `Error: ${err instanceof Error ? err.message : 'The About page could not be loaded.'}`, }); } finally { setAboutLoading(false); } }, [loadDraft]); async function saveAboutContent() { if (!aboutLoaded) return; setAboutSaving(true); setAboutStatus(null); const cleanedMeta = { ...aboutMeta }; for (const [k, v] of Object.entries(cleanedMeta)) { if (v === '' || v === undefined) delete cleanedMeta[k as keyof typeof cleanedMeta]; } const gearLines = aboutGearText .split('\n') .map((s) => s.trim()) .filter(Boolean); if (gearLines.length > 0) { cleanedMeta.gear = gearLines; } else { delete cleanedMeta.gear; } 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 saved: AboutDraft = { meta: cleanedMeta, body: aboutBody, gearText: aboutGearText }; setServerAbout(saved); draft.saved(aboutFingerprint(saved)); setAboutDirty(false); setAboutStatus({ kind: 'success', message: data.message || 'Saved!' }); setTimeout(() => setAboutStatus(null), 4000); } else { const err = await res.json(); setAboutStatus({ kind: 'error', message: `Error: ${err.error}` }); } } catch { setAboutStatus({ kind: 'error', message: 'Error: Failed to save' }); } finally { setAboutSaving(false); } } // 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 (active && !aboutLoaded) loadAboutContent(); }, [active, aboutLoaded, loadAboutContent]); // A kept About draft loads the file at once, from any section, so the save // bar owns up to those edits before the About section is ever opened. useEffect(() => { if (!active && readDraft('about')) loadAboutContent(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // A restored about.md replaces what this editor loaded. useContentRestored(({ target }) => { if (target === 'about' && aboutLoaded) loadAboutContent(); }); function updateAboutMeta(key: string, value: unknown) { setAboutMeta((m) => ({ ...m, [key]: value })); setAboutDirty(true); setAboutStatus(null); } return { meta: aboutMeta, body: aboutBody, gearText: aboutGearText, loading: aboutLoading, saving: aboutSaving, dirty: aboutDirty, status: aboutStatus as SaveStatus, updateMeta: updateAboutMeta, setBody: (value: string) => { setAboutBody(value); setAboutDirty(true); setAboutStatus(null); }, setGearText: (value: string) => { setAboutGearText(value); setAboutDirty(true); setAboutStatus(null); }, save: saveAboutContent, draftStatus: draft.status, dismissDraft: draft.dismiss, discardDraft: () => { draft.discard(); if (serverAbout) applyAbout(serverAbout); setAboutDirty(false); setAboutStatus(null); }, restoreDraft: () => { const value = draft.takeConflicting(); if (!value) return; applyAbout(value); setAboutDirty(true); }, }; } export type AboutEditor = ReturnType; export default function AboutSection({ about }: { about: AboutEditor }) { return (

About Page

Edit the portrait, biography and gear shown on the About page. Use General to show or hide the page itself.

{about.loading ? (
) : ( <>
about.updateMeta('portrait', e.target.value)} placeholder="Immich asset UUID for the portrait photo" />
about.updateMeta('name', e.target.value)} placeholder="Your name" />
about.updateMeta('location', e.target.value)} placeholder="City, Country" />