/** * Settings persistence. * * Settings live at `/.sys/config.json` on the WebDAV server. The bundled * `config.json` is used as the initial value while the file is being * fetched, and as a fallback if the server is unreachable. * * The `useSettings()` hook returns the same `(config, setConfig)` shape * the page components already use, but every change is also persisted * to the WebDAV server after a short debounce. */ import { useCallback, useEffect, useRef, useState } from 'react'; import bundledConfig from '../imports/config.json'; import { createFolder, getFileContents, putFileContents, } from './webdav'; /** The canonical path on the WebDAV server. */ export const SETTINGS_PATH = '/.sys/config.json'; const SETTINGS_DIR = '/.sys'; /** * How long to wait after the last change before writing to disk. * * The debounce timer is reset on every change, so this is effectively * "the number of milliseconds the user must be idle before the change * is committed". Bump this up to give the user more time to make * multiple related edits; lower it to write changes more eagerly. */ export const SAVE_IDLE_MS = 3000; /** How long the "Saved" indicator lingers before fading out. */ const SAVED_INDICATOR_MS = 1500; export type SettingsConfig = Record; /** Read the settings file from the WebDAV server. Returns null on failure. */ export async function readSettings(): Promise { try { const blob = await getFileContents(SETTINGS_PATH); const text = await blob.text(); if (!text) return null; const parsed = JSON.parse(text); if (parsed && typeof parsed === 'object') return parsed as SettingsConfig; return null; } catch { return null; } } /** * Persist the settings object to the WebDAV server, creating the * containing `/.sys/` directory if needed. Pretty-prints with 2-space * indentation so the file is diff-friendly. */ export async function writeSettings(config: SettingsConfig): Promise { try { await createFolder(SETTINGS_DIR, true); } catch { /* directory may already exist; ignore */ } const json = JSON.stringify(config, null, 2) + '\n'; await putFileContents(SETTINGS_PATH, json); } export type SaveStatus = | 'idle' // nothing to do | 'loading' // initial fetch from server | 'unsaved' // one or more changes pending; waiting for the idle timer | 'saving' // a write is currently in flight | 'saved' // last write succeeded (fades back to 'idle' shortly) | 'error'; // last write failed; will retry on the next change export interface UseSettingsResult { config: SettingsConfig; setConfig: (next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => void; /** Whether the initial load from the server has completed. */ loaded: boolean; /** Current save status (see {@link SaveStatus}). */ saveStatus: SaveStatus; /** Number of pending (uncommitted) changes since the last successful save. */ pendingCount: number; /** Force a re-fetch from the server and replace local state. */ reload: () => Promise; /** Flush any pending changes immediately, ignoring the idle timer. */ flushNow: () => Promise; } /** * React hook that loads settings from the WebDAV server on mount and * auto-saves them on every change (debounced). * * Behavior: * - State is initialised with the bundled `config.json` so the UI * renders immediately, even before the server is contacted. * - On mount, an async fetch replaces state with the server's copy. * - On every `setConfig`, a debounced write fires `SAVE_DEBOUNCE_MS` * after the most recent change. * - On `beforeunload`, any pending change is flushed synchronously * (best-effort: `sendBeacon` cannot be used with WebDAV, so the page * may be allowed to close only if the user agrees via the browser's * default "Leave site?" dialog). */ export function useSettings(): UseSettingsResult { const [config, setConfigState] = useState(bundledConfig as SettingsConfig); const [loaded, setLoaded] = useState(false); const [saveStatus, setSaveStatus] = useState('loading'); const [pendingCount, setPendingCount] = useState(0); // Refs so the debounced writer always sees the latest values without // needing to re-subscribe on every render. const configRef = useRef(config); const loadedRef = useRef(false); const timerRef = useRef(null); const dirtyRef = useRef(false); const pendingRef = useRef(0); const savedTimerRef = useRef(null); const load = useCallback(async () => { setSaveStatus('loading'); const fromServer = await readSettings(); if (fromServer) { configRef.current = fromServer; setConfigState(fromServer); } loadedRef.current = true; setLoaded(true); setSaveStatus('idle'); }, []); useEffect(() => { void load(); }, [load]); const flushNow = useCallback(async () => { if (timerRef.current !== null) { window.clearTimeout(timerRef.current); timerRef.current = null; } if (savedTimerRef.current !== null) { window.clearTimeout(savedTimerRef.current); savedTimerRef.current = null; } if (!dirtyRef.current) return; dirtyRef.current = false; const pending = pendingRef.current; pendingRef.current = 0; setPendingCount(0); setSaveStatus('saving'); try { await writeSettings(configRef.current); setSaveStatus('saved'); // Drop the "saved" badge after a short delay so it doesn't linger. savedTimerRef.current = window.setTimeout(() => { setSaveStatus((s) => (s === 'saved' ? 'idle' : s)); }, SAVED_INDICATOR_MS); } catch (e) { // Roll back: the change is still pending. dirtyRef.current = true; pendingRef.current = pending; setPendingCount(pending); setSaveStatus('error'); // Re-throw so the caller can handle it if they want. throw e; } }, []); // Flush on tab close (best effort; most browsers will block until the // fetch completes if it can). useEffect(() => { const onBeforeUnload = (e: BeforeUnloadEvent) => { if (!dirtyRef.current) return; // Modern browsers: `fetch` with `keepalive: true` continues even // after the page is being unloaded. We don't await it. try { void fetch(absoluteSettingsUrl(), { method: 'PUT', body: JSON.stringify(configRef.current, null, 2) + '\n', headers: { 'Content-Type': 'application/json' }, keepalive: true, }); } catch { /* ignore */ } e.preventDefault(); e.returnValue = ''; }; window.addEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload); }, []); const setConfig = useCallback( (next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => { const value = typeof next === 'function' ? (next as (p: SettingsConfig) => SettingsConfig)(configRef.current) : next; configRef.current = value; setConfigState(value); dirtyRef.current = true; pendingRef.current += 1; setPendingCount(pendingRef.current); setSaveStatus('unsaved'); if (timerRef.current !== null) { window.clearTimeout(timerRef.current); } // Reset the idle timer: save `SAVE_IDLE_MS` after the most recent // change. Every additional change resets the timer again. timerRef.current = window.setTimeout(() => { void flushNow(); }, SAVE_IDLE_MS); }, [flushNow], ); return { config, setConfig, loaded, saveStatus, pendingCount, reload: load, flushNow, }; } function absoluteSettingsUrl(): string { return `${window.location.protocol}//${window.location.hostname}${ window.location.port ? ':' + window.location.port : '' }${SETTINGS_PATH}`; }