Compare commits
No commits in common. "bb3dd5ee578f480acc6837f41c521108d0a31dbc" and "5e329a7f394a77bdb42f5c32dfb7379aac282439" have entirely different histories.
bb3dd5ee57
...
5e329a7f39
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,2 @@
|
||||||
?archives/*
|
?archives/*
|
||||||
dist/*
|
|
||||||
node_modules/*
|
node_modules/*
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,6 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Meatloaf Config</title>
|
<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>
|
<style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.0 KiB |
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
131
src/app/App.tsx
131
src/app/App.tsx
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
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 } from 'lucide-react';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import StatusPage from './components/StatusPage';
|
import StatusPage from './components/StatusPage';
|
||||||
import DevicesPage from './components/DevicesPage';
|
import DevicesPage from './components/DevicesPage';
|
||||||
|
|
@ -12,23 +12,7 @@ import SearchOverlay from './components/SearchOverlay';
|
||||||
import logoSvg from '../imports/logo.svg';
|
import logoSvg from '../imports/logo.svg';
|
||||||
import configData from '../imports/config.json';
|
import configData from '../imports/config.json';
|
||||||
|
|
||||||
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
|
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools';
|
||||||
|
|
||||||
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() {
|
export default function App() {
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('status');
|
const [currentPage, setCurrentPage] = useState<Page>('status');
|
||||||
|
|
@ -43,94 +27,9 @@ export default function App() {
|
||||||
network: <NetworkPage config={config} setConfig={setConfig} />,
|
network: <NetworkPage config={config} setConfig={setConfig} />,
|
||||||
other: <OtherPage config={config} setConfig={setConfig} />,
|
other: <OtherPage config={config} setConfig={setConfig} />,
|
||||||
general: <GeneralPage config={config} setConfig={setConfig} />,
|
general: <GeneralPage config={config} setConfig={setConfig} />,
|
||||||
tools: <ToolsPage 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 (
|
return (
|
||||||
<div className="size-full flex flex-col bg-neutral-50">
|
<div className="size-full flex flex-col bg-neutral-50">
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
|
|
@ -147,11 +46,10 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
<Search className="w-5 h-5 text-white" />
|
<Search className="w-5 h-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage('apps')}
|
onClick={() => setCurrentPage('tools')}
|
||||||
className={`p-2 hover:bg-[#5e5e5e] rounded-lg${currentPage === 'apps' ? ' bg-[#5e5e5e]' : ''}`}
|
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
|
||||||
aria-label="Apps"
|
|
||||||
>
|
>
|
||||||
<AppWindow className="w-5 h-5 text-white" />
|
<Wrench className="w-5 h-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
|
@ -172,16 +70,6 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
<Settings className="w-4 h-4 text-[#4d4d4d]" />
|
<Settings className="w-4 h-4 text-[#4d4d4d]" />
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setShowProfileMenu(false)}
|
onClick={() => setShowProfileMenu(false)}
|
||||||
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
|
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
|
||||||
|
|
@ -252,6 +140,13 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
<MoreHorizontal className="w-5 h-5 text-white" />
|
<MoreHorizontal className="w-5 h-5 text-white" />
|
||||||
<span className="text-xs text-white">More</span>
|
<span className="text-xs text-white">More</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -326,13 +326,7 @@ export default function DeviceDetailOverlay({
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Media Set</label>
|
<label className="text-sm text-neutral-500 block mb-2">Media Set</label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{mediaSet.files.slice(0, 5).map((file, index) => {
|
{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
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => switchMedia(index)}
|
onClick={() => switchMedia(index)}
|
||||||
|
|
@ -342,10 +336,9 @@ export default function DeviceDetailOverlay({
|
||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{`${index + 1}: ${title}`}
|
Disk {index + 1}
|
||||||
</button>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 z-50 backdrop-blur-md bg-black/40 flex items-center justify-center"
|
className="fixed inset-0 z-50 backdrop-blur-md bg-black/30"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -95,11 +95,10 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
exit={{ y: -50, opacity: 0 }}
|
exit={{ y: -50, opacity: 0 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
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"
|
className="max-w-2xl mx-auto mt-20 bg-white rounded-xl shadow-2xl overflow-hidden"
|
||||||
style={{ maxHeight: '100dvh' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="p-4 sm:p-6 flex-1 flex flex-col">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-medium">Search</h2>
|
<h2 className="text-xl font-medium">Search</h2>
|
||||||
<button onClick={onClose} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg">
|
<button onClick={onClose} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg">
|
||||||
|
|
@ -121,10 +120,10 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
disabled={isSearching}
|
disabled={isSearching}
|
||||||
className="p-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center"
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
|
||||||
aria-label="Search"
|
|
||||||
>
|
>
|
||||||
<Search className="w-5 h-5" />
|
<Search className="w-5 h-5" />
|
||||||
|
Search
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map } from 'lucide-react';
|
import { HardDrive, Activity, Wifi, Signal, Clock } from 'lucide-react';
|
||||||
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
||||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
|
|
||||||
import FileBrowser from './FileBrowser';
|
|
||||||
|
|
||||||
interface StatusPageProps {
|
interface StatusPageProps {
|
||||||
config: any;
|
config: any;
|
||||||
|
|
@ -43,296 +40,18 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
{ time: '14:31:58', event: 'Device reset', type: 'warning' }
|
{ time: '14:31:58', event: 'Device reset', type: 'warning' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const stats = {
|
||||||
// Mock loading/progress state
|
bytesRead: '2.4 MB',
|
||||||
const [loading, setLoading] = useState(false);
|
bytesWritten: '156 KB',
|
||||||
const [progress, setProgress] = useState(0.0); // 0.0 to 1.0
|
operations: 1247,
|
||||||
|
errors: 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 (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<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>
|
<h2 className="text-sm text-neutral-500">System Status</h2>
|
||||||
|
|
||||||
<div className="bg-white border border-neutral-200 rounded-lg p-4">
|
<div className="bg-white border border-neutral-200 rounded-lg p-4">
|
||||||
{/* System Status Action Buttons at bottom */}
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="text-xs text-neutral-500 mb-1">Memory Utilization</div>
|
<div className="text-xs text-neutral-500 mb-1">Memory Utilization</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
@ -386,22 +105,154 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 mt-6">
|
</div>
|
||||||
<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"
|
{activeDevice && (
|
||||||
onClick={() => { setShowResetModal('meatloaf'); setResetStatus('idle'); }}
|
<>
|
||||||
|
<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)}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-5 h-5" /> Reset Meatloaf
|
<div className="flex items-center justify-between mb-4">
|
||||||
</button>
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
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"
|
<HardDrive className="w-5 h-5 text-blue-600" />
|
||||||
onClick={() => { setShowResetModal('host'); setResetStatus('idle'); }}
|
</div>
|
||||||
>
|
<div>
|
||||||
<RefreshCw className="w-5 h-5" /> Reset Host
|
<div className="font-medium">Device #{activeDevice.number}</div>
|
||||||
</button>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
|
|
||||||
|
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./app/App.tsx";
|
import App from "./app/App.tsx";
|
||||||
import "./styles/index.css";
|
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 />);
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user