Compare commits
10 Commits
5e329a7f39
...
bb3dd5ee57
| Author | SHA1 | Date | |
|---|---|---|---|
| bb3dd5ee57 | |||
| 939565ce5a | |||
| 2643178b61 | |||
| a8b1aadb1c | |||
| fa3f84e42e | |||
| 00572089e3 | |||
| a5627ef860 | |||
| c6d382a88d | |||
| 6b0ffb7de2 | |||
| 4c2ce166e8 |
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
?archives/*
|
||||
dist/*
|
||||
node_modules/*
|
||||
|
|
|
|||
|
|
@ -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
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
BIN
public/icon.512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
21
public/manifest.webmanifest
Normal file
21
public/manifest.webmanifest
Normal 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
15
public/service-worker.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
143
src/app/App.tsx
143
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 } 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user