meatloaf-config/src/app/App.tsx
Jaime Idolpx 75ced3fa0e feat(App): implement lazy loading for RealityOverridePage and add a loading spinner for improved performance
feat(MediaManager): lazy load MediaViewerEditor and enhance file viewer overlay with loading state
feat(MediaViewerEditor): create a new component for handling various media types with mode switching
fix(config): disable unnecessary drives and networks in configuration for better resource management
2026-06-10 02:52:26 -04:00

351 lines
18 KiB
TypeScript

import { lazy, Suspense, 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, Terminal, Link, Printer, Maximize2, Minimize2 } from 'lucide-react';
import { Toaster, toast } from 'sonner';
import StatusPage from './components/StatusPage';
import DevicesPage from './components/DevicesPage';
import GeneralPage from './components/GeneralPage';
import NetworkPage from './components/NetworkPage';
import IECPage from './components/IECPage';
import ToolsPage from './components/ToolsPage';
import SearchOverlay from './components/SearchOverlay';
import RealityOverrideAdminPage from './components/RealityOverrideAdminPage';
import MediaManager from './components/MediaManager';
import logoSvg from '../imports/logo.svg';
import { useSettings } from './settings';
import { WsProvider } from './ws';
// Three.js lives only in RealityOverridePage — keep lazy so it doesn't load on startup.
// CodeMirror/syntax-highlighter/ReactMarkdown live in MediaViewerEditor — lazy-loaded
// inside MediaManager when the user first opens a file to view or edit.
const RealityOverridePage = lazy(() => import('./components/RealityOverridePage'));
type Page = 'status' | 'devices' | 'iec' | 'network' | 'general' | 'tools' | 'apps' | AppId;
type AppId =
| 'file-manager'
| 'print-manager'
| 'serial-console'
| 'directory-editor'
| 'sector-editor'
| 'bam-editor'
| 'disk-visualizer'
| 'ramrom-explorer'
| 'dump-disk-image'
| 'write-disk-image'
| 'prg-to-crt'
| 'magic-desk-cart-builder'
| 'easy-flash-cart-builder'
| 'basic-editor'
| 'assembler'
| 'sprite-editor'
| 'charset-editor'
| 'petscii-editor'
| 'idle-animation'
| 'loading-animation'
| 'reality-override'
| 'reality-override-admin';
function PageLoader() {
return (
<div className="flex items-center justify-center h-full text-neutral-400">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
);
}
export default function App() {
const [currentPage, setCurrentPage] = useState<Page>('status');
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
const [showSearch, setShowSearch] = useState(false);
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
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(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
document.addEventListener('webkitfullscreenchange', onChange);
return () => {
document.removeEventListener('fullscreenchange', onChange);
document.removeEventListener('webkitfullscreenchange', onChange);
};
}, []);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
(document.documentElement.requestFullscreen?.() ?? (document.documentElement as any).webkitRequestFullscreen?.());
} else {
(document.exitFullscreen?.() ?? (document as any).webkitExitFullscreen?.());
}
};
const pages = {
status: <StatusPage config={config} setConfig={setConfig} />,
devices: <DevicesPage config={config} setConfig={setConfig} openDeviceId={devicesOpenId} onClearOpenDevice={() => setDevicesOpenId(null)} />,
iec: <IECPage config={config} setConfig={setConfig} />,
network: <NetworkPage config={config} setConfig={setConfig} />,
general: <GeneralPage config={config} setConfig={setConfig} />,
tools: <ToolsPage config={config} setConfig={setConfig} />,
apps: (
<div className="max-w-3xl mx-auto py-8 px-4">
<h1 className="text-2xl font-bold mb-6 text-center">Apps</h1>
<div className="space-y-10">
<div>
<h2 className="text-lg font-semibold mb-4 text-blue-700">Management</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<AppCard icon={<Folder className="w-7 h-7" />} label="Media Manager" onClick={() => setCurrentPage('file-manager')} />
<AppCard icon={<Printer className="w-7 h-7" />} label="Print Manager" onClick={() => setCurrentPage('print-manager')} />
<AppCard icon={<Terminal className="w-7 h-7" />} label="Serial Console" onClick={() => setCurrentPage('serial-console')} />
<AppCard icon={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} />
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4 text-blue-700">Disk</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<AppCard icon={<Database className="w-7 h-7" />} label="RAM/ROM Explorer" onClick={() => setCurrentPage('ramrom-explorer')} />
<AppCard icon={<LayoutList className="w-7 h-7" />} label="BAM Editor" onClick={() => setCurrentPage('bam-editor')} />
<AppCard icon={<Folder className="w-7 h-7" />} label="Directory Editor" onClick={() => setCurrentPage('directory-editor')} />
<AppCard icon={<Edit className="w-7 h-7" />} label="Sector Editor" onClick={() => setCurrentPage('sector-editor')} />
<AppCard icon={<Eye className="w-7 h-7" />} label="Disk Visualizer" onClick={() => setCurrentPage('disk-visualizer')} />
<AppCard icon={<Download className="w-7 h-7" />} label="Dump Disk Image" onClick={() => setCurrentPage('dump-disk-image')} />
<AppCard icon={<Upload className="w-7 h-7" />} label="Write Disk Image" onClick={() => setCurrentPage('write-disk-image')} />
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4 text-blue-700">Cartridge</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<AppCard icon={<Folder className="w-7 h-7" />} label="PRG to CRT" onClick={() => setCurrentPage('prg-to-crt')} />
<AppCard icon={<Edit className="w-7 h-7" />} label="Magic Desk Cart Builder" onClick={() => setCurrentPage('magic-desk-cart-builder')} />
<AppCard icon={<LayoutList className="w-7 h-7" />} label="Easy Flash Cart Builder" onClick={() => setCurrentPage('easy-flash-cart-builder')} />
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4 text-green-700">Development</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<AppCard icon={<FileText className="w-7 h-7" />} label="Basic Editor" onClick={() => setCurrentPage('basic-editor')} />
<AppCard icon={<Code2 className="w-7 h-7" />} label="Assembler" onClick={() => setCurrentPage('assembler')} />
<AppCard icon={<Image className="w-7 h-7" />} label="Sprite Editor" onClick={() => setCurrentPage('sprite-editor')} />
<AppCard icon={<Edit className="w-7 h-7" />} label="Character Set Editor" onClick={() => setCurrentPage('charset-editor')} />
<AppCard icon={<Edit className="w-7 h-7" />} label="Petscii Editor" onClick={() => setCurrentPage('petscii-editor')} />
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4 text-purple-700">Display</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<AppCard icon={<Activity className="w-7 h-7" />} label="Idle Animation" onClick={() => setCurrentPage('idle-animation')} />
<AppCard icon={<Loader2 className="w-7 h-7" />} label="Loading Animation" onClick={() => setCurrentPage('loading-animation')} />
<AppCard icon={<Wifi className="w-7 h-7" />} label="Reality Override" onClick={() => setCurrentPage('reality-override')} />
<AppCard icon={<Wifi className="w-7 h-7" />} label="Override Admin" onClick={() => setCurrentPage('reality-override-admin')} />
</div>
</div>
</div>
</div>
),
'file-manager': <MediaManager
onBack={() => setCurrentPage('apps')}
config={config}
setConfig={setConfig}
onNavigateToDevice={(id) => { setCurrentPage('devices'); setDevicesOpenId(id); }}
/>,
'print-manager': <MediaManager
title="Print Manager"
rootPath="/sd/.print"
onBack={() => setCurrentPage('apps')}
config={config}
setConfig={setConfig}
/>,
'serial-console': <AppPage title="Serial Console" onBack={() => setCurrentPage('apps')} />,
'directory-editor': <AppPage title="Directory Editor" onBack={() => setCurrentPage('apps')} />,
'sector-editor': <AppPage title="Sector Editor" onBack={() => setCurrentPage('apps')} />,
'bam-editor': <AppPage title="BAM Editor" onBack={() => setCurrentPage('apps')} />,
'disk-visualizer': <AppPage title="Disk Visualizer" onBack={() => setCurrentPage('apps')} />,
'ramrom-explorer': <AppPage title="RAM/ROM Explorer" onBack={() => setCurrentPage('apps')} />,
'dump-disk-image': <AppPage title="Dump Disk Image" onBack={() => setCurrentPage('apps')} />,
'write-disk-image': <AppPage title="Write Disk Image" onBack={() => setCurrentPage('apps')} />,
'prg-to-crt': <AppPage title="PRG to CRT" onBack={() => setCurrentPage('apps')} />,
'magic-desk-cart-builder': <AppPage title="Magic Desk Cart Builder" onBack={() => setCurrentPage('apps')} />,
'easy-flash-cart-builder': <AppPage title="Easy Flash Cart Builder" onBack={() => setCurrentPage('apps')} />,
'basic-editor': <AppPage title="Basic Editor" onBack={() => setCurrentPage('apps')} />,
'assembler': <AppPage title="Assembler" onBack={() => setCurrentPage('apps')} />,
'sprite-editor': <AppPage title="Sprite Editor" onBack={() => setCurrentPage('apps')} />,
'charset-editor': <AppPage title="Character Set Editor" onBack={() => setCurrentPage('apps')} />,
'petscii-editor': <AppPage title="Petscii Editor" onBack={() => setCurrentPage('apps')} />,
'idle-animation': <AppPage title="Idle Animation" onBack={() => setCurrentPage('apps')} />,
'loading-animation': <AppPage title="Loading Animation" onBack={() => setCurrentPage('apps')} />,
'reality-override': <RealityOverridePage onBack={() => setCurrentPage('apps')} />,
'reality-override-admin': <RealityOverrideAdminPage onBack={() => setCurrentPage('apps')} />,
};
// AppCard component for app grid
function AppCard({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) {
return (
<button
className="flex flex-col items-center justify-center gap-2 bg-white rounded-xl shadow border border-neutral-200 hover:bg-blue-50 transition p-6 w-full h-32 focus:outline-none"
onClick={onClick}
>
<span className="text-blue-700">{icon}</span>
<span className="text-base font-medium text-neutral-800 mt-2">{label}</span>
</button>
);
}
// AppPage component for individual app pages
function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
return (
<div className="max-w-2xl mx-auto py-8 px-4">
<button
className="flex items-center gap-2 mb-6 text-blue-700 hover:underline"
onClick={onBack}
>
<ChevronLeft className="w-5 h-5" /> Back to Apps
</button>
<h1 className="text-2xl font-bold mb-4">{title}</h1>
<div className="bg-white rounded-xl shadow p-8 text-neutral-500 text-center border border-neutral-200">
{title} coming soon...
</div>
</div>
);
}
return (
<WsProvider>
<div className="size-full flex flex-col bg-neutral-50">
<Toaster position="top-center" />
<header className="bg-[#4d4d4d] px-0 py-0 flex-shrink-0" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
<div className="flex items-stretch justify-between min-h-[56px]">
<button onClick={() => setCurrentPage('status')} className="flex items-center h-full">
<img src={logoSvg} alt="Meatloaf" className="h-full max-h-[56px] w-auto object-contain" />
</button>
<div className="flex items-center gap-3">
<button
onClick={toggleFullscreen}
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-5 h-5 text-white" /> : <Maximize2 className="w-5 h-5 text-white" />}
</button>
<button
onClick={() => setShowSearch(true)}
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
>
<Search className="w-5 h-5 text-white" />
</button>
<button
onClick={() => setCurrentPage('apps')}
className={`p-2 hover:bg-[#5e5e5e] rounded-lg${currentPage === 'apps' ? ' bg-[#5e5e5e]' : ''}`}
aria-label="Apps"
>
<AppWindow className="w-5 h-5 text-white" />
</button>
<div className="relative">
<button
onClick={() => setShowProfileMenu(!showProfileMenu)}
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
>
<User className="w-5 h-5 text-white" />
</button>
{showProfileMenu && (
<div className="absolute right-0 top-12 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[200px] z-20">
<button
onClick={() => { setShowProfileMenu(false); setCurrentPage('general'); }}
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
>
<Settings className="w-4 h-4 text-[#4d4d4d]" />
Settings
</button>
<button
onClick={() => setShowProfileMenu(false)}
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
>
<Bell className="w-4 h-4 text-[#4d4d4d]" />
Notifications
</button>
<button
onClick={() => setShowProfileMenu(false)}
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
>
<FileText className="w-4 h-4 text-[#4d4d4d]" />
Documentation
</button>
<div className="border-t border-neutral-200 my-2" />
<button
onClick={() => setShowProfileMenu(false)}
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2 text-red-600"
>
<LogOut className="w-4 h-4 text-[#4d4d4d]" />
Logout
</button>
</div>
)}
</div>
</div>
</div>
</header>
<main className="flex-1 overflow-y-auto">
<Suspense fallback={<PageLoader />}>
{pages[currentPage]}
</Suspense>
</main>
<nav className="bg-[#4d4d4d] flex-shrink-0" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<div className="flex">
<button onClick={() => setCurrentPage('status')} className="flex-1 flex flex-col items-center gap-1 py-2">
<Activity className="w-5 h-5 text-white" />
<span className="text-xs text-white">Status</span>
</button>
<button onClick={() => setCurrentPage('devices')} className="flex-1 flex flex-col items-center gap-1 py-2">
<HardDrive className="w-5 h-5 text-white" />
<span className="text-xs text-white">Devices</span>
</button>
<button onClick={() => setCurrentPage('iec')} className="flex-1 flex flex-col items-center gap-1 py-2">
<Cpu className="w-5 h-5 text-white" />
<span className="text-xs text-white">IEC</span>
</button>
<button onClick={() => setCurrentPage('network')} className="flex-1 flex flex-col items-center gap-1 py-2">
<Network className="w-5 h-5 text-white" />
<span className="text-xs text-white">Network</span>
</button>
<button onClick={() => setCurrentPage('tools')} className="flex-1 flex flex-col items-center gap-1 py-2">
<Wrench className="w-5 h-5 text-white" />
<span className="text-xs text-white">System</span>
</button>
</div>
</nav>
{showSearch && (
<Suspense fallback={null}>
<SearchOverlay
config={config}
setConfig={setConfig}
onClose={() => setShowSearch(false)}
/>
</Suspense>
)}
</div>
</WsProvider>
);
}