feat: implement useSettings hook for improved settings persistence and management

This commit is contained in:
Jaime Idolpx 2026-06-07 19:38:18 -04:00
parent e060c73d48
commit 1331033b81
2 changed files with 247 additions and 3 deletions

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2 } from 'lucide-react'; import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw } from 'lucide-react';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import StatusPage from './components/StatusPage'; import StatusPage from './components/StatusPage';
import DevicesPage from './components/DevicesPage'; import DevicesPage from './components/DevicesPage';
@ -10,7 +10,7 @@ import OtherPage from './components/OtherPage';
import ToolsPage from './components/ToolsPage'; import ToolsPage from './components/ToolsPage';
import SearchOverlay from './components/SearchOverlay'; import SearchOverlay from './components/SearchOverlay';
import logoSvg from '../imports/logo.svg'; import logoSvg from '../imports/logo.svg';
import configData from '../imports/config.json'; import { useSettings } from './settings';
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId; type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
@ -35,7 +35,7 @@ type AppId =
export default function App() { export default function App() {
const [currentPage, setCurrentPage] = useState<Page>('status'); const [currentPage, setCurrentPage] = useState<Page>('status');
const [config, setConfig] = useState(configData); const { config, setConfig, saveStatus, reload } = useSettings();
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [showProfileMenu, setShowProfileMenu] = useState(false); const [showProfileMenu, setShowProfileMenu] = useState(false);
@ -168,6 +168,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
> >
<AppWindow className="w-5 h-5 text-white" /> <AppWindow className="w-5 h-5 text-white" />
</button> </button>
<SaveStatusBadge status={saveStatus} onReload={reload} />
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowProfileMenu(!showProfileMenu)} onClick={() => setShowProfileMenu(!showProfileMenu)}
@ -279,4 +280,60 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
)} )}
</div> </div>
); );
}
/**
* Tiny indicator that reflects the current save status of the settings
* file. Renders nothing when idle (so it doesn't clutter the header).
* Clicking the error badge re-attempts the load.
*/
function SaveStatusBadge({
status,
onReload,
}: {
status: 'idle' | 'loading' | 'saving' | 'saved' | 'error';
onReload: () => void;
}) {
if (status === 'idle') return null;
if (status === 'loading') {
return (
<span
className="text-xs text-white/80 inline-flex items-center gap-1"
title="Loading settings from /.sys/config.json"
>
<Loader2 className="w-3 h-3 animate-spin" /> Loading
</span>
);
}
if (status === 'saving') {
return (
<span
className="text-xs text-white/80 inline-flex items-center gap-1"
title="Saving to /.sys/config.json"
>
<Loader2 className="w-3 h-3 animate-spin" /> Saving
</span>
);
}
if (status === 'saved') {
return (
<span
className="text-xs text-white/80 inline-flex items-center gap-1"
title="Saved to /.sys/config.json"
>
<Check className="w-3 h-3" /> Saved
</span>
);
}
// error
return (
<button
onClick={onReload}
className="text-xs text-red-300 hover:text-white inline-flex items-center gap-1"
title="Failed to save to /.sys/config.json — click to retry"
>
<AlertCircle className="w-3 h-3" /> Save failed
<RefreshCw className="w-3 h-3" />
</button>
);
} }

187
src/app/settings.ts Normal file
View File

@ -0,0 +1,187 @@
/**
* 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. */
const SAVE_DEBOUNCE_MS = 400;
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' | 'loading' | 'saving' | 'saved' | 'error';
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. `'saving'` means a write is in flight. */
saveStatus: SaveStatus;
/** Force a re-fetch from the server and replace local state. */
reload: () => 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');
// 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 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 (!dirtyRef.current) return;
dirtyRef.current = false;
setSaveStatus('saving');
try {
await writeSettings(configRef.current);
setSaveStatus('saved');
// Drop the "saved" badge after a short delay so it doesn't linger.
window.setTimeout(() => {
setSaveStatus((s) => (s === 'saved' ? 'idle' : s));
}, 1500);
} catch (e) {
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;
setSaveStatus('saving');
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
void flushNow();
}, SAVE_DEBOUNCE_MS);
},
[flushNow],
);
return { config, setConfig, loaded, saveStatus, reload: load };
}
function absoluteSettingsUrl(): string {
return `${window.location.protocol}//${window.location.hostname}${
window.location.port ? ':' + window.location.port : ''
}${SETTINGS_PATH}`;
}