234 lines
7.9 KiB
TypeScript
234 lines
7.9 KiB
TypeScript
/**
|
|
* 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<string, any>;
|
|
|
|
/** Read the settings file from the WebDAV server. Returns null on failure. */
|
|
export async function readSettings(): Promise<SettingsConfig | null> {
|
|
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<void> {
|
|
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<void>;
|
|
/** Flush any pending changes immediately, ignoring the idle timer. */
|
|
flushNow: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* 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<SettingsConfig>(bundledConfig as SettingsConfig);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('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<SettingsConfig>(config);
|
|
const loadedRef = useRef<boolean>(false);
|
|
const timerRef = useRef<number | null>(null);
|
|
const dirtyRef = useRef<boolean>(false);
|
|
const pendingRef = useRef<number>(0);
|
|
const savedTimerRef = useRef<number | null>(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}`;
|
|
}
|