Compare commits
No commits in common. "5ae3a6e58449970af8fa37163cd5fc248a58efa5" and "941bbbc12aaf632fdddfe75cd4f583634f4487b7" have entirely different histories.
5ae3a6e584
...
941bbbc12a
111
AGENTS.md
111
AGENTS.md
|
|
@ -8,8 +8,7 @@
|
||||||
|
|
||||||
- **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, Lucide React icons
|
- **UI components**: Radix UI primitives, MUI icons (`@mui/icons-material`), 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)
|
||||||
|
|
||||||
|
|
@ -18,27 +17,21 @@
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
app/
|
app/
|
||||||
App.tsx # Root component: routing, nav, layout, SaveStatusBadge, WsProvider wrapper
|
App.tsx # Root component: routing, nav, layout, SaveStatusBadge
|
||||||
settings.ts # useSettings() hook — loads config.json + devices.json, saves split
|
settings.ts # useSettings() hook — load/save config.json via WebDAV
|
||||||
webdav.ts # WebDAV abstraction (listDirectory, stat, put/get, fileExists, etc.)
|
webdav.ts # WebDAV abstraction (listDirectory, stat, put/get, etc.)
|
||||||
ws.tsx # WsProvider + useWs() — single shared WebSocket connection context
|
|
||||||
components/
|
components/
|
||||||
StatusPage.tsx # System status, activity log, reset (via WS), WS status indicator
|
StatusPage.tsx # System status, activity log, file info, progress, overlays
|
||||||
DevicesPage.tsx # Device list, Rescan Bus (sends "iec scan" via WS)
|
DevicesPage.tsx # Device list
|
||||||
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, WiFi scan/connect (via WS)
|
NetworkPage.tsx # Network settings
|
||||||
OtherPage.tsx # Misc settings
|
OtherPage.tsx # Misc settings
|
||||||
ToolsPage.tsx # Tools
|
ToolsPage.tsx # Tools
|
||||||
SearchOverlay.tsx
|
SearchOverlay.tsx
|
||||||
WiFiScanOverlay.tsx # WiFi scan results; Connect sends "connect <ssid> [<pass>]" via WS
|
WiFiScanOverlay.tsx
|
||||||
MediaManager.tsx # WebDAV file browser: file icons by type, kebab actions,
|
MediaBrowser.tsx # WebDAV file browser (file click = select, kebab menu)
|
||||||
# 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/
|
||||||
|
|
@ -47,18 +40,14 @@ src/
|
||||||
package.json
|
package.json
|
||||||
imports/
|
imports/
|
||||||
logo.svg
|
logo.svg
|
||||||
config.json # Bundled fallback (full merged config including iec.devices)
|
# config.json removed — config now loaded at runtime from /.sys/config.json
|
||||||
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
|
||||||
files/
|
webdav3.py # Dev Python WebDAV server (port 80, serves 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
|
||||||
```
|
```
|
||||||
|
|
@ -78,17 +67,16 @@ 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` unless implemented:
|
Grid of app cards grouped by category, each navigates to a stub `AppPage`:
|
||||||
|
|
||||||
- **Management**: Media Manager, Print Manager, Serial Console, Short Codes
|
- **Disk**: Directory Editor, Sector Editor, BAM Editor, Disk Visualizer, RAM/ROM Explorer, Dump Disk Image, Write Disk Image
|
||||||
- **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, **Reality Override** (implemented), **Override Admin** (implemented)
|
- **Display**: Idle Animation, Loading Animation
|
||||||
|
|
||||||
|
All individual app pages are currently stubs (`AppPage` component with "coming soon" message).
|
||||||
|
|
||||||
## Work to Date (chronological)
|
## Work to Date (chronological)
|
||||||
|
|
||||||
|
|
@ -105,73 +93,18 @@ Grid of app cards grouped by category, each navigates to a stub `AppPage` unless
|
||||||
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
|
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
|
||||||
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**: Most individual app pages are stubs. Reality Override and Override Admin are implemented; all others show "coming soon".
|
- **App pages**: All individual app pages (Directory Editor, Sector Editor, etc.) are stubs. Actual implementations are pending.
|
||||||
- **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 split across two files on the device:
|
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`).
|
||||||
|
|
||||||
| 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
|
||||||
|
|
||||||
|
|
@ -183,14 +116,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 → `Blob` |
|
| `getFileContents(path)` | GET → `string` |
|
||||||
| `createFolder(path)` | MKCOL |
|
| `createFolder(path)` | MKCOL |
|
||||||
| `deletePath(path)` | DELETE |
|
| `deletePath(path)` | DELETE |
|
||||||
| `movePath(from, to)` | MOVE |
|
| `movePath(from, to)` | MOVE |
|
||||||
| `fileExists(path)` | HEAD → `boolean` |
|
| `fileExists(path)` | HEAD |
|
||||||
| `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.
|
||||||
|
|
|
||||||
45
README.md
45
README.md
|
|
@ -5,38 +5,17 @@ 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; WebSocket connection status; reset device or host (sends command via WebSocket)
|
- **Status** — live system status, activity log, file info, loading progress, directory and disk map overlays
|
||||||
- **Devices** — browse and manage attached IEC devices; Rescan Bus sends `iec scan` via WebSocket
|
- **Devices** — browse and manage attached storage devices with detailed device overlays
|
||||||
- **IEC** — configure the IEC serial bus (fastloaders, chainloaders, directory options)
|
- **IEC** — configure the IEC serial bus
|
||||||
- **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
|
- **Network** — manage network settings and scan for Wi-Fi networks
|
||||||
- **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:
|
||||||
- **Management**: Media Manager, Print Manager, Serial Console, Short Codes
|
- **Disk**: Directory Editor, Sector Editor, BAM Editor, Disk Visualizer, RAM/ROM Explorer, Dump/Write Disk Image
|
||||||
- **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, Reality Override, Override Admin
|
- **Display**: Idle Animation, Loading Animation
|
||||||
- **PWA** — installable on desktop and mobile; runs offline via service worker; fullscreen-capable with safe-area insets for notched phones
|
- **Tools & Settings** — general configuration and utility tools
|
||||||
|
- **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
|
||||||
|
|
||||||
|
|
@ -59,14 +38,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw, Terminal, Link, Printer, Maximize2, Minimize2 } from 'lucide-react';
|
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, 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';
|
||||||
|
|
@ -15,7 +16,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' | 'general' | 'tools' | 'apps' | AppId;
|
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
|
||||||
|
|
||||||
type AppId =
|
type AppId =
|
||||||
| 'file-manager'
|
| 'file-manager'
|
||||||
|
|
@ -72,6 +73,7 @@ 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: (
|
||||||
|
|
@ -251,6 +253,16 @@ 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"
|
||||||
|
|
@ -315,11 +327,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('tools')}
|
onClick={() => setCurrentPage('other')}
|
||||||
className="flex-1 flex flex-col items-center gap-1 py-2"
|
className="flex-1 flex flex-col items-center gap-1 py-2"
|
||||||
>
|
>
|
||||||
<Wrench className="w-5 h-5 text-white" />
|
<MoreHorizontal className="w-5 h-5 text-white" />
|
||||||
<span className="text-xs text-white">Tools</span>
|
<span className="text-xs text-white">More</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,11 @@ 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' | 'code';
|
export type CodeMode = 'text' | 'json' | 'xml';
|
||||||
|
|
||||||
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>;
|
||||||
}
|
}
|
||||||
|
|
@ -29,11 +27,10 @@ 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', code: 'text',
|
text: 'text', json: 'json', xml: 'xml',
|
||||||
};
|
};
|
||||||
|
|
||||||
function prettify(text: string, mode: CodeMode): string {
|
function prettify(text: string, mode: CodeMode): string {
|
||||||
|
|
@ -43,7 +40,7 @@ function prettify(text: string, mode: CodeMode): string {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CodeEditor({ text, mode, syntaxHighlightLang, readOnly = false, onSave }: CodeEditorProps) {
|
export default function CodeEditor({ text, mode, 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);
|
||||||
|
|
@ -74,7 +71,7 @@ export default function CodeEditor({ text, mode, syntaxHighlightLang, readOnly =
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 overflow-auto text-xs">
|
<div className="flex-1 overflow-auto text-xs">
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language={syntaxHighlightLang ?? syntaxLang[mode]}
|
language={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
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,6 @@ 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
|
||||||
|
|
@ -324,112 +319,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
Terminal,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
|
|
@ -77,23 +76,22 @@ 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' | 'code';
|
type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config';
|
||||||
|
|
||||||
// ─── Extension sets ──────────────────────────────────────────────────────────
|
// ─── Extension sets ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']);
|
const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'bas', 'asm', 'seq', 'rel', 'prg', 'log', 'csv', 's', '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', 'webmanifest']);
|
const JSON_EXTS = new Set(['json']);
|
||||||
const XML_EXTS = new Set(['xml', 'html', 'htm', 'rss', 'atom', 'xsl']);
|
const XML_EXTS = new Set(['xml', 'svg', 'html', 'htm', 'rss', 'atom', 'xsl']);
|
||||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']);
|
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']);
|
||||||
const AUDIO_EXTS = new Set(['sid', 'psid', 'rsid', 'mus', 'vgm']);
|
const AUDIO_EXTS = new Set(['sid', 'psid', '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', 'bbt', 'd8b', 'dfi']);
|
const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd']);
|
||||||
const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']);
|
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 CONFIG_EXTS = new Set(['config']);
|
const CONFIG_EXTS = new Set(['config']);
|
||||||
|
|
||||||
function defaultViewMode(entry: EntryInfo): ViewMode {
|
function defaultViewMode(entry: EntryInfo): ViewMode {
|
||||||
|
|
@ -103,7 +101,6 @@ 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';
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +113,6 @@ 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'],
|
||||||
};
|
};
|
||||||
|
|
@ -124,17 +120,7 @@ function availableViewers(entry: EntryInfo): ViewMode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIEWER_LABEL: Record<ViewMode, string> = {
|
const VIEWER_LABEL: Record<ViewMode, string> = {
|
||||||
code: 'Code', text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'HTML/XML', hex: 'Hex', image: 'Image', config: 'Config',
|
text: 'Text', markdown: 'Markdown', json: 'JSON', xml: '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 ───────────────────────────────────────────────────────
|
||||||
|
|
@ -145,7 +131,6 @@ 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} />;
|
||||||
|
|
@ -628,9 +613,7 @@ 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;
|
||||||
const imgExt = entry.name.split('.').pop()?.toLowerCase();
|
setViewImgUrl(URL.createObjectURL(new Blob([ab])));
|
||||||
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 {
|
||||||
|
|
@ -653,9 +636,7 @@ 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;
|
||||||
const ext = viewEntry.name.split('.').pop()?.toLowerCase();
|
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([ab])); });
|
||||||
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 {
|
||||||
|
|
@ -1333,15 +1314,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
202
src/app/components/OtherPage.tsx
Normal file
202
src/app/components/OtherPage.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2 } from 'lucide-react';
|
import { HardDrive, Activity, Wifi, Radio, Signal, Clock, RefreshCw, FolderOpen, Map, 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;
|
||||||
|
|
@ -80,6 +82,51 @@ 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">
|
||||||
|
|
||||||
|
|
@ -116,6 +163,8 @@ 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">
|
||||||
|
|
@ -151,6 +200,20 @@ 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 && (
|
||||||
|
|
@ -173,6 +236,71 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -200,75 +200,6 @@ 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">
|
||||||
|
|
@ -339,6 +270,75 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,8 @@ import {
|
||||||
putFileContents,
|
putFileContents,
|
||||||
} from './webdav';
|
} from './webdav';
|
||||||
|
|
||||||
/** The canonical paths on the WebDAV server. */
|
/** The canonical path 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,32 +37,15 @@ const SAVED_INDICATOR_MS = 1500;
|
||||||
|
|
||||||
export type SettingsConfig = Record<string, any>;
|
export type SettingsConfig = Record<string, any>;
|
||||||
|
|
||||||
/** Read both config files from the WebDAV server and merge them. Returns null on failure. */
|
/** Read the settings file from the WebDAV server. Returns null on failure. */
|
||||||
export async function readSettings(): Promise<SettingsConfig | null> {
|
export async function readSettings(): Promise<SettingsConfig | null> {
|
||||||
try {
|
try {
|
||||||
const [configText, devicesText] = await Promise.all([
|
const blob = await getFileContents(SETTINGS_PATH);
|
||||||
getFileContents(SETTINGS_PATH).then(b => b.text()),
|
const text = await blob.text();
|
||||||
getFileContents(DEVICES_PATH).then(b => b.text()).catch(() => null),
|
if (!text) return null;
|
||||||
]);
|
const parsed = JSON.parse(text);
|
||||||
if (!configText) return null;
|
if (parsed && typeof parsed === 'object') return parsed as SettingsConfig;
|
||||||
const config = JSON.parse(configText);
|
return null;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -80,12 +62,8 @@ export async function writeSettings(config: SettingsConfig): Promise<void> {
|
||||||
} catch {
|
} catch {
|
||||||
/* directory may already exist; ignore */
|
/* directory may already exist; ignore */
|
||||||
}
|
}
|
||||||
const { iec, ...mainConfig } = config;
|
const json = JSON.stringify(config, null, 2) + '\n';
|
||||||
const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any;
|
await putFileContents(SETTINGS_PATH, json);
|
||||||
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 =
|
||||||
|
|
@ -198,17 +176,9 @@ 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 {
|
||||||
const { iec, ...mainConfig } = configRef.current;
|
void fetch(absoluteSettingsUrl(), {
|
||||||
const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any;
|
|
||||||
void fetch(absoluteUrl(SETTINGS_PATH), {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n',
|
body: JSON.stringify(configRef.current, 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,
|
||||||
});
|
});
|
||||||
|
|
@ -256,8 +226,8 @@ export function useSettings(): UseSettingsResult {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function absoluteUrl(path: string): string {
|
function absoluteSettingsUrl(): 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 : ''
|
||||||
}${path}`;
|
}${SETTINGS_PATH}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user