feat: implement useSettings hook for improved settings persistence and management
This commit is contained in:
parent
e060c73d48
commit
1331033b81
|
|
@ -1,5 +1,5 @@
|
|||
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 StatusPage from './components/StatusPage';
|
||||
import DevicesPage from './components/DevicesPage';
|
||||
|
|
@ -10,7 +10,7 @@ import OtherPage from './components/OtherPage';
|
|||
import ToolsPage from './components/ToolsPage';
|
||||
import SearchOverlay from './components/SearchOverlay';
|
||||
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;
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ type AppId =
|
|||
|
||||
export default function App() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('status');
|
||||
const [config, setConfig] = useState(configData);
|
||||
const { config, setConfig, saveStatus, reload } = useSettings();
|
||||
const [showSearch, setShowSearch] = 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" />
|
||||
</button>
|
||||
<SaveStatusBadge status={saveStatus} onReload={reload} />
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||
|
|
@ -279,4 +280,60 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
|||
)}
|
||||
</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
187
src/app/settings.ts
Normal 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}`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user