Compare commits

..

9 Commits

10 changed files with 403 additions and 477 deletions

111
AGENTS.md
View File

@ -8,7 +8,8 @@
- **Framework**: React 18 + Vite 6 - **Framework**: React 18 + Vite 6
- **Styling**: Tailwind CSS 4 (via `@tailwindcss/vite`) - **Styling**: Tailwind CSS 4 (via `@tailwindcss/vite`)
- **UI components**: Radix UI primitives, MUI icons (`@mui/icons-material`), Lucide React icons - **UI components**: Radix UI primitives, Lucide React icons
- **3D / animation**: Three.js r0.160 (`WebGLRenderer`, `EffectComposer`, `UnrealBloomPass`, custom GLSL shaders)
- **Routing**: React Router 7 (single-page, page state managed in `App.tsx`) - **Routing**: React Router 7 (single-page, page state managed in `App.tsx`)
- **Build base path**: `/config/` (overridable via `BASE_PATH` env var) - **Build base path**: `/config/` (overridable via `BASE_PATH` env var)
@ -17,21 +18,27 @@
``` ```
src/ src/
app/ app/
App.tsx # Root component: routing, nav, layout, SaveStatusBadge App.tsx # Root component: routing, nav, layout, SaveStatusBadge, WsProvider wrapper
settings.ts # useSettings() hook — load/save config.json via WebDAV settings.ts # useSettings() hook — loads config.json + devices.json, saves split
webdav.ts # WebDAV abstraction (listDirectory, stat, put/get, etc.) webdav.ts # WebDAV abstraction (listDirectory, stat, put/get, fileExists, etc.)
ws.tsx # WsProvider + useWs() — single shared WebSocket connection context
components/ components/
StatusPage.tsx # System status, activity log, file info, progress, overlays StatusPage.tsx # System status, activity log, reset (via WS), WS status indicator
DevicesPage.tsx # Device list DevicesPage.tsx # Device list, Rescan Bus (sends "iec scan" via WS)
DeviceDetailOverlay.tsx DeviceDetailOverlay.tsx
GeneralPage.tsx # General/settings GeneralPage.tsx # General/settings
IECPage.tsx # IEC bus configuration IECPage.tsx # IEC bus configuration
NetworkPage.tsx # Network settings NetworkPage.tsx # Network settings, WiFi scan/connect (via WS)
OtherPage.tsx # Misc settings OtherPage.tsx # Misc settings
ToolsPage.tsx # Tools ToolsPage.tsx # Tools
SearchOverlay.tsx SearchOverlay.tsx
WiFiScanOverlay.tsx WiFiScanOverlay.tsx # WiFi scan results; Connect sends "connect <ssid> [<pass>]" via WS
MediaBrowser.tsx # WebDAV file browser (file click = select, kebab menu) MediaManager.tsx # WebDAV file browser: file icons by type, kebab actions,
# Configure Folder (.config editor), base_url mount logic,
# media set existence check, navigate into new folder
RealityOverridePage.tsx # Full-screen WS command display; Three.js Digital Tokamak
# vortex + star field; double-tap to toggle background
RealityOverrideAdminPage.tsx # Command palette (Image/Audio/Video); freeform input; WS send
figma/ # Figma-generated components figma/ # Figma-generated components
ui/ # shadcn/Radix UI wrappers ui/ # shadcn/Radix UI wrappers
vendor/ vendor/
@ -40,14 +47,18 @@ src/
package.json package.json
imports/ imports/
logo.svg logo.svg
# config.json removed — config now loaded at runtime from /.sys/config.json config.json # Bundled fallback (full merged config including iec.devices)
main.tsx main.tsx
styles/ styles/
public/ public/
manifest.webmanifest manifest.webmanifest
icon.192.png / icon.512.png icon.192.png / icon.512.png
service-worker.js # PWA service worker service-worker.js # PWA service worker
webdav3.py # Dev Python WebDAV server (port 80, serves files/) files/
.sys/
config.json # Runtime config on device (no iec.devices)
devices.json # Runtime device list: { "iec": { "devices": { ... } } }
webdav3.py # Dev Python WebDAV + WebSocket server (port 80, serves files/)
index.html index.html
vite.config.ts vite.config.ts
``` ```
@ -67,16 +78,17 @@ Bottom tab bar + header icons:
| (profile) | `general` | GeneralPage | | (profile) | `general` | GeneralPage |
| (profile) | `tools` | ToolsPage | | (profile) | `tools` | ToolsPage |
Header also has: fullscreen toggle (Maximize2/Minimize2), search, apps grid, save-status badge, profile menu. Logo click → status page.
## Apps Page ## Apps Page
Grid of app cards grouped by category, each navigates to a stub `AppPage`: Grid of app cards grouped by category, each navigates to a stub `AppPage` unless implemented:
- **Disk**: Directory Editor, Sector Editor, BAM Editor, Disk Visualizer, RAM/ROM Explorer, Dump Disk Image, Write Disk Image - **Management**: Media Manager, Print Manager, Serial Console, Short Codes
- **Disk**: RAM/ROM Explorer, BAM Editor, Directory Editor, Sector Editor, Disk Visualizer, Dump/Write Disk Image
- **Cartridge**: PRG to CRT, Magic Desk Cart Builder, Easy Flash Cart Builder - **Cartridge**: PRG to CRT, Magic Desk Cart Builder, Easy Flash Cart Builder
- **Development**: Basic Editor, Assembler, Sprite Editor, Character Set Editor, Petscii Editor - **Development**: Basic Editor, Assembler, Sprite Editor, Character Set Editor, Petscii Editor
- **Display**: Idle Animation, Loading Animation - **Display**: Idle Animation, Loading Animation, **Reality Override** (implemented), **Override Admin** (implemented)
All individual app pages are currently stubs (`AppPage` component with "coming soon" message).
## Work to Date (chronological) ## Work to Date (chronological)
@ -93,18 +105,73 @@ All individual app pages are currently stubs (`AppPage` component with "coming s
11. **WebDAV client** — replaced `webdav@5` npm package with vendored `webdav-component` (browser-native `fetch` + `DOMParser`, no external deps). All pages import from `webdav.ts` abstraction layer. 11. **WebDAV client** — replaced `webdav@5` npm package with vendored `webdav-component` (browser-native `fetch` + `DOMParser`, no external deps). All pages import from `webdav.ts` abstraction layer.
12. **WebDAV path fixes**`webdav.ts`: always `decodeURIComponent` paths; use `entry.uri` (not broken `entry.path`) for servers returning relative hrefs 12. **WebDAV path fixes**`webdav.ts`: always `decodeURIComponent` paths; use `entry.uri` (not broken `entry.path`) for servers returning relative hrefs
13. **`webdav3.py` server fixes** — `displayname` now returns leaf name only (not full path); PROPFIND depth-1 guard prevents crash when called on a file 13. **`webdav3.py` server fixes** — `displayname` now returns leaf name only (not full path); PROPFIND depth-1 guard prevents crash when called on a file
14. **MediaBrowser redesign** — file click = `onSelect` + close; folder click = navigate; per-row kebab (`MoreVert`) opens a Dialog with contextual actions; permanent "Select Folder" button in footer; no mode-toggle buttons 14. **MediaBrowser redesign** — file click = `onSelect` + close; folder click = navigate; per-row kebab (`MoreVert`) opens a Dialog with contextual actions; permanent "Select Folder" button in footer
15. **Settings persistence**`settings.ts` + `useSettings()` hook: loads `/.sys/config.json` via WebDAV on mount, auto-saves 3 s after last change, exposes `saveStatus` / `pendingCount` / `flushNow`; `beforeunload` flushes via `fetch keepalive` 15. **Settings persistence**`settings.ts` + `useSettings()` hook: loads `/.sys/config.json` via WebDAV on mount, auto-saves 3 s after last change, exposes `saveStatus` / `pendingCount` / `flushNow`; `beforeunload` flushes via `fetch keepalive`
16. **Save-status badge**`SaveStatusBadge` in `App.tsx` header shows: idle (hidden), loading spinner, amber "N unsaved + Save button", saving spinner, saved checkmark, red error + retry 16. **Save-status badge**`SaveStatusBadge` in `App.tsx` header shows: idle (hidden), loading spinner, amber "N unsaved + Save button", saving spinner, saved checkmark, red error + retry
17. **File icons by extension** — MediaManager shows `Disc` icon for disk extensions (`.d64`, `.d71`, `.d81`, etc.) and `HardDrive` icon for HD extensions (`.img`, `.iso`, etc.)
18. **Device URL display** — StatusPage shows `device.base_url + device.url` concatenated
19. **WebSocket server**`webdav3.py` upgraded with RFC 6455 WebSocket handler at `/ws`; echoes received frames to all connected clients; thread-safe broadcast
20. **RealityOverridePage** — full-screen page that subscribes to WS commands and displays them centered on screen; Three.js Digital Tokamak vortex background with `UnrealBloomPass`; twinkling star field canvas always running beneath
21. **RealityOverrideAdminPage** — command palette (Image/Audio/Video conversion groups, 30 commands); freeform command input with Enter key support; sent-command flash indicator
22. **Source map warnings fixed** — stripped all `//# sourceMappingURL=` comments from 26 vendored webdav-component ESM files
23. **MediaManager: navigate into new folder** — after `handleCreateFolder`, automatically calls `load(newPath)` to navigate into the created folder
24. **MediaManager: Configure Folder** — "Configure Folder" action in kebab menu creates `.config` if absent and opens it in ConfigEditor; saving re-parses content into `folderConfig` state immediately and closes editor
25. **Mount logic: base_url=`.`** — in `.config`, `base_url: .` resolves to the current folder path; mounting a file inside the base sets `url` to the relative path; mounting outside clears `base_url`
26. **Media Set existence check**`Promise.all(fileExists)` filter applied before building media set in both MediaManager and DeviceDetailOverlay; toast warning shown if some files are missing
27. **Mobile PWA fullscreen**`viewport-fit=cover`, `apple-mobile-web-app-capable`, `black-translucent` status bar style, `apple-mobile-web-app-title`; body `overscroll-behavior: none`; safe-area CSS env() insets on header and nav
28. **Fullscreen API button** — header Maximize2/Minimize2 button; webkit prefix fallback; `isFullscreen` state tracks `fullscreenchange` event
29. **Logo click → status page** — logo wrapped in `<button onClick={() => setCurrentPage('status')}>`
30. **Double-tap to toggle vortex**`onPointerDown` handler with `isPrimary` guard and 400 ms window; `pausedRef` + `bgVisible` state; mode persisted in `localStorage` key `ro-bg`
31. **Shared WebSocket context**`ws.tsx`: `WsProvider` manages a single `WebSocket` to `ws://<hostname>/ws`; subscriber pattern via `listeners` ref Set; auto-reconnect after 3 s; `useWs()` exposes `{ status, send, subscribe }`; `App.tsx` return wrapped in `<WsProvider>`
32. **Components migrated to shared WS**`RealityOverridePage` and `RealityOverrideAdminPage` removed own WS effects; now use `useWs()`; status indicator uses `Radio` icon (distinct from `Wifi` used for WiFi)
33. **StatusPage WS status**`Radio` icon + live `wsStatus` shown in System Status grid alongside WiFi
34. **Reset via WebSocket** — "Reset Meatloaf" sends `"reset"`; "Reset Host" sends `"reset hard"` via shared WS on confirm
35. **Rescan Bus via WebSocket** — DevicesPage "Rescan Bus" button sends `"iec scan"` via shared WS
36. **WiFi Scan via WebSocket** — NetworkPage "Scan" button sends `"scan"` via shared WS before opening overlay
37. **WiFi connect via WebSocket** — WiFiScanOverlay "Connect" sends `"connect <ssid>"` or `"connect <ssid> <password>"`; NetworkPage known-network click sends same command, marks network `enabled: 1`, moves it to top of list
38. **Split config / devices storage**`settings.ts` loads `/.sys/config.json` and `/.sys/devices.json` in parallel; merges with one-level deep merge (so `iec.devices` from devices.json is merged into `iec` from config.json); saves with split: `iec.devices``devices.json`, everything else (including remaining `iec` bus settings) → `config.json`; `beforeunload` flush also split across both files
## Known Issues / Open Work ## Known Issues / Open Work
- **Service worker 404**: `https://meatloaf.cc/service-worker.js` returns 404. The SW is not being served at the correct path relative to the `/config/` base. - **Service worker 404**: `https://meatloaf.cc/service-worker.js` returns 404. The SW is not being served at the correct path relative to the `/config/` base.
- **App pages**: All individual app pages (Directory Editor, Sector Editor, etc.) are stubs. Actual implementations are pending. - **App pages**: Most individual app pages are stubs. Reality Override and Override Admin are implemented; all others show "coming soon".
- **WiFi scan results**: Currently mock data. Needs real scan results delivered over WebSocket from the device.
- **Reset confirmation**: The "in-progress" and "done" states are simulated with `setTimeout`. Needs real completion signal from the device over WebSocket.
## Configuration Shape ## Configuration Shape
Config is stored at `files/.sys/config.json` on the device and loaded at runtime via WebDAV. Top-level keys: `general`, `host`, `hardware`, `wifi`, `network`, `bluetooth`, `modem`, `cassette`, `iec`, `boip`. The `useSettings()` hook in `settings.ts` provides `config` / `setConfig` to all page components (passed as props from `App.tsx`). Config is split across two files on the device:
| File | Contents |
|---|---|
| `/.sys/config.json` | `general`, `host`, `hardware`, `wifi`, `network`, `bluetooth`, `modem`, `cassette`, `boip`, `iec` (bus settings only — no `iec.devices`) |
| `/.sys/devices.json` | `{ "iec": { "devices": { "printer": {...}, "drive": {...}, "network": {...}, "other": {...}, "meatloaf": {...} } } }` |
The `useSettings()` hook in `settings.ts` loads both files on mount, merges them into a single `config` object, and splits them back on save. All page components receive the unified `config` / `setConfig` props from `App.tsx`.
The bundled `src/imports/config.json` is used as the initial UI state while the server fetch is in flight. It contains the full merged shape (including `iec.devices`) so the UI renders immediately without waiting.
## WebSocket Layer
A single `WebSocket` connection to `ws://<hostname>/ws` is managed by `WsProvider` in `ws.tsx` and shared app-wide via React Context.
| Export | Purpose |
|---|---|
| `WsProvider` | Mounts in `App.tsx`; owns connection lifecycle + auto-reconnect |
| `useWs()` | Returns `{ status: WsStatus, send, subscribe }` |
| `WsStatus` | `'connecting' \| 'connected' \| 'disconnected'` |
| `send(msg)` | Sends a string frame if socket is open |
| `subscribe(handler)` | Adds a message listener; returns unsubscribe function |
Commands sent to the device:
| Command | Sent from | Trigger |
|---|---|---|
| `reset` | StatusPage | Reset Meatloaf confirmed |
| `reset hard` | StatusPage | Reset Host confirmed |
| `iec scan` | DevicesPage | Rescan Bus button |
| `scan` | NetworkPage | WiFi Scan button |
| `connect <ssid> [<pass>]` | NetworkPage / WiFiScanOverlay | Network tap / Connect button |
## WebDAV Layer ## WebDAV Layer
@ -116,14 +183,14 @@ All WebDAV I/O goes through `src/app/webdav.ts`:
| `listDirectory(path)` | PROPFIND depth-1, returns `EntryInfo[]` (filters self-entry + non-direct-children) | | `listDirectory(path)` | PROPFIND depth-1, returns `EntryInfo[]` (filters self-entry + non-direct-children) |
| `stat(path)` | PROPFIND depth-0, returns `EntryInfo \| null` | | `stat(path)` | PROPFIND depth-0, returns `EntryInfo \| null` |
| `putFileContents(path, data)` | PUT | | `putFileContents(path, data)` | PUT |
| `getFileContents(path)` | GET → `string` | | `getFileContents(path)` | GET → `Blob` |
| `createFolder(path)` | MKCOL | | `createFolder(path)` | MKCOL |
| `deletePath(path)` | DELETE | | `deletePath(path)` | DELETE |
| `movePath(from, to)` | MOVE | | `movePath(from, to)` | MOVE |
| `fileExists(path)` | HEAD | | `fileExists(path)` | HEAD `boolean` |
| `humanFileSize(bytes)` | Formatting helper | | `humanFileSize(bytes)` | Formatting helper |
| `normalizePath` / `splitPath` / `joinPath` / `basename` | Path utilities | | `normalizePath` / `splitPath` / `joinPath` / `basename` | Path utilities |
`EntryInfo`: `{ name, path, type: 'folder'\|'file', size, lastModified, contentType }` `EntryInfo`: `{ name, path, type: 'folder'|'file', size, lastModified, contentType }`
The vendored `webdav-component` lives at `src/app/vendor/webdav-component/esm/index.js`. Known quirk: `entry.path` is broken for servers that return relative hrefs — always use `entry.uri` in the wrapper. The vendored `webdav-component` lives at `src/app/vendor/webdav-component/esm/index.js`. Known quirk: `entry.path` is broken for servers that return relative hrefs — always use `entry.uri` in the wrapper.

View File

@ -5,17 +5,38 @@ A Progressive Web App (PWA) for configuring and managing [Meatloaf](https://meat
## Features ## Features
- **Status** — live system status, activity log, file info, loading progress, directory and disk map overlays - **Status** — live system status, activity log, file info, loading progress, directory and disk map overlays; WebSocket connection status; reset device or host (sends command via WebSocket)
- **Devices** — browse and manage attached storage devices with detailed device overlays - **Devices** — browse and manage attached IEC devices; Rescan Bus sends `iec scan` via WebSocket
- **IEC** — configure the IEC serial bus - **IEC** — configure the IEC serial bus (fastloaders, chainloaders, directory options)
- **Network** — manage network settings and scan for Wi-Fi networks - **Network** — manage WiFi networks (scan, connect, remove); clicking a known network reconnects and moves it to the top; all connect/scan actions send commands via WebSocket
- **Media Manager** — WebDAV file browser with per-row action menus, folder configuration (`.config` editor), media set detection with existence checks, and smart `base_url` mount logic
- **Reality Override** — full-screen WebSocket command display over a Three.js Digital Tokamak vortex; double-tap to toggle background; twinkling star field when hidden
- **Reality Override Admin** — command palette for Image/Audio/Video conversion commands plus freeform input; broadcasts to all connected clients via WebSocket
- **Apps** — built-in tools grouped by category: - **Apps** — built-in tools grouped by category:
- **Disk**: Directory Editor, Sector Editor, BAM Editor, Disk Visualizer, RAM/ROM Explorer, Dump/Write Disk Image - **Management**: Media Manager, Print Manager, Serial Console, Short Codes
- **Disk**: RAM/ROM Explorer, BAM Editor, Directory Editor, Sector Editor, Disk Visualizer, Dump/Write Disk Image
- **Cartridge**: PRG to CRT, Magic Desk Cart Builder, Easy Flash Cart Builder - **Cartridge**: PRG to CRT, Magic Desk Cart Builder, Easy Flash Cart Builder
- **Development**: Basic Editor, Assembler, Sprite Editor, Character Set Editor, PETSCII Editor - **Development**: Basic Editor, Assembler, Sprite Editor, Character Set Editor, PETSCII Editor
- **Display**: Idle Animation, Loading Animation - **Display**: Idle Animation, Loading Animation, Reality Override, Override Admin
- **Tools & Settings** — general configuration and utility tools - **PWA** — installable on desktop and mobile; runs offline via service worker; fullscreen-capable with safe-area insets for notched phones
- **PWA** — installable on desktop and mobile; runs offline via service worker
## Architecture Highlights
### Shared WebSocket
A single `WebSocket` connection to `ws://<device-hostname>/ws` is managed by `WsProvider` (`src/app/ws.tsx`) and shared app-wide via React Context. Any component can send commands or subscribe to incoming frames via `useWs()`. The connection auto-reconnects every 3 s on drop.
### Split Config Storage
Device settings are stored in two separate files on the device (LittleFS erase-block friendly, ≤ 4 096 bytes each):
| File | Contents |
|---|---|
| `/.sys/config.json` | General, host, hardware, WiFi, network, Bluetooth, modem, cassette, BOIP, IEC bus settings |
| `/.sys/devices.json` | IEC device list (`iec.devices`) |
`useSettings()` loads both files in parallel and merges them; saves split them back automatically. A bundled fallback (`src/imports/config.json`) is used as the initial UI state while the device is being contacted.
### WebDAV Layer
All file I/O goes through `src/app/webdav.ts`, which wraps the vendored `webdav-component` (browser-native `fetch` + `DOMParser`, no npm dependencies).
## Requirements ## Requirements
@ -38,6 +59,14 @@ npm run dev
The app is served at `http://localhost:5173/config/` by default. The app is served at `http://localhost:5173/config/` by default.
Start the local WebDAV + WebSocket dev server (requires Python 3):
```bash
python webdav3.py
```
This serves `files/` over WebDAV and provides a `/ws` WebSocket endpoint on port 80.
To use a different base path: To use a different base path:
```bash ```bash

View File

@ -1,12 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, 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, Check, AlertCircle, RefreshCw, Terminal, Link, Printer, Maximize2, Minimize2 } from 'lucide-react'; import { Cpu, Settings, Wifi, Network, HardDrive, Activity, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw, Terminal, Link, Printer, Maximize2, Minimize2 } 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';
import GeneralPage from './components/GeneralPage'; import GeneralPage from './components/GeneralPage';
import NetworkPage from './components/NetworkPage'; import NetworkPage from './components/NetworkPage';
import IECPage from './components/IECPage'; import IECPage from './components/IECPage';
import OtherPage from './components/OtherPage';
import ToolsPage from './components/ToolsPage'; import ToolsPage from './components/ToolsPage';
import SearchOverlay from './components/SearchOverlay'; import SearchOverlay from './components/SearchOverlay';
import MediaManager from './components/MediaManager'; import MediaManager from './components/MediaManager';
@ -16,7 +15,7 @@ import logoSvg from '../imports/logo.svg';
import { useSettings } from './settings'; import { useSettings } from './settings';
import { WsProvider } from './ws'; import { WsProvider } from './ws';
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId; type Page = 'status' | 'devices' | 'iec' | 'network' | 'general' | 'tools' | 'apps' | AppId;
type AppId = type AppId =
| 'file-manager' | 'file-manager'
@ -73,7 +72,6 @@ export default function App() {
devices: <DevicesPage config={config} setConfig={setConfig} openDeviceId={devicesOpenId} onClearOpenDevice={() => setDevicesOpenId(null)} />, devices: <DevicesPage config={config} setConfig={setConfig} openDeviceId={devicesOpenId} onClearOpenDevice={() => setDevicesOpenId(null)} />,
iec: <IECPage config={config} setConfig={setConfig} />, iec: <IECPage config={config} setConfig={setConfig} />,
network: <NetworkPage config={config} setConfig={setConfig} />, network: <NetworkPage 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: ( apps: (
@ -253,16 +251,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"
@ -327,11 +315,11 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
<span className="text-xs text-white">Network</span> <span className="text-xs text-white">Network</span>
</button> </button>
<button <button
onClick={() => setCurrentPage('other')} onClick={() => setCurrentPage('tools')}
className="flex-1 flex flex-col items-center gap-1 py-2" className="flex-1 flex flex-col items-center gap-1 py-2"
> >
<MoreHorizontal className="w-5 h-5 text-white" /> <Wrench className="w-5 h-5 text-white" />
<span className="text-xs text-white">More</span> <span className="text-xs text-white">Tools</span>
</button> </button>
</div> </div>
</nav> </nav>

View File

@ -7,11 +7,13 @@ import { Eye, Pencil, Save } from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
export type CodeMode = 'text' | 'json' | 'xml'; export type CodeMode = 'text' | 'json' | 'xml' | 'code';
interface CodeEditorProps { interface CodeEditorProps {
text: string; text: string;
mode: CodeMode; mode: CodeMode;
/** Prism language id for syntax highlighting in view mode (used when mode='code'). */
syntaxHighlightLang?: string;
readOnly?: boolean; readOnly?: boolean;
onSave?: (text: string) => Promise<void>; onSave?: (text: string) => Promise<void>;
} }
@ -27,10 +29,11 @@ const langExt: Record<CodeMode, any> = {
json: json(), json: json(),
xml: xml(), xml: xml(),
text: [], text: [],
code: [], // no CM lang pack needed; SyntaxHighlighter handles view-mode
}; };
const syntaxLang: Record<CodeMode, string> = { const syntaxLang: Record<CodeMode, string> = {
text: 'text', json: 'json', xml: 'xml', text: 'text', json: 'json', xml: 'xml', code: 'text',
}; };
function prettify(text: string, mode: CodeMode): string { function prettify(text: string, mode: CodeMode): string {
@ -40,7 +43,7 @@ function prettify(text: string, mode: CodeMode): string {
return text; return text;
} }
export default function CodeEditor({ text, mode, readOnly = false, onSave }: CodeEditorProps) { export default function CodeEditor({ text, mode, syntaxHighlightLang, readOnly = false, onSave }: CodeEditorProps) {
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [editInitText, setEditInitText] = useState(''); const [editInitText, setEditInitText] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -71,7 +74,7 @@ export default function CodeEditor({ text, mode, readOnly = false, onSave }: Cod
)} )}
<div className="flex-1 overflow-auto text-xs"> <div className="flex-1 overflow-auto text-xs">
<SyntaxHighlighter <SyntaxHighlighter
language={syntaxLang[mode]} language={syntaxHighlightLang ?? syntaxLang[mode]}
style={vscDarkPlus} style={vscDarkPlus}
customStyle={{ margin: 0, minHeight: '100%', background: '#0a0a0a', fontSize: '12px', lineHeight: '1.5' }} customStyle={{ margin: 0, minHeight: '100%', background: '#0a0a0a', fontSize: '12px', lineHeight: '1.5' }}
showLineNumbers showLineNumbers

View File

@ -36,6 +36,11 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
const [selectedDeviceIndex, setSelectedDeviceIndex] = useState<number | null>(null); const [selectedDeviceIndex, setSelectedDeviceIndex] = useState<number | null>(null);
const [isScanning, setIsScanning] = useState(false); const [isScanning, setIsScanning] = useState(false);
const hardware = config.hardware || {};
const modem = config.modem || {};
const cassette = config.cassette || {};
const boip = config.boip || {};
const devices: Device[] = []; const devices: Device[] = [];
// Printer devices // Printer devices
@ -319,6 +324,112 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
hasNext={selectedDeviceIndex < devices.length - 1} hasNext={selectedDeviceIndex < devices.length - 1}
/> />
)} )}
{/* ── Hardware ── */}
<h2 className="text-sm text-neutral-500 pt-4">Hardware</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">PS/2</label>
<button
onClick={() => updateSetting(['hardware', 'ps2'], hardware.ps2 ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${hardware.ps2 ? 'bg-blue-600' : 'bg-neutral-300'}`}
>
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${hardware.ps2 ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
</div>
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">User Port Enabled</label>
<button
onClick={() => updateSetting(['hardware', 'userport', 'enabled'], hardware.userport?.enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${hardware.userport?.enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
>
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${hardware.userport?.enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">User Port Mode</label>
<select
value={hardware.userport?.mode?.split('|')[0] || 'serial'}
onChange={(e) => updateSetting(['hardware', 'userport', 'mode'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="serial">Serial</option>
<option value="parallel">Parallel</option>
<option value="IEEE-488">IEEE-488</option>
</select>
</div>
</div>
{/* ── Modem ── */}
<h2 className="text-sm text-neutral-500 pt-4">Modem</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Modem Enabled</label>
<button
onClick={() => updateSetting(['modem', 'modem_enabled'], modem.modem_enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${modem.modem_enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
>
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${modem.modem_enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
</div>
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Sniffer Enabled</label>
<button
onClick={() => updateSetting(['modem', 'sniffer_enabled'], modem.sniffer_enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${modem.sniffer_enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
>
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${modem.sniffer_enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
</div>
</div>
{/* ── Cassette ── */}
<h2 className="text-sm text-neutral-500 pt-4">Cassette</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Enabled</label>
<button
onClick={() => updateSetting(['cassette', 'enabled'], cassette.enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${cassette.enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
>
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${cassette.enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Play/Record</label>
<input type="text" value={cassette.play_record || ''} onChange={(e) => updateSetting(['cassette', 'play_record'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Pulldown</label>
<input type="text" value={cassette.pulldown || ''} onChange={(e) => updateSetting(['cassette', 'pulldown'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">URL</label>
<input type="text" value={cassette.url || ''} onChange={(e) => updateSetting(['cassette', 'url'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
</div>
</div>
{/* ── BOIP ── */}
<h2 className="text-sm text-neutral-500 pt-4">BOIP</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Enabled</label>
<button
onClick={() => updateSetting(['boip', 'enabled'], boip.enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${boip.enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
>
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${boip.enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Host</label>
<input type="text" value={boip.host || ''} onChange={(e) => updateSetting(['boip', 'host'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Port</label>
<input type="text" value={boip.port || ''} onChange={(e) => updateSetting(['boip', 'port'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
</div>
</div>
</div> </div>
); );
} }

View File

@ -35,6 +35,7 @@ import {
RefreshCw, RefreshCw,
Save, Save,
Search, Search,
Terminal,
Trash2, Trash2,
Upload, Upload,
X, X,
@ -76,22 +77,23 @@ import {
type SortKey = 'name' | 'size' | 'date'; type SortKey = 'name' | 'size' | 'date';
type Clipboard = { op: 'copy' | 'move'; paths: string[] }; type Clipboard = { op: 'copy' | 'move'; paths: string[] };
type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config'; type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config' | 'code';
// ─── Extension sets ────────────────────────────────────────────────────────── // ─── Extension sets ──────────────────────────────────────────────────────────
const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'bas', 'asm', 'seq', 'rel', 'prg', 'log', 'csv', 's', 'lst']); const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']);
const CODE_EXTS = new Set(['asm', 'bas', 's', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'py', 'c', 'cpp', 'h', 'hpp', 'lua', 'sh', 'bash', 'php', 'rb', 'rs', 'go', 'java', 'cs', 'kt', 'sql', 'pl']);
const MD_EXTS = new Set(['md', 'markdown']); const MD_EXTS = new Set(['md', 'markdown']);
const JSON_EXTS = new Set(['json']); const JSON_EXTS = new Set(['json', 'webmanifest']);
const XML_EXTS = new Set(['xml', 'svg', 'html', 'htm', 'rss', 'atom', 'xsl']); const XML_EXTS = new Set(['xml', 'html', 'htm', 'rss', 'atom', 'xsl']);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']); const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']);
const AUDIO_EXTS = new Set(['sid', 'psid', 'mus', 'vgm']); const AUDIO_EXTS = new Set(['sid', 'psid', 'rsid', 'mus', 'vgm']);
const ROM_EXTS = new Set(['bin', 'rom', 'crt']); const ROM_EXTS = new Set(['bin', 'rom', 'crt']);
const TAPE_EXTS = new Set(['tap', 'htap', 't64', 'tcrt']); const TAPE_EXTS = new Set(['tap', 'htap', 't64', 'tcrt']);
const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', 'g64', 'g71', 'g81', 'p64', 'p71', 'p81', 'nib']); const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', 'g64', 'g71', 'g81', 'p64', 'p71', 'p81', 'nib']);
const DISC_EXTS = new Set(['iso', 'img', 'cue']); const DISC_EXTS = new Set(['iso', 'img', 'cue']);
const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd']); const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd', 'bbt', 'd8b', 'dfi']);
const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx', 'bbt', 'd8b', 'dfi']); const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']);
const CONFIG_EXTS = new Set(['config']); const CONFIG_EXTS = new Set(['config']);
function defaultViewMode(entry: EntryInfo): ViewMode { function defaultViewMode(entry: EntryInfo): ViewMode {
@ -101,6 +103,7 @@ function defaultViewMode(entry: EntryInfo): ViewMode {
if (MD_EXTS.has(ext)) return 'markdown'; if (MD_EXTS.has(ext)) return 'markdown';
if (JSON_EXTS.has(ext)) return 'json'; if (JSON_EXTS.has(ext)) return 'json';
if (XML_EXTS.has(ext)) return 'xml'; if (XML_EXTS.has(ext)) return 'xml';
if (CODE_EXTS.has(ext)) return 'code';
if (TEXT_EXTS.has(ext)) return 'text'; if (TEXT_EXTS.has(ext)) return 'text';
return 'hex'; return 'hex';
} }
@ -113,6 +116,7 @@ function availableViewers(entry: EntryInfo): ViewMode[] {
markdown: ['markdown', 'text', 'hex'], markdown: ['markdown', 'text', 'hex'],
json: ['json', 'text', 'hex'], json: ['json', 'text', 'hex'],
xml: ['xml', 'text', 'hex'], xml: ['xml', 'text', 'hex'],
code: ['code', 'text', 'hex'],
text: ['text', 'hex'], text: ['text', 'hex'],
hex: ['hex', 'text'], hex: ['hex', 'text'],
}; };
@ -120,7 +124,17 @@ function availableViewers(entry: EntryInfo): ViewMode[] {
} }
const VIEWER_LABEL: Record<ViewMode, string> = { const VIEWER_LABEL: Record<ViewMode, string> = {
text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image', config: 'Config', code: 'Code', text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'HTML/XML', hex: 'Hex', image: 'Image', config: 'Config',
};
const EXT_TO_LANG: Record<string, string> = {
asm: 'nasm', s: 'nasm', bas: 'basic',
js: 'javascript', ts: 'typescript', jsx: 'jsx', tsx: 'tsx',
css: 'css', scss: 'scss',
py: 'python', c: 'c', cpp: 'cpp', h: 'cpp', hpp: 'cpp',
lua: 'lua', sh: 'bash', bash: 'bash',
php: 'php', rb: 'ruby', rs: 'rust', go: 'go',
java: 'java', cs: 'csharp', kt: 'kotlin', sql: 'sql', pl: 'perl',
}; };
// ─── Viewer components ─────────────────────────────────────────────────────── // ─── Viewer components ───────────────────────────────────────────────────────
@ -131,6 +145,7 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin
case 'text': return <AlignLeft className={cls} />; case 'text': return <AlignLeft className={cls} />;
case 'markdown': return <BookOpen className={cls} />; case 'markdown': return <BookOpen className={cls} />;
case 'json': return <Braces className={cls} />; case 'json': return <Braces className={cls} />;
case 'code': return <Terminal className={cls} />;
case 'xml': return <Code2 className={cls} />; case 'xml': return <Code2 className={cls} />;
case 'hex': return <Hash className={cls} />; case 'hex': return <Hash className={cls} />;
case 'image': return <ImageIcon className={cls} />; case 'image': return <ImageIcon className={cls} />;
@ -613,7 +628,9 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
const bytes = await _getEntryBytes(entry); const bytes = await _getEntryBytes(entry);
if (targetMode === 'image') { if (targetMode === 'image') {
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
setViewImgUrl(URL.createObjectURL(new Blob([ab]))); const imgExt = entry.name.split('.').pop()?.toLowerCase();
const imgMime = imgExt === 'svg' ? 'image/svg+xml' : undefined;
setViewImgUrl(URL.createObjectURL(new Blob([ab], imgMime ? { type: imgMime } : undefined)));
} else if (targetMode === 'hex') { } else if (targetMode === 'hex') {
setViewHexData(bytes); setViewHexData(bytes);
} else { } else {
@ -636,7 +653,9 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
const bytes = await _getEntryBytes(viewEntry); const bytes = await _getEntryBytes(viewEntry);
if (mode === 'image') { if (mode === 'image') {
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([ab])); }); const ext = viewEntry.name.split('.').pop()?.toLowerCase();
const mime = ext === 'svg' ? 'image/svg+xml' : undefined;
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([ab], mime ? { type: mime } : undefined)); });
} else if (mode === 'hex') { } else if (mode === 'hex') {
setViewHexData(bytes); setViewHexData(bytes);
} else { } else {
@ -1314,6 +1333,15 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
{!viewLoading && viewMode === 'config' && viewText !== null && ( {!viewLoading && viewMode === 'config' && viewText !== null && (
<ConfigEditor key={viewEntry.path} text={viewText} onSave={s => saveViewFile(s)} /> <ConfigEditor key={viewEntry.path} text={viewText} onSave={s => saveViewFile(s)} />
)} )}
{!viewLoading && viewMode === 'code' && viewText !== null && (
<CodeEditor
key={viewEntry.path}
text={viewText}
mode="code"
syntaxHighlightLang={EXT_TO_LANG[viewEntry.name.split('.').pop()?.toLowerCase() ?? ''] ?? 'text'}
onSave={s => saveViewFile(s)}
/>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -1,202 +0,0 @@
interface OtherPageProps {
config: any;
setConfig: (config: any) => void;
}
export default function OtherPage({ config, setConfig }: OtherPageProps) {
const updateSetting = (path: string[], value: any) => {
const newConfig = JSON.parse(JSON.stringify(config));
let current = newConfig;
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i]];
}
current[path[path.length - 1]] = value;
setConfig(newConfig);
};
const hardware = config.hardware || {};
const modem = config.modem || {};
const cassette = config.cassette || {};
const boip = config.boip || {};
return (
<div className="p-4 space-y-4">
<h2 className="text-sm text-neutral-500">Hardware</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">PS/2</label>
<button
onClick={() => updateSetting(['hardware', 'ps2'], hardware.ps2 ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
hardware.ps2 ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
hardware.ps2 ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">User Port Enabled</label>
<button
onClick={() => updateSetting(['hardware', 'userport', 'enabled'], hardware.userport?.enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
hardware.userport?.enabled ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
hardware.userport?.enabled ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">User Port Mode</label>
<select
value={hardware.userport?.mode?.split('|')[0] || 'serial'}
onChange={(e) => updateSetting(['hardware', 'userport', 'mode'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="serial">Serial</option>
<option value="parallel">Parallel</option>
<option value="IEEE-488">IEEE-488</option>
</select>
</div>
</div>
<h2 className="text-sm text-neutral-500 pt-4">Modem</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Modem Enabled</label>
<button
onClick={() => updateSetting(['modem', 'modem_enabled'], modem.modem_enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
modem.modem_enabled ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
modem.modem_enabled ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Sniffer Enabled</label>
<button
onClick={() => updateSetting(['modem', 'sniffer_enabled'], modem.sniffer_enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
modem.sniffer_enabled ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
modem.sniffer_enabled ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
<h2 className="text-sm text-neutral-500 pt-4">Cassette</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Enabled</label>
<button
onClick={() => updateSetting(['cassette', 'enabled'], cassette.enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
cassette.enabled ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
cassette.enabled ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Play/Record</label>
<input
type="text"
value={cassette.play_record || ''}
onChange={(e) => updateSetting(['cassette', 'play_record'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
/>
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Pulldown</label>
<input
type="text"
value={cassette.pulldown || ''}
onChange={(e) => updateSetting(['cassette', 'pulldown'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
/>
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">URL</label>
<input
type="text"
value={cassette.url || ''}
onChange={(e) => updateSetting(['cassette', 'url'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
/>
</div>
</div>
<h2 className="text-sm text-neutral-500 pt-4">BOIP</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Enabled</label>
<button
onClick={() => updateSetting(['boip', 'enabled'], boip.enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
boip.enabled ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
boip.enabled ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Host</label>
<input
type="text"
value={boip.host || ''}
onChange={(e) => updateSetting(['boip', 'host'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
/>
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Port</label>
<input
type="text"
value={boip.port || ''}
onChange={(e) => updateSetting(['boip', 'port'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
/>
</div>
</div>
</div>
);
}

View File

@ -1,12 +1,10 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { HardDrive, Activity, Wifi, Radio, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react'; import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2 } from 'lucide-react';
import { useWs } from '../ws'; import { useWs } from '../ws';
import DeviceDetailOverlay from './DeviceDetailOverlay'; import DeviceDetailOverlay from './DeviceDetailOverlay';
import MediaSet from './MediaSet'; import MediaSet from './MediaSet';
import { ImageWithFallback } from './figma/ImageWithFallback'; import { ImageWithFallback } from './figma/ImageWithFallback';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
import DirectoryListing from './DirectoryListing';
import { listDirectory, normalizePath, splitPath, type EntryInfo } from '../webdav';
interface StatusPageProps { interface StatusPageProps {
config: any; config: any;
@ -82,51 +80,6 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
const [showResetModal, setShowResetModal] = useState<null | 'meatloaf' | 'host'>(null); const [showResetModal, setShowResetModal] = useState<null | 'meatloaf' | 'host'>(null);
const [resetStatus, setResetStatus] = useState('idle'); // 'idle' | 'in-progress' | 'done' 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);
// Real directory contents for the active device's mounted file.
// Pulled from the WebDAV server (parent folder of the mounted image).
const [dirEntries, setDirEntries] = useState<EntryInfo[]>([]);
const [dirLoading, setDirLoading] = useState(false);
const [dirError, setDirError] = useState<string | null>(null);
const directoryPath: string | null = (() => {
const url = activeDevice?.url;
if (!url) return null;
return splitPath(normalizePath(url)).parent;
})();
useEffect(() => {
if (!showDirectory) return;
if (!directoryPath) {
setDirEntries([]);
setDirError(null);
return;
}
let cancelled = false;
setDirLoading(true);
setDirError(null);
listDirectory(directoryPath)
.then((items) => {
if (cancelled) return;
setDirEntries(items);
})
.catch((e: any) => {
if (cancelled) return;
setDirError((e && e.message) || 'Failed to load directory');
setDirEntries([]);
})
.finally(() => {
if (cancelled) return;
setDirLoading(false);
});
return () => {
cancelled = true;
};
}, [showDirectory, directoryPath]);
return ( return (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
@ -163,8 +116,6 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
</div> </div>
)} )}
{/* Directory and Disk Map buttons at bottom */}
{/* New device info cards */} {/* New device info cards */}
<div className="mb-4"> <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="bg-neutral-50 rounded-lg p-3 flex flex-col items-start justify-center w-full mb-2">
@ -200,20 +151,6 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
</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-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> </div>
{showDeviceOverlay && ( {showDeviceOverlay && (
@ -236,71 +173,6 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
/> />
)} )}
{/* Directory Overlay */}
{showDirectory && (
<>
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-md" onClick={() => setShowDirectory(false)} />
<div className="fixed inset-0 z-50 bg-white shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<div className="min-w-0">
<h2 className="text-xl font-medium">Directory</h2>
<div className="text-xs text-neutral-500 truncate mt-0.5">
Device #{activeDevice.number} {activeDevice.url ? activeDevice.url.split('/').pop() : 'No file mounted'}
</div>
</div>
<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 min-h-0 flex flex-col">
{!activeDevice?.url && (
<div className="p-8 text-center text-neutral-500 text-sm">
No file mounted on this device.
</div>
)}
{activeDevice?.url && dirLoading && (
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
<Loader2 className="w-6 h-6 animate-spin" />
Loading directory from WebDAV
</div>
)}
{activeDevice?.url && !dirLoading && dirError && (
<div className="p-4 text-sm">
<div className="text-red-600 mb-2">Failed to load directory</div>
<div className="text-neutral-500 text-xs break-all">{dirError}</div>
</div>
)}
{activeDevice?.url && !dirLoading && !dirError && (
<DirectoryListing
entries={dirEntries.map((e) => ({
name: e.name,
type: e.type === 'folder' ? 'DIR' : (e.name.split('.').pop() || 'FILE').toUpperCase(),
blocks: e.type === 'file' ? Math.max(1, Math.ceil(e.size / 254)) : 0,
}))}
footerNote={`${dirEntries.length} ENTRIES · ${directoryPath ?? ''}`}
/>
)}
</div>
</div>
</>
)}
{/* Disk Map Overlay */}
{showDiskMap && (
<>
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-md" onClick={() => setShowDiskMap(false)} />
<div className="fixed inset-0 z-50 bg-white shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
<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 min-h-0 overflow-auto flex items-center justify-center text-neutral-500 p-4">
<span>Disk map visualization goes here.</span>
</div>
</div>
</>
)}
{/* Reset Activity Modal */} {/* Reset Activity Modal */}
<Dialog open={!!showResetModal} onOpenChange={open => !open && setShowResetModal(null)}> <Dialog open={!!showResetModal} onOpenChange={open => !open && setShowResetModal(null)}>
<DialogContent> <DialogContent>

View File

@ -200,6 +200,75 @@ export default function ToolsPage({ config }: ToolsPageProps) {
return ( return (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<div className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-neutral-100 rounded-lg flex items-center justify-center">
<Wrench className="w-5 h-5 text-neutral-600" />
</div>
<div className="flex-1">
<div className="font-medium">System Information</div>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-neutral-500">Firmware Version</span>
<span className="text-neutral-900">v2.5.1</span>
</div>
<div className="flex justify-between">
<span className="text-neutral-500">Hardware Revision</span>
<span className="text-neutral-900">Rev C</span>
</div>
<div className="flex justify-between">
<span className="text-neutral-500">Serial Number</span>
<span className="text-neutral-900">ML-2024-0420</span>
</div>
</div>
</div>
<button
onClick={handleSystemUpdate}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<Download className="w-5 h-5 text-purple-600" />
</div>
<div className="flex-1">
<div className="font-medium">System Update</div>
<div className="text-sm text-neutral-500">Check for firmware updates</div>
</div>
</button>
<button
onClick={() => setShowFirmware(true)}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<Cpu className="w-5 h-5 text-purple-600" />
</div>
<div className="flex-1">
<div className="font-medium">Change Firmware</div>
<div className="text-sm text-neutral-500">Select a firmware version to install</div>
</div>
<ChevronRight className="w-4 h-4 text-neutral-400" />
</button>
<button
onClick={handleExportLogs}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-neutral-100 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-neutral-600" />
</div>
<div className="flex-1">
<div className="font-medium">Export System Logs</div>
<div className="text-sm text-neutral-500">Download diagnostic logs</div>
</div>
</button>
</div>
<h2 className="text-sm text-neutral-500">System Tools</h2> <h2 className="text-sm text-neutral-500">System Tools</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200"> <div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
@ -270,75 +339,6 @@ export default function ToolsPage({ config }: ToolsPageProps) {
</div> </div>
</div> </div>
<h2 className="text-sm text-neutral-500 pt-4">System</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<div className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-neutral-100 rounded-lg flex items-center justify-center">
<Wrench className="w-5 h-5 text-neutral-600" />
</div>
<div className="flex-1">
<div className="font-medium">System Information</div>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-neutral-500">Firmware Version</span>
<span className="text-neutral-900">v2.5.1</span>
</div>
<div className="flex justify-between">
<span className="text-neutral-500">Hardware Revision</span>
<span className="text-neutral-900">Rev C</span>
</div>
<div className="flex justify-between">
<span className="text-neutral-500">Serial Number</span>
<span className="text-neutral-900">ML-2024-0420</span>
</div>
</div>
</div>
<button
onClick={handleSystemUpdate}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<Download className="w-5 h-5 text-purple-600" />
</div>
<div className="flex-1">
<div className="font-medium">System Update</div>
<div className="text-sm text-neutral-500">Check for firmware updates</div>
</div>
</button>
<button
onClick={() => setShowFirmware(true)}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<Cpu className="w-5 h-5 text-purple-600" />
</div>
<div className="flex-1">
<div className="font-medium">Change Firmware</div>
<div className="text-sm text-neutral-500">Select a firmware version to install</div>
</div>
<ChevronRight className="w-4 h-4 text-neutral-400" />
</button>
<button
onClick={handleExportLogs}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-neutral-100 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-neutral-600" />
</div>
<div className="flex-1">
<div className="font-medium">Export System Logs</div>
<div className="text-sm text-neutral-500">Download diagnostic logs</div>
</div>
</button>
</div>
{showFirmware && <FirmwareOverlay onClose={() => setShowFirmware(false)} />} {showFirmware && <FirmwareOverlay onClose={() => setShowFirmware(false)} />}
</div> </div>

View File

@ -18,8 +18,9 @@ import {
putFileContents, putFileContents,
} from './webdav'; } from './webdav';
/** The canonical path on the WebDAV server. */ /** The canonical paths on the WebDAV server. */
export const SETTINGS_PATH = '/.sys/config.json'; export const SETTINGS_PATH = '/.sys/config.json';
export const DEVICES_PATH = '/.sys/devices.json';
const SETTINGS_DIR = '/.sys'; const SETTINGS_DIR = '/.sys';
/** /**
@ -37,15 +38,32 @@ const SAVED_INDICATOR_MS = 1500;
export type SettingsConfig = Record<string, any>; export type SettingsConfig = Record<string, any>;
/** Read the settings file from the WebDAV server. Returns null on failure. */ /** Read both config files from the WebDAV server and merge them. Returns null on failure. */
export async function readSettings(): Promise<SettingsConfig | null> { export async function readSettings(): Promise<SettingsConfig | null> {
try { try {
const blob = await getFileContents(SETTINGS_PATH); const [configText, devicesText] = await Promise.all([
const text = await blob.text(); getFileContents(SETTINGS_PATH).then(b => b.text()),
if (!text) return null; getFileContents(DEVICES_PATH).then(b => b.text()).catch(() => null),
const parsed = JSON.parse(text); ]);
if (parsed && typeof parsed === 'object') return parsed as SettingsConfig; if (!configText) return null;
return null; const config = JSON.parse(configText);
if (!config || typeof config !== 'object') return null;
if (devicesText) {
const devices = JSON.parse(devicesText);
if (devices && typeof devices === 'object') {
// One-level deep merge: devices.json keys are merged into matching
// top-level objects in config (e.g. devices.iec.devices → config.iec.devices).
for (const [k, v] of Object.entries(devices)) {
if (config[k] && typeof config[k] === 'object' && !Array.isArray(config[k]) &&
v && typeof v === 'object' && !Array.isArray(v)) {
config[k] = { ...config[k], ...(v as object) };
} else {
config[k] = v;
}
}
}
}
return config as SettingsConfig;
} catch { } catch {
return null; return null;
} }
@ -62,8 +80,12 @@ export async function writeSettings(config: SettingsConfig): Promise<void> {
} catch { } catch {
/* directory may already exist; ignore */ /* directory may already exist; ignore */
} }
const json = JSON.stringify(config, null, 2) + '\n'; const { iec, ...mainConfig } = config;
await putFileContents(SETTINGS_PATH, json); const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any;
await Promise.all([
putFileContents(SETTINGS_PATH, JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n'),
putFileContents(DEVICES_PATH, JSON.stringify({ iec: { devices: iecDevices } }, null, 2) + '\n'),
]);
} }
export type SaveStatus = export type SaveStatus =
@ -176,9 +198,17 @@ export function useSettings(): UseSettingsResult {
// Modern browsers: `fetch` with `keepalive: true` continues even // Modern browsers: `fetch` with `keepalive: true` continues even
// after the page is being unloaded. We don't await it. // after the page is being unloaded. We don't await it.
try { try {
void fetch(absoluteSettingsUrl(), { const { iec, ...mainConfig } = configRef.current;
const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any;
void fetch(absoluteUrl(SETTINGS_PATH), {
method: 'PUT', method: 'PUT',
body: JSON.stringify(configRef.current, null, 2) + '\n', body: JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
});
void fetch(absoluteUrl(DEVICES_PATH), {
method: 'PUT',
body: JSON.stringify({ iec: { devices: iecDevices } }, null, 2) + '\n',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
keepalive: true, keepalive: true,
}); });
@ -226,8 +256,8 @@ export function useSettings(): UseSettingsResult {
}; };
} }
function absoluteSettingsUrl(): string { function absoluteUrl(path: string): string {
return `${window.location.protocol}//${window.location.hostname}${ return `${window.location.protocol}//${window.location.hostname}${
window.location.port ? ':' + window.location.port : '' window.location.port ? ':' + window.location.port : ''
}${SETTINGS_PATH}`; }${path}`;
} }