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 { 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)}
|
||||||
|
|
@ -280,3 +281,59 @@ 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
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