diff --git a/src/app/App.tsx b/src/app/App.tsx index ba5637a..4c805e2 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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('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 }) { > +
); +} + +/** + * 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 ( + + Loading + + ); + } + if (status === 'saving') { + return ( + + Saving + + ); + } + if (status === 'saved') { + return ( + + Saved + + ); + } + // error + return ( + + ); } \ No newline at end of file diff --git a/src/app/settings.ts b/src/app/settings.ts new file mode 100644 index 0000000..9170499 --- /dev/null +++ b/src/app/settings.ts @@ -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; + +/** 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' | '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; +} + +/** + * 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'); + + // 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 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}`; +}