Compare commits

..

10 Commits

11 changed files with 504 additions and 190 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
?archives/*
dist/*
node_modules/*

View File

@ -5,6 +5,13 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meatloaf Config</title>
<meta name="theme-color" content="#4d4d4d" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
</head>

BIN
public/icon.192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/icon.512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -0,0 +1,21 @@
{
"name": "Meatloaf Config",
"short_name": "Meatloaf",
"start_url": ".",
"display": "standalone",
"background_color": "#4d4d4d",
"theme_color": "#4d4d4d",
"description": "Configuration app for Meatloaf device.",
"icons": [
{
"src": "/icon.192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon.512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

15
public/service-worker.js Normal file
View File

@ -0,0 +1,15 @@
self.addEventListener('install', event => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText } 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 } from 'lucide-react';
import { Toaster } from 'sonner';
import StatusPage from './components/StatusPage';
import DevicesPage from './components/DevicesPage';
@ -12,7 +12,23 @@ import SearchOverlay from './components/SearchOverlay';
import logoSvg from '../imports/logo.svg';
import configData from '../imports/config.json';
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools';
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
type AppId =
| 'directory-editor'
| 'sector-editor'
| 'bam-editor'
| 'disk-visualizer'
| 'ramrom-explorer'
| 'dump-disk-image'
| 'write-disk-image'
| 'basic-editor'
| 'assembler'
| 'sprite-editor'
| 'charset-editor'
| 'petscii-editor'
| 'idle-animation'
| 'loading-animation';
export default function App() {
const [currentPage, setCurrentPage] = useState<Page>('status');
@ -21,15 +37,100 @@ export default function App() {
const [showProfileMenu, setShowProfileMenu] = useState(false);
const pages = {
status: <StatusPage config={config} setConfig={setConfig} />,
devices: <DevicesPage config={config} setConfig={setConfig} />,
iec: <IECPage config={config} setConfig={setConfig} />,
network: <NetworkPage config={config} setConfig={setConfig} />,
other: <OtherPage config={config} setConfig={setConfig} />,
general: <GeneralPage config={config} setConfig={setConfig} />,
tools: <ToolsPage config={config} setConfig={setConfig} />
status: <StatusPage config={config} setConfig={setConfig} />,
devices: <DevicesPage config={config} setConfig={setConfig} />,
iec: <IECPage config={config} setConfig={setConfig} />,
network: <NetworkPage config={config} setConfig={setConfig} />,
other: <OtherPage 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">
{/* Disk Group */}
<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={<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={<LayoutList className="w-7 h-7" />} label="BAM Editor" onClick={() => setCurrentPage('bam-editor')} />
<AppCard icon={<Eye className="w-7 h-7" />} label="Disk Visualizer" onClick={() => setCurrentPage('disk-visualizer')} />
<AppCard icon={<Database className="w-7 h-7" />} label="RAM/ROM Explorer" onClick={() => setCurrentPage('ramrom-explorer')} />
<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>
{/* Development Group */}
<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>
{/* Display Group */}
<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')} />
</div>
</div>
</div>
</div>
),
// Individual app pages
'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')} />,
'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')} />
};
// 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 (
<div className="size-full flex flex-col bg-neutral-50">
<Toaster position="top-center" />
@ -46,10 +147,11 @@ export default function App() {
<Search className="w-5 h-5 text-white" />
</button>
<button
onClick={() => setCurrentPage('tools')}
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
onClick={() => setCurrentPage('apps')}
className={`p-2 hover:bg-[#5e5e5e] rounded-lg${currentPage === 'apps' ? ' bg-[#5e5e5e]' : ''}`}
aria-label="Apps"
>
<Wrench className="w-5 h-5 text-white" />
<AppWindow className="w-5 h-5 text-white" />
</button>
<div className="relative">
<button
@ -70,6 +172,16 @@ export default function App() {
<Settings className="w-4 h-4 text-[#4d4d4d]" />
Settings
</button>
<button
onClick={() => {
setShowProfileMenu(false);
setCurrentPage('tools');
}}
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
>
<Wrench className="w-4 h-4 text-[#4d4d4d]" />
Tools
</button>
<button
onClick={() => setShowProfileMenu(false)}
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
@ -140,13 +252,6 @@ export default function App() {
<MoreHorizontal className="w-5 h-5 text-white" />
<span className="text-xs text-white">More</span>
</button>
<button
onClick={() => setCurrentPage('general')}
className="flex-1 flex flex-col items-center gap-1 py-2"
>
<Settings className="w-5 h-5 text-white" />
<span className="text-xs text-white">General</span>
</button>
</div>
</nav>

View File

@ -326,19 +326,26 @@ export default function DeviceDetailOverlay({
<div className="mt-3">
<label className="text-sm text-neutral-500 block mb-2">Media Set</label>
<div className="flex gap-2 flex-wrap">
{mediaSet.files.slice(0, 5).map((file, index) => (
<button
key={index}
onClick={() => switchMedia(index)}
className={`px-3 py-1.5 rounded-lg text-sm ${
deviceData.url === file
? 'bg-blue-600 text-white'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
Disk {index + 1}
</button>
))}
{mediaSet.files.slice(0, 5).map((file, index) => {
// Attempt to extract a title from the filename, fallback to filename
// Example: /path/to/Game Disk.d64 or /path/to/disk1.d64
const fileName = file.split('/').pop() || file;
// If you have a title mapping, replace this logic
const title = fileName.replace(/\.[^.]+$/, '');
return (
<button
key={index}
onClick={() => switchMedia(index)}
className={`px-3 py-1.5 rounded-lg text-sm ${
deviceData.url === file
? 'bg-blue-600 text-white'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
{`${index + 1}: ${title}`}
</button>
);
})}
</div>
</div>
)}

View File

@ -87,7 +87,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 backdrop-blur-md bg-black/30"
className="fixed inset-0 z-50 backdrop-blur-md bg-black/40 flex items-center justify-center"
onClick={onClose}
>
<motion.div
@ -95,10 +95,11 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
animate={{ y: 0, opacity: 1 }}
exit={{ y: -50, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="max-w-2xl mx-auto mt-20 bg-white rounded-xl shadow-2xl overflow-hidden"
className="w-full h-full max-w-2xl sm:rounded-xl bg-white/50 shadow-2xl overflow-auto flex flex-col justify-center mx-0 sm:mx-auto my-0 sm:my-20 p-0 sm:p-0"
style={{ maxHeight: '100dvh' }}
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<div className="p-4 sm:p-6 flex-1 flex flex-col">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-medium">Search</h2>
<button onClick={onClose} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg">
@ -120,10 +121,10 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
<button
onClick={handleSearch}
disabled={isSearching}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
className="p-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center"
aria-label="Search"
>
<Search className="w-5 h-5" />
Search
</button>
</div>
</div>

View File

@ -1,6 +1,9 @@
import { useState } from 'react';
import { HardDrive, Activity, Wifi, Signal, Clock } from 'lucide-react';
import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map } from 'lucide-react';
import DeviceDetailOverlay from './DeviceDetailOverlay';
import { ImageWithFallback } from './figma/ImageWithFallback';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
import FileBrowser from './FileBrowser';
interface StatusPageProps {
config: any;
@ -40,18 +43,296 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
{ time: '14:31:58', event: 'Device reset', type: 'warning' }
];
const stats = {
bytesRead: '2.4 MB',
bytesWritten: '156 KB',
operations: 1247,
errors: 0
};
// Mock loading/progress state
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0.0); // 0.0 to 1.0
// Mock file info (replace with real data if available)
const lastFile = 'MEATLOAF MANIACS.PRG';
const fileSize = '1.44 MB'; // Replace with real size if available
const transferSpeed = '250 KB/s'; // Replace with real speed if available
// Mock image association (replace with real logic if available)
const imageUrl = lastFile.endsWith('.d64') ? '/assets/floppy.png' : undefined;
// Dialog/modal state for reset actions
const [showResetModal, setShowResetModal] = useState<null | 'meatloaf' | 'host'>(null);
const [resetStatus, setResetStatus] = useState('idle'); // 'idle' | 'in-progress' | 'done'
// Overlay state for directory/disk map
const [showDirectory, setShowDirectory] = useState(false);
const [showDiskMap, setShowDiskMap] = useState(false);
return (
<div className="p-4 space-y-4">
{activeDevice && (
<>
<h2 className="text-sm text-neutral-500 pt-2">Active Device</h2>
<div
className="bg-white border border-neutral-200 rounded-lg p-4 relative"
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<HardDrive className="w-5 h-5 text-blue-600" />
</div>
<div>
<div className="font-medium">Device #{activeDevice.number}</div>
<div className="text-sm text-neutral-500">{activeDevice.url}</div>
</div>
</div>
<div className="w-2 h-2 rounded-full bg-green-500" />
</div>
{/* Directory and Disk Map buttons at bottom */}
{/* New device info cards */}
<div className="mb-4">
<div className="bg-neutral-50 rounded-lg p-3 flex flex-col items-start justify-center w-full mb-2">
<div className="text-xs text-neutral-500 mb-1">Last File</div>
<div className="text-sm font-medium break-all w-full text-left">{lastFile}</div>
</div>
<div className="flex flex-row justify-between gap-4 w-full">
<div className="bg-neutral-50 rounded-lg p-3 flex-1 flex flex-col items-start justify-center">
<div className="text-xs text-neutral-500 mb-1">Size</div>
<div className="text-sm font-medium">{fileSize}</div>
</div>
<div className="bg-neutral-50 rounded-lg p-3 flex-1 flex flex-col items-end justify-center">
<div className="text-xs text-neutral-500 mb-1">Transfer Speed</div>
<div className="text-sm font-medium">{transferSpeed}</div>
</div>
</div>
</div>
{/* Progress bar (shows when loading) */}
{loading && (
<div className="w-full h-3 bg-neutral-200 rounded overflow-hidden mb-4">
<div
className="h-3 bg-blue-500 transition-all"
style={{ width: `${progress * 100}%` }}
/>
</div>
)}
{/* Image placeholder if associated */}
{imageUrl && (
<div className="w-full mb-4">
<ImageWithFallback src={imageUrl} alt="Media image" className="w-full h-32 object-contain rounded shadow bg-neutral-100" />
</div>
)}
{/* Media switch buttons if media set is detected */}
{(() => {
// Media set detection logic (copied from DeviceDetailOverlay)
const url = activeDevice.url;
if (!url) return null;
const match = url.match(/^(.+?)(\d+)(\.[^.]+)$/);
if (!match) return null;
const [, prefix, num, ext] = match;
const currentNum = parseInt(num);
const mediaSet = [];
for (let i = 1; i <= 10; i++) {
mediaSet.push(`${prefix}${i}${ext}`);
}
return (
<div className="flex flex-wrap gap-2 mt-2">
{mediaSet.map((file, idx) => {
const fileName = file.split('/').pop() || file;
const title = fileName.replace(/\.[^.]+$/, '');
return (
<button
key={file}
className={`px-2 py-1 rounded text-xs border ${url === file ? 'bg-blue-600 text-white border-blue-600' : 'bg-neutral-100 text-neutral-700 border-neutral-300'}`}
onClick={e => {
e.stopPropagation();
if (setConfig) {
const newConfig = JSON.parse(JSON.stringify(config));
let current = newConfig;
if (current.iec && current.iec.devices && current.iec.devices.drive && current.iec.devices.drive[num]) {
current.iec.devices.drive[num].url = file;
setConfig(newConfig);
}
}
}}
>
{`${idx + 1}: ${title}`}
</button>
);
})}
</div>
);
})()}
<div className="flex flex-col gap-2 mt-6">
<button
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-neutral-200 text-neutral-700 hover:bg-blue-600 hover:text-white transition text-base font-medium w-full"
onClick={() => setShowDirectory(true)}
>
<FolderOpen className="w-5 h-5" /> Show Directory
</button>
<button
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-neutral-200 text-neutral-700 hover:bg-blue-600 hover:text-white transition text-base font-medium w-full"
onClick={() => setShowDiskMap(true)}
>
<Map className="w-5 h-5" /> Show Disk Map
</button>
</div>
</div>
{showDeviceOverlay && (
<DeviceDetailOverlay
device={{
id: `drive-${activeDevice.number}`,
number: activeDevice.number,
type: 'drive',
name: activeDevice.name,
enabled: activeDevice.enabled,
url: activeDevice.url,
mode: activeDevice.mode
}}
config={config}
setConfig={setConfig}
onClose={() => setShowDeviceOverlay(false)}
onNavigate={() => {}}
hasPrev={false}
hasNext={false}
/>
)}
{/* Directory Overlay */}
{showDirectory && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-md" onClick={() => setShowDirectory(false)} />
<div className="relative w-full h-full max-w-2xl sm:rounded-xl bg-white/90 shadow-2xl overflow-auto flex flex-col mx-0 sm:mx-auto my-0 sm:my-20 p-0 sm:p-0" style={{ maxHeight: '100dvh' }}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-xl font-medium">Directory</h2>
<button onClick={() => setShowDirectory(false)} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg"><svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /></svg></button>
</div>
<div className="flex-1 overflow-auto p-4">
<FileBrowser currentPath={activeDevice.url ? activeDevice.url.replace(/\/[^/]+$/, '') : '/'} onSelect={() => {}} onClose={() => setShowDirectory(false)} />
</div>
</div>
</div>
)}
{/* Disk Map Overlay */}
{showDiskMap && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-md" onClick={() => setShowDiskMap(false)} />
<div className="relative w-full h-full max-w-2xl sm:rounded-xl bg-white/90 shadow-2xl overflow-auto flex flex-col mx-0 sm:mx-auto my-0 sm:my-20 p-0 sm:p-0" style={{ maxHeight: '100dvh' }}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-xl font-medium">Disk Map</h2>
<button onClick={() => setShowDiskMap(false)} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg"><svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /></svg></button>
</div>
<div className="flex-1 overflow-auto flex items-center justify-center text-neutral-500 p-4">
<span>Disk map visualization goes here.</span>
</div>
</div>
</div>
)}
{/* Reset Activity Modal */}
<Dialog open={!!showResetModal} onOpenChange={open => !open && setShowResetModal(null)}>
<DialogContent>
<DialogTitle>{showResetModal === 'meatloaf' ? 'Reset Meatloaf' : 'Reset Host'}</DialogTitle>
<DialogDescription>
{resetStatus === 'idle' && (
<>
Are you sure you want to reset {showResetModal === 'meatloaf' ? 'the Meatloaf device' : 'the Host'}?
<div className="flex gap-2 mt-4">
<button
className="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
onClick={() => {
setResetStatus('in-progress');
setTimeout(() => setResetStatus('done'), 2000);
}}
>
Reset
</button>
<button
className="px-4 py-2 rounded bg-neutral-200 text-neutral-700 hover:bg-neutral-300 transition"
onClick={() => setShowResetModal(null)}
>
Cancel
</button>
</div>
</>
)}
{resetStatus === 'in-progress' && (
<div className="flex flex-col items-center gap-4 mt-4">
<span>Resetting...</span>
<div className="w-full h-2 bg-neutral-200 rounded overflow-hidden">
<div className="h-2 bg-blue-500 animate-pulse" style={{ width: '100%' }} />
</div>
</div>
)}
{resetStatus === 'done' && (
<div className="flex flex-col items-center gap-4 mt-4">
<span className="text-green-600">Reset complete!</span>
<button
className="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
onClick={() => setShowResetModal(null)}
>
Close
</button>
</div>
)}
</DialogDescription>
</DialogContent>
</Dialog>
</>
)}
{!activeDevice && (
<div className="bg-white border border-neutral-200 rounded-lg p-8 text-center">
<div className="text-neutral-400 mb-2">
<HardDrive className="w-12 h-12 mx-auto" />
</div>
<div className="text-neutral-600">No active device</div>
<div className="text-sm text-neutral-500 mt-1">
Enable a device to see activity
</div>
</div>
)}
<h2 className="text-sm text-neutral-500 pt-2 flex items-center gap-2">
<Activity className="w-4 h-4" />
Activity Log
</h2>
<div className="bg-white border border-neutral-200 rounded-lg overflow-hidden">
{activityLog.map((entry, index) => (
<div
key={index}
className="px-4 py-3 border-b border-neutral-100 last:border-b-0"
>
<div className="flex items-start gap-3">
<div className="text-xs text-neutral-500 font-mono mt-0.5 w-16 flex-shrink-0">
{entry.time}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<div
className={`w-1.5 h-1.5 rounded-full ${
entry.type === 'success'
? 'bg-green-500'
: entry.type === 'warning'
? 'bg-yellow-500'
: 'bg-blue-500'
}`}
/>
<span className="text-sm text-neutral-900">{entry.event}</span>
</div>
</div>
</div>
</div>
))}
</div>
<h2 className="text-sm text-neutral-500">System Status</h2>
<div className="bg-white border border-neutral-200 rounded-lg p-4">
{/* System Status Action Buttons at bottom */}
<div className="mb-4">
<div className="text-xs text-neutral-500 mb-1">Memory Utilization</div>
<div className="flex flex-col gap-2">
@ -105,154 +386,22 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
</div>
</div>
</div>
<div className="flex flex-col gap-2 mt-6">
<button
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-blue-600 text-white hover:bg-blue-700 transition text-base font-medium w-full"
onClick={() => { setShowResetModal('meatloaf'); setResetStatus('idle'); }}
>
<RefreshCw className="w-5 h-5" /> Reset Meatloaf
</button>
<button
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-blue-600 text-white hover:bg-blue-700 transition text-base font-medium w-full"
onClick={() => { setShowResetModal('host'); setResetStatus('idle'); }}
>
<RefreshCw className="w-5 h-5" /> Reset Host
</button>
</div>
</div>
{activeDevice && (
<>
<h2 className="text-sm text-neutral-500 pt-2">Active Device</h2>
<div
className="bg-white border border-neutral-200 rounded-lg p-4 cursor-pointer hover:bg-neutral-50 transition"
onClick={() => setShowDeviceOverlay(true)}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<HardDrive className="w-5 h-5 text-blue-600" />
</div>
<div>
<div className="font-medium">Device #{activeDevice.number}</div>
<div className="text-sm text-neutral-500">{activeDevice.url}</div>
</div>
</div>
<div className="w-2 h-2 rounded-full bg-green-500" />
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-neutral-50 rounded-lg p-3">
<div className="text-xs text-neutral-500">Bytes Read</div>
<div className="text-lg font-medium">{stats.bytesRead}</div>
</div>
<div className="bg-neutral-50 rounded-lg p-3">
<div className="text-xs text-neutral-500">Bytes Written</div>
<div className="text-lg font-medium">{stats.bytesWritten}</div>
</div>
<div className="bg-neutral-50 rounded-lg p-3">
<div className="text-xs text-neutral-500">Operations</div>
<div className="text-lg font-medium">{stats.operations}</div>
</div>
<div className="bg-neutral-50 rounded-lg p-3">
<div className="text-xs text-neutral-500">Errors</div>
<div className="text-lg font-medium text-green-600">{stats.errors}</div>
</div>
</div>
{/* Media switch buttons if media set is detected */}
{(() => {
// Media set detection logic (copied from DeviceDetailOverlay)
const url = activeDevice.url;
if (!url) return null;
const match = url.match(/^(.+?)(\d+)(\.[^.]+)$/);
if (!match) return null;
const [, prefix, num, ext] = match;
const currentNum = parseInt(num);
const mediaSet = [];
for (let i = 1; i <= 10; i++) {
mediaSet.push(`${prefix}${i}${ext}`);
}
return (
<div className="flex flex-wrap gap-2 mt-2">
{mediaSet.map((file, idx) => (
<button
key={file}
className={`px-2 py-1 rounded text-xs border ${url === file ? 'bg-blue-600 text-white border-blue-600' : 'bg-neutral-100 text-neutral-700 border-neutral-300'}`}
onClick={e => {
e.stopPropagation();
if (setConfig) {
const newConfig = JSON.parse(JSON.stringify(config));
let current = newConfig;
if (current.iec && current.iec.devices && current.iec.devices.drive && current.iec.devices.drive[num]) {
current.iec.devices.drive[num].url = file;
setConfig(newConfig);
}
}
}}
>
{idx + 1}
</button>
))}
</div>
);
})()}
</div>
{showDeviceOverlay && (
<DeviceDetailOverlay
device={{
id: `drive-${activeDevice.number}`,
number: activeDevice.number,
type: 'drive',
name: activeDevice.name,
enabled: activeDevice.enabled,
url: activeDevice.url,
mode: activeDevice.mode
}}
config={config}
setConfig={setConfig}
onClose={() => setShowDeviceOverlay(false)}
onNavigate={() => {}}
hasPrev={false}
hasNext={false}
/>
)}
<h2 className="text-sm text-neutral-500 pt-2 flex items-center gap-2">
<Activity className="w-4 h-4" />
Activity Log
</h2>
<div className="bg-white border border-neutral-200 rounded-lg overflow-hidden">
{activityLog.map((entry, index) => (
<div
key={index}
className="px-4 py-3 border-b border-neutral-100 last:border-b-0"
>
<div className="flex items-start gap-3">
<div className="text-xs text-neutral-500 font-mono mt-0.5 w-16 flex-shrink-0">
{entry.time}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<div
className={`w-1.5 h-1.5 rounded-full ${
entry.type === 'success'
? 'bg-green-500'
: entry.type === 'warning'
? 'bg-yellow-500'
: 'bg-blue-500'
}`}
/>
<span className="text-sm text-neutral-900">{entry.event}</span>
</div>
</div>
</div>
</div>
))}
</div>
</>
)}
{!activeDevice && (
<div className="bg-white border border-neutral-200 rounded-lg p-8 text-center">
<div className="text-neutral-400 mb-2">
<HardDrive className="w-12 h-12 mx-auto" />
</div>
<div className="text-neutral-600">No active device</div>
<div className="text-sm text-neutral-500 mt-1">
Enable a device to see activity
</div>
</div>
)}
</div>
);
}

View File

@ -1,7 +1,15 @@
import { createRoot } from "react-dom/client";
import App from "./app/App.tsx";
import "./styles/index.css";
// Register service worker for PWA installability
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js');
});
}
createRoot(document.getElementById("root")!).render(<App />);