feat(App, DevicesPage): implement toast notifications for save status and update DevicesPage UI

This commit is contained in:
Jaime Idolpx 2026-06-09 05:42:01 -04:00
parent 3da4d8afc3
commit e4c2aa0dbc
2 changed files with 26 additions and 75 deletions

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw, Terminal, Link, Printer, Maximize2, Minimize2 } from 'lucide-react'; import { Cpu, Settings, Wifi, Network, HardDrive, Activity, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Terminal, Link, Printer, Maximize2, Minimize2 } from 'lucide-react';
import { Toaster } from 'sonner'; import { Toaster, toast } from 'sonner';
import StatusPage from './components/StatusPage'; import StatusPage from './components/StatusPage';
import DevicesPage from './components/DevicesPage'; import DevicesPage from './components/DevicesPage';
import GeneralPage from './components/GeneralPage'; import GeneralPage from './components/GeneralPage';
@ -49,6 +49,28 @@ export default function App() {
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null); const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const id = 'save-status';
switch (saveStatus) {
case 'idle': toast.dismiss(id); break;
case 'loading': toast.loading('Loading settings…', { id }); break;
case 'unsaved':
toast(`${pendingCount} unsaved change${pendingCount === 1 ? '' : 's'}`, {
id, duration: Infinity,
action: { label: 'Save', onClick: flushNow },
});
break;
case 'saving': toast.loading('Saving…', { id }); break;
case 'saved': toast.success('Settings saved', { id, duration: 2000 }); break;
case 'error':
toast.error('Save failed', {
id, duration: Infinity,
action: { label: 'Retry', onClick: reload },
});
break;
}
}, [saveStatus, pendingCount, flushNow, reload]);
useEffect(() => { useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement); const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange); document.addEventListener('fullscreenchange', onChange);
@ -231,7 +253,6 @@ 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} pendingCount={pendingCount} onSave={flushNow} onReload={reload} />
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowProfileMenu(!showProfileMenu)} onClick={() => setShowProfileMenu(!showProfileMenu)}
@ -336,73 +357,3 @@ 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).
*/
function SaveStatusBadge({
status,
pendingCount,
onSave,
onReload,
}: {
status: 'idle' | 'loading' | 'unsaved' | 'saving' | 'saved' | 'error';
pendingCount: number;
onSave: () => void;
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 === 'unsaved') {
return (
<span className="inline-flex items-center gap-1">
<span className="text-xs text-amber-300" title={`${pendingCount} unsaved change${pendingCount === 1 ? '' : 's'}`}>
{pendingCount} unsaved
</span>
<button
onClick={onSave}
className="text-xs bg-amber-500 hover:bg-amber-400 text-white px-2 py-0.5 rounded"
title="Save now"
>
Save
</button>
</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>
);
}

View File

@ -340,14 +340,14 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${cassette.enabled ? 'translate-x-6' : 'translate-x-0.5'}`} /> <div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${cassette.enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button> </button>
</div> </div>
<div className="p-4"> {/* <div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Play/Record</label> <label className="text-sm text-neutral-500 block mb-2">Play/Record</label>
<input type="text" value={cassette.play_record || ''} onChange={(e) => updateSetting(['cassette', 'play_record'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" /> <input type="text" value={cassette.play_record || ''} onChange={(e) => updateSetting(['cassette', 'play_record'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
</div> </div>
<div className="p-4"> <div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Pulldown</label> <label className="text-sm text-neutral-500 block mb-2">Pulldown</label>
<input type="text" value={cassette.pulldown || ''} onChange={(e) => updateSetting(['cassette', 'pulldown'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" /> <input type="text" value={cassette.pulldown || ''} onChange={(e) => updateSetting(['cassette', 'pulldown'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
</div> </div> */}
<div className="p-4"> <div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">URL</label> <label className="text-sm text-neutral-500 block mb-2">URL</label>
<input type="text" value={cassette.url || ''} onChange={(e) => updateSetting(['cassette', 'url'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" /> <input type="text" value={cassette.url || ''} onChange={(e) => updateSetting(['cassette', 'url'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />