Compare commits

...

6 Commits

Author SHA1 Message Date
faa2e41be4 Refactor config structure: rename 'iec.devices' to 'devices.iec' across the codebase
- Updated references in AGENTS.md, README.md, and various component files (DevicesPage, GeneralPage, IECPage, MediaManager, SearchOverlay, StatusPage) to reflect the new config structure.
- Adjusted settings handling in settings.ts to accommodate the new structure.
- Modified initial config in config.json to match the updated schema.
2026-06-11 17:08:27 -04:00
89d093e159 feat(IECPage): rename boot_disk to autoboot for clarity in configuration 2026-06-11 15:06:52 -04:00
41809f098c feat(DevicesPage): remove unnecessary device click button from device display 2026-06-11 14:47:40 -04:00
d7c167a9bf feat(DevicesPage): improve device status display and enhance UI layout for device information 2026-06-11 14:46:25 -04:00
0192d230c9 feat(DevicesPage): enhance device action handling and improve UI for physical devices 2026-06-11 14:35:16 -04:00
44736750b6 feat(DevicesPage): update device model options for clarity and add new models 2026-06-11 14:15:09 -04:00
10 changed files with 468 additions and 271 deletions

View File

@ -10,6 +10,7 @@
- **Styling**: Tailwind CSS 4 (via `@tailwindcss/vite`)
- **UI components**: Radix UI primitives, Lucide React icons
- **3D / animation**: Three.js r0.160 (`WebGLRenderer`, `EffectComposer`, `UnrealBloomPass`, custom GLSL shaders)
- **Terminal**: xterm.js (`@xterm/xterm`, `@xterm/addon-fit`) — SerialConsolePage only
- **Routing**: React Router 7 (single-page, page state managed in `App.tsx`)
- **Build base path**: `/config/` (overridable via `BASE_PATH` env var)
@ -18,12 +19,16 @@
```
src/
app/
App.tsx # Root component: routing, nav, layout, SaveStatusBadge, WsProvider wrapper
App.tsx # Root component: routing, nav, layout, WsProvider wrapper
# fileManagerInitialPath / fileManagerReturnPage state for
# deep-linking into MediaManager from StatusPage
settings.ts # useSettings() hook — loads config.json + devices.json, saves split
webdav.ts # WebDAV abstraction (listDirectory, stat, put/get, fileExists, etc.)
ws.tsx # WsProvider + useWs() — single shared WebSocket connection context
components/
StatusPage.tsx # System status, activity log, reset (via WS), WS status indicator
# Active Device panel: FolderOpen → MediaManager, DirectorySlideshow
# or per-entry cover image, MediaSet with named entries
DevicesPage.tsx # Device list, Rescan Bus (sends "iec scan" via WS)
DeviceDetailOverlay.tsx
GeneralPage.tsx # General/settings
@ -36,18 +41,38 @@ src/
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
# initialPath takes priority over localStorage when set
MediaBrowser.tsx # Lightweight file picker (used in device mount dialogs)
MediaEntry.tsx # Shared entry row: icon by extension, hover highlight (blue left border),
# leftSlot / nameSlot props; exports EntryIcon + extension sets
MediaViewerEditor.tsx # Viewer/editor shell: tiled icon background (z-index:-1), toolbar
MediaSet.tsx # Disk-swap button row; MediaSetEntry = string | {url, name?};
# exports mediaSetEntryUrl() helper
DirectorySlideshow.tsx # Auto-advancing image slideshow from a WebDAV directory;
# prev/next arrows, dot indicators, centered pause/play button;
# controls appear on hover or tap; paused + idx persisted to localStorage
HexEditor.tsx # Hex viewer: responsive 8/16 col via ResizeObserver; bright address col
ConfigEditor.tsx # YAML-style .config editor
CodeEditor.tsx # CodeMirror code editor (transparent background)
SerialConsolePage.tsx # xterm.js terminal over shared WS; line-buffered input with echo
# suppression; tiled background overlay
ProfilePage.tsx # Full-page profile menu (replaced dropdown); iOS-style grouped list
AboutMeatloafPage.tsx # Project info table, GPL3 license
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
ui/ # shadcn/Radix UI wrappers
ui/
lazy-loader.tsx # Animated progress bar for Suspense fallbacks (staged steps)
confirm-dialog.tsx
# … other shadcn/Radix UI wrappers
vendor/
webdav-component/ # Vendored ESM WebDAV client (no external deps)
esm/index.js
package.json
imports/
logo.svg
config.json # Bundled fallback (full merged config including iec.devices)
config.json # Bundled fallback (full merged config including devices.iec)
main.tsx
styles/
public/
@ -56,7 +81,7 @@ public/
service-worker.js # PWA service worker
files/
.sys/
config.json # Runtime config on device (no iec.devices)
config.json # Runtime config on device (no devices.iec)
devices.json # Runtime device list: { "iec": { "devices": { ... } } }
webdav3.py # Dev Python WebDAV + WebSocket server (port 80, serves files/)
index.html
@ -67,24 +92,25 @@ vite.config.ts
Bottom tab bar + header icons:
| Nav item | Page key | Component |
|-------------|-------------|------------------|
| Status | `status` | StatusPage |
| Devices | `devices` | DevicesPage |
| IEC | `iec` | IECPage |
| Network | `network` | NetworkPage |
| More | `other` | OtherPage |
| (header) | `apps` | Apps grid |
| (profile) | `general` | GeneralPage |
| (profile) | `tools` | ToolsPage |
| Nav item | Page key | Component |
|-------------|-------------------|------------------|
| Status | `status` | StatusPage |
| Devices | `devices` | DevicesPage |
| IEC | `iec` | IECPage |
| Network | `network` | NetworkPage |
| System | `tools` | ToolsPage |
| (header) | `apps` | Apps grid |
| (header) | `profile` | ProfilePage |
| (profile) | `general` | GeneralPage |
| (profile) | `about-meatloaf` | AboutMeatloafPage |
Header also has: fullscreen toggle (Maximize2/Minimize2), search, apps grid, save-status badge, profile menu. Logo click → status page.
Header: fullscreen toggle, search, apps grid, profile button (→ ProfilePage). Logo click → status page. Toast messages appear `bottom-center` just above the navbar (`offset="calc(4rem + env(safe-area-inset-bottom))"`).
## Apps Page
Grid of app cards grouped by category, each navigates to a stub `AppPage` unless implemented:
- **Management**: Media Manager, Print Manager, Serial Console, Short Codes
- **Management**: Media Manager, Print Manager, **Serial Console** (implemented), 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
- **Development**: Basic Editor, Assembler, Sprite Editor, Character Set Editor, Petscii Editor
@ -129,14 +155,28 @@ Grid of app cards grouped by category, each navigates to a stub `AppPage` unless
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
38. **Split config / devices storage**`settings.ts` loads `/.sys/config.json` and `/.sys/devices.json` in parallel; merges with one-level deep merge (so `devices.iec` from devices.json is merged into `iec` from config.json); saves with split: `devices.iec``devices.json`, everything else (including remaining `iec` bus settings) → `config.json`; `beforeunload` flush also split across both files
39. **SerialConsolePage** — xterm.js terminal (`@xterm/xterm` + `@xterm/addon-fit`) over shared `useWs()`; line-buffered input: printable chars echoed locally, `\r` sends buffer, `\x7f` backspaces, `\x03` clears; echo suppression via `echoQueue` ref; tiled icon background; lazy-loaded
40. **LazyLoader component**`ui/lazy-loader.tsx`: animated progress bar with staged percentage steps (30 → 60 → 80 → 92%) for Suspense fallbacks; replaces inline `PageLoader` in `App.tsx`
41. **MediaViewerEditor tiled background** — icon tile overlay (`z-index: -1`) inside a `z-index: 0` stacking context; sub-components (HexEditor, ConfigEditor, CodeEditor) use transparent backgrounds so the tile shows through
42. **HexEditor responsive columns**`ResizeObserver` on scroll container switches between 8 columns (< 600px) and 16 columns (≥ 600px); address column brightened to `text-neutral-400`
43. **ProfilePage**`ProfilePage.tsx` replaces the header dropdown; iOS-style grouped list with Preferences → GeneralPage, Notifications (stub), Documentation (stub), About Meatloaf → AboutMeatloafPage, Log Out; profile button in header navigates to `'profile'` page key
44. **AboutMeatloafPage**`AboutMeatloafPage.tsx`: logo, project info table (Project, Platform, Website, GitHub), GPL3 license note
45. **MediaEntry hover highlight**`border-l-2 border-l-transparent` always reserves space (no layout shift); `hover:bg-blue-50 hover:border-l-blue-400`; selected state mirrors hover; also applied to `..` (navigate-up) rows in MediaBrowser and MediaManager
46. **Toast position**`<Toaster position="bottom-center" offset="calc(4rem + env(safe-area-inset-bottom))">` — toasts appear just above the navbar on all devices
47. **StatusPage: Browse active device**`FolderOpen` button in Active Device panel header opens MediaManager at `base_url` (if set) or parent directory of `url`; uses `fileManagerInitialPath` / `fileManagerReturnPage` state in `App.tsx`; Apps page card clears `fileManagerInitialPath` so normal navigation restores last-visited path from localStorage
48. **MediaManager initialPath priority**`initialPath` prop now takes priority over `localStorage` when explicitly set (uses `??` instead of `||`); default changed from `'/'` to `undefined`
49. **DirectorySlideshow component**`DirectorySlideshow.tsx`: lists a WebDAV directory, filters for image files, auto-advances every 4 s; prev/next arrow buttons + dot indicators + centered pause/play button; all controls fade in on hover or tap (3 s auto-hide on touch); `paused` persisted to `localStorage` key `slideshow.paused`; `idx` persisted to `localStorage` key `slideshow.idx:<path>` (per directory, clamped to image count on restore)
50. **MediaSet named entries**`MediaSetEntry = string | { url: string; name?: string }`; `MediaSet.tsx` exports the type and `mediaSetEntryUrl()` helper; `name` field displayed instead of filename when provided; `DeviceDetailOverlay` and `StatusPage` updated to use `MediaSetEntry[]`
51. **StatusPage: Active Device cover image** — when `activeDevice.url` has a matching image in the same directory, it is shown (fixed `h-48`) instead of the `DirectorySlideshow`; matching is case-insensitive with `_`/`-`/space normalization via a single `listDirectory` call; the media_set entry's `name` field is tried first, URL base name second
## 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.
- **App pages**: Most individual app pages are stubs. Reality Override and Override Admin are implemented; all others show "coming soon".
- **App pages**: Most individual app pages are stubs. Reality Override, Override Admin, and Serial Console 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.
- **StatusPage mock data**: Last File Access, Size, Transfer Speed, Uptime, IP/MAC, WiFi status, and memory stats are all hardcoded mock values.
## Configuration Shape
@ -144,12 +184,26 @@ 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/config.json` | `general`, `host`, `hardware`, `wifi`, `network`, `bluetooth`, `modem`, `cassette`, `boip`, `iec` (bus settings only — no `devices.iec`) |
| `/.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.
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 `devices.iec`) so the UI renders immediately without waiting.
### media_set format
`media_set` entries can be plain strings (URL) or named objects:
```json
"media_set": [
{ "url": "/sd/game/1.d64", "name": "Boot Disk" },
{ "url": "/sd/game/2.d64", "name": "Disk 1 - Side A" },
"/sd/game/3.d64"
]
```
When a `name` is present, it is shown in the MediaSet UI instead of the filename, and is used as the primary candidate when looking for a matching cover image.
## WebSocket Layer

View File

@ -31,7 +31,7 @@ Device settings are stored in two separate files on the device (LittleFS erase-b
| 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`) |
| `/.sys/devices.json` | IEC device list (`devices.iec`) |
`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.

View File

@ -1,5 +1,12 @@
import { useEffect, useState } from 'react';
import { Printer, HardDrive, Network, Box, ChevronRight, RefreshCw, FolderOpen, Computer, Cable, CassetteTape, Plug } from 'lucide-react';
import {
Printer, HardDrive, Network, Box, ChevronRight, RefreshCw, FolderOpen,
Computer, Cable, CassetteTape, Plug, Zap, MoreVertical,
Info, Tag, Database, CheckCircle, Activity,
} from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from './ui/dialog';
import DeviceDetailOverlay from './DeviceDetailOverlay';
import MediaBrowser from './MediaBrowser';
import { toast } from 'sonner';
@ -16,6 +23,65 @@ interface Device {
mode?: number;
}
interface PhysicalDevice {
number: string;
model: string;
status: 'Ready' | 'Busy' | 'Not Responding';
}
type DeviceStatus = 'Virtual' | 'Disabled' | 'Ready' | 'Busy' | 'Not Responding';
interface DisplayDevice extends Device {
physical: boolean;
physicalModel?: string;
deviceStatus: DeviceStatus;
}
interface DeviceAction {
label: string;
Icon: React.ComponentType<{ className?: string }>;
cmd: string;
variant?: 'primary' | 'danger';
}
function getDeviceActions(device: DisplayDevice): DeviceAction[] {
const actions: DeviceAction[] = [
{ label: 'Initialize', Icon: Zap, cmd: `iec init ${device.number}`, variant: 'primary' },
];
if (device.type === 'drive') {
actions.push(
{ label: 'Read Directory', Icon: FolderOpen, cmd: `iec dir ${device.number}`, variant: 'primary' },
{ label: 'Test Drive', Icon: CheckCircle, cmd: `iec test ${device.number}` },
);
if (device.physical) {
actions.push({ label: 'Identify', Icon: Tag, cmd: `iec id ${device.number}` });
}
actions.push({ label: 'Format Disk', Icon: Database, cmd: `iec format ${device.number}`, variant: 'danger' });
}
if (device.type === 'printer') {
actions.push(
{ label: 'Test Print', Icon: Printer, cmd: `iec print ${device.number}` },
{ label: 'Reset Device', Icon: RefreshCw, cmd: `iec reset ${device.number}`, variant: 'danger' },
);
}
if (device.type === 'network') {
actions.push(
{ label: 'Ping', Icon: Activity, cmd: `iec ping ${device.number}` },
{ label: 'Reset Device', Icon: RefreshCw, cmd: `iec reset ${device.number}`, variant: 'danger' },
);
}
if (device.type === 'meatloaf') {
actions.push(
{ label: 'System Info', Icon: Info, cmd: `iec info ${device.number}` },
{ label: 'Reset Device', Icon: RefreshCw, cmd: `iec reset ${device.number}`, variant: 'danger' },
);
}
return actions;
}
const PHYSICAL_MODELS = ['SD2IEC', '1541', '1541-II', '1571', '1581', 'RAMLINK', 'CMD HD40', 'PI1541'];
const PHYSICAL_STATUSES: PhysicalDevice['status'][] = ['Ready', 'Ready', 'Ready', 'Busy', 'Not Responding'];
interface DevicesPageProps {
config: any;
setConfig: (config: any) => void;
@ -24,7 +90,6 @@ interface DevicesPageProps {
}
export default function DevicesPage({ config, setConfig, openDeviceId, onClearOpenDevice }: DevicesPageProps) {
// Host Settings update function
const updateSetting = (path: string[], value: any) => {
const newConfig = JSON.parse(JSON.stringify(config));
let current = newConfig;
@ -34,19 +99,20 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
current[path[path.length - 1]] = value;
setConfig(newConfig);
};
const [selectedDeviceIndex, setSelectedDeviceIndex] = useState<number | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [showCassetteUrlBrowser, setShowCassetteUrlBrowser] = useState(false);
const [physicalDevices, setPhysicalDevices] = useState<PhysicalDevice[]>([]);
const [actionDevice, setActionDevice] = useState<DisplayDevice | null>(null);
const hardware = config.hardware || {};
const modem = config.modem || {};
const cassette = config.cassette || {};
const boip = config.boip || {};
const cassette = config.devices?.cassette || {};
const userport = config.devices?.userport || {};
// Virtual devices from config (used for detail overlay navigation)
const devices: Device[] = [];
if (config.iec?.devices) {
Object.entries(config.iec.devices).forEach(([num, device]: [string, any]) => {
if (config.devices?.iec) {
Object.entries(config.devices.iec).forEach(([num, device]: [string, any]) => {
const type = device.type as Device['type'];
const name = device.name || (
type === 'drive' ? `Drive ${num}` :
@ -67,7 +133,29 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
});
}
// Auto-open the overlay when the parent passes a device ID (e.g. from a toast action)
// Unified display list: physical devices shadow virtual ones at the same bus address
const physicalNums = new Set(physicalDevices.map(p => p.number));
const displayDevices: DisplayDevice[] = [];
devices.forEach(d => {
if (physicalNums.has(d.number)) return;
displayDevices.push({ ...d, physical: false, deviceStatus: d.enabled ? 'Virtual' : 'Disabled' });
});
physicalDevices.forEach(p => {
displayDevices.push({
id: `physical-${p.number}`,
number: p.number,
type: 'drive',
name: p.model,
enabled: 1,
physical: true,
physicalModel: p.model,
deviceStatus: p.status,
});
});
displayDevices.sort((a, b) => parseInt(a.number) - parseInt(b.number));
// Auto-open the overlay when the parent passes a device ID
useEffect(() => {
if (!openDeviceId) return;
const idx = devices.findIndex(d => d.id === openDeviceId);
@ -78,35 +166,48 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
const getDeviceIcon = (type: Device['type']) => {
switch (type) {
case 'printer':
return <Printer className="w-5 h-5" />;
case 'drive':
return <HardDrive className="w-5 h-5" />;
case 'network':
return <Network className="w-5 h-5" />;
case 'meatloaf':
return <Computer className="w-5 h-5" />;
default:
return <Box className="w-5 h-5" />;
case 'printer': return <Printer className="w-5 h-5" />;
case 'drive': return <HardDrive className="w-5 h-5" />;
case 'network': return <Network className="w-5 h-5" />;
case 'meatloaf': return <Computer className="w-5 h-5" />;
default: return <Box className="w-5 h-5" />;
}
};
const handleDeviceClick = (index: number) => {
setSelectedDeviceIndex(index);
const iconColor = (device: DisplayDevice) => {
if (device.physical) {
if (device.deviceStatus === 'Not Responding') return 'text-red-500';
if (device.deviceStatus === 'Busy') return 'text-amber-500';
return 'text-green-600';
}
return device.enabled ? 'text-blue-600' : 'text-neutral-400';
};
const handleCloseOverlay = () => {
setSelectedDeviceIndex(null);
const statusEl = (status: DeviceStatus) => {
const dot = (cls: string) => <span className={`inline-block w-1.5 h-1.5 rounded-full mr-1.5 ${cls}`} />;
switch (status) {
case 'Ready': return <><span className="text-green-600">{dot('bg-green-500')}Ready</span></>;
case 'Busy': return <><span className="text-amber-600">{dot('bg-amber-400')}Busy</span></>;
case 'Not Responding': return <><span className="text-red-600">{dot('bg-red-500')}Not Responding</span></>;
case 'Disabled': return <span className="text-neutral-400">Disabled</span>;
default: return null;
}
};
const handleDeviceClick = (dd: DisplayDevice) => {
if (dd.physical) return;
const idx = devices.findIndex(d => d.number === dd.number);
if (idx >= 0) setSelectedDeviceIndex(idx);
};
const handleCloseOverlay = () => setSelectedDeviceIndex(null);
const handleNavigate = (direction: 'prev' | 'next') => {
if (selectedDeviceIndex === null) return;
if (direction === 'prev' && selectedDeviceIndex > 0) {
if (direction === 'prev' && selectedDeviceIndex > 0)
setSelectedDeviceIndex(selectedDeviceIndex - 1);
} else if (direction === 'next' && selectedDeviceIndex < devices.length - 1) {
else if (direction === 'next' && selectedDeviceIndex < devices.length - 1)
setSelectedDeviceIndex(selectedDeviceIndex + 1);
}
};
const { send: wsSend } = useWs();
@ -114,27 +215,48 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
const rescanBus = async () => {
setIsScanning(true);
wsSend('iec scan');
toast.loading('Scanning IEC bus...');
const toastId = toast.loading('Scanning IEC bus...');
// Simulate bus scan
await new Promise(resolve => setTimeout(resolve, 2000));
const count = 2 + Math.floor(Math.random() * 3);
const shuffled = [...PHYSICAL_MODELS].sort(() => Math.random() - 0.5).slice(0, count);
const usedIds = new Set<string>();
const found: PhysicalDevice[] = shuffled.map(model => {
let id: number;
do { id = 8 + Math.floor(Math.random() * 22); } while (usedIds.has(String(id)));
usedIds.add(String(id));
return {
number: String(id),
model,
status: PHYSICAL_STATUSES[Math.floor(Math.random() * PHYSICAL_STATUSES.length)],
};
});
found.sort((a, b) => parseInt(a.number) - parseInt(b.number));
setPhysicalDevices(found);
setIsScanning(false);
toast.dismiss();
toast.success(`Found ${devices.length} devices on the bus`);
toast.dismiss(toastId);
toast.success(`Found ${found.length} physical device${found.length !== 1 ? 's' : ''} on the bus`);
};
const toggleDeviceEnabled = (device: Device, e: React.MouseEvent) => {
const handleAction = (action: DeviceAction) => {
wsSend(action.cmd);
toast.success(`${action.label} sent to device #${actionDevice?.number}`);
setActionDevice(null);
};
const toggleDeviceEnabled = (device: DisplayDevice, e: React.MouseEvent) => {
e.stopPropagation();
const newConfig = JSON.parse(JSON.stringify(config));
newConfig.iec.devices[device.number].enabled = device.enabled ? 0 : 1;
newConfig.devices.iec[device.number].enabled = device.enabled ? 0 : 1;
setConfig(newConfig);
toast.success(`Device #${device.number} ${device.enabled ? 'disabled' : 'enabled'}`);
};
return (
<div className="p-4">
{/* Host Settings section moved from GeneralPage */}
{/* ── Host Settings ── */}
<h2 className="text-sm text-neutral-500 mb-2 flex items-center gap-2"><Computer className="w-4 h-4" /> Host Settings</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200 mb-6">
<div className="p-4">
@ -144,16 +266,17 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
onChange={(e) => updateSetting(['host', 'model'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="c64">C64</option>
<option value="c64c">C64C</option>
<option value="c128">C128</option>
<option value="sx64">SX64</option>
<option value="plus4">Plus/4</option>
<option value="c16">C16</option>
<option value="cx16">CX16</option>
<option value="foenix">Foenix</option>
<option value="c64">C64/C64c/SX64</option>
<option value="c64u">C64/C64c Ultimate / Ultimate 64</option>
<option value="dtv">DTV</option>
<option value="thec64">TheC64 Mini/Maxi</option>
<option value="c128">C128</option>
<option value="c16">C16</option>
<option value="plus4">C116/Plus/4</option>
<option value="pet">PET</option>
<option value="cbm2">CBM2</option>
<option value="cx16">CX16 / OtterX</option>
<option value="foenix">Foenix</option>
</select>
</div>
<div className="p-4">
@ -194,8 +317,10 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
</select>
</div>
</div>
{/* ── IEC Serial Devices ── */}
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm text-neutral-500 flex items-center gap-2"><Cable className="w-4 h-4" /> IEC Devices</h2>
<h2 className="text-sm text-neutral-500 flex items-center gap-2"><Cable className="w-4 h-4" /> IEC Serial Devices</h2>
<button
onClick={rescanBus}
disabled={isScanning}
@ -205,54 +330,78 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
Rescan Bus
</button>
</div>
<div className="space-y-2">
{devices.map((device, index) => (
{displayDevices.map((device) => (
<div
key={device.id}
className="w-full bg-white border border-neutral-200 rounded-lg p-4 flex items-center gap-3"
>
<div className={`${device.enabled ? 'text-blue-600' : 'text-neutral-400'}`}>
{/* Icon + number stacked */}
<div className={`flex-shrink-0 flex flex-col items-center gap-0.5 ${iconColor(device)}`}>
<span className="text-sm font-semibold leading-none">
{device.number}
</span>
{getDeviceIcon(device.type)}
</div>
{/* Info — clickable for virtual devices */}
<button
onClick={() => handleDeviceClick(index)}
className="flex-1 min-w-0 text-left"
onClick={() => handleDeviceClick(device)}
disabled={device.physical}
className="flex-1 min-w-0 text-left disabled:cursor-default"
>
<div className="flex items-center gap-2">
<span className={device.enabled ? 'text-neutral-900' : 'text-neutral-400'}>
<div className="flex items-center gap-2 flex-wrap">
<span className={!device.physical && !device.enabled ? 'text-neutral-400' : 'text-neutral-900'}>
{device.name || `Device ${device.number}`}
</span>
<span className="text-xs text-neutral-500 px-2 py-0.5 bg-neutral-100 rounded">
#{device.number}
</span>
{device.physical && (
<span className="text-xs text-green-700 px-1.5 py-0.5 bg-green-50 border border-green-200 rounded">
Physical
</span>
)}
</div>
{(device.base_url || device.url) && (
<div className="text-sm text-neutral-500 truncate mt-0.5">
{[device.base_url, device.url].filter(Boolean).join('')}
</div>
)}
{device.deviceStatus !== 'Virtual' && (
<div className="text-xs mt-1 flex items-center">
{statusEl(device.deviceStatus)}
</div>
)}
</button>
<div className="flex items-center gap-3">
{/* Controls */}
<div className="flex items-center gap-2 flex-shrink-0">
{!device.physical && (
<>
<button
onClick={(e) => toggleDeviceEnabled(device, e)}
className={`relative w-11 h-6 rounded-full transition-colors ${
device.enabled ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
device.enabled ? 'translate-x-5' : 'translate-x-0.5'
}`} />
</button>
</>
)}
<button
onClick={(e) => toggleDeviceEnabled(device, e)}
className={`relative w-11 h-6 rounded-full transition-colors ${
device.enabled ? 'bg-blue-600' : 'bg-neutral-300'
}`}
onClick={(e) => { e.stopPropagation(); setActionDevice(device); }}
className="p-2 rounded hover:bg-neutral-200 flex-shrink-0"
title="Actions"
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
device.enabled ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
<button onClick={() => handleDeviceClick(index)}>
<ChevronRight className="w-4 h-4 text-neutral-400" />
<MoreVertical className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
{/* DeviceDetailOverlay — virtual devices only */}
{selectedDeviceIndex !== null && (
<DeviceDetailOverlay
device={devices[selectedDeviceIndex]}
@ -265,6 +414,42 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
/>
)}
{/* Action Dialog */}
<Dialog open={actionDevice !== null} onOpenChange={open => !open && setActionDevice(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{actionDevice?.name || `Device ${actionDevice?.number}`}</DialogTitle>
<DialogDescription>
#{actionDevice?.number}
{actionDevice?.physical
? ` · ${actionDevice.physicalModel} · Physical`
: ' · Virtual'}
</DialogDescription>
</DialogHeader>
{actionDevice && (
<div className="flex flex-col gap-2">
{getDeviceActions(actionDevice).map(action => (
<button
key={action.label}
onClick={() => handleAction(action)}
className={`w-full text-left px-4 py-3 rounded border inline-flex items-center gap-3 ${
action.variant === 'danger'
? 'border-red-200 hover:bg-red-50 text-red-700'
: action.variant === 'primary'
? 'border-neutral-200 hover:bg-blue-50 hover:border-blue-300'
: 'border-neutral-200 hover:bg-neutral-50'
}`}
>
<action.Icon className={`w-4 h-4 flex-shrink-0 ${
action.variant === 'danger' ? 'text-red-500' : 'text-neutral-500'
}`} />
<span>{action.label}</span>
</button>
))}
</div>
)}
</DialogContent>
</Dialog>
{/* ── Cassette ── */}
<h2 className="text-sm text-neutral-500 pt-4 flex items-center gap-2"><CassetteTape className="w-4 h-4" /> Cassette</h2>
@ -272,27 +457,19 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
<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)}
onClick={() => updateSetting(['devices', '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>
<div className="flex gap-2">
<input
type="text"
value={cassette.url || ''}
onChange={(e) => updateSetting(['cassette', 'url'], e.target.value)}
onChange={(e) => updateSetting(['devices', 'cassette', 'url'], e.target.value)}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/>
<button
@ -308,28 +485,28 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
{showCassetteUrlBrowser && (
<MediaBrowser
currentPath={cassette.url || '/'}
onSelect={(p) => updateSetting(['cassette', 'url'], p)}
onSelect={(p) => updateSetting(['devices', 'cassette', 'url'], p)}
onClose={() => setShowCassetteUrlBrowser(false)}
/>
)}
{/* ── Hardware ── */}
{/* ── User Port ── */}
<h2 className="text-sm text-neutral-500 pt-4 flex items-center gap-2"><Plug className="w-4 h-4" /> User Port</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">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'}`}
onClick={() => updateSetting(['devices', 'userport', 'enabled'], userport.enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${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'}`} />
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${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)}
value={userport.mode?.split('|')[0] || 'serial'}
onChange={(e) => updateSetting(['devices', 'userport', 'mode'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="serial">Serial</option>
@ -339,20 +516,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
</select>
</div>
</div>
{/* ── Hardware ── */}
{/* <h2 className="text-sm text-neutral-500 pt-4">Other 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> */}
</div>
);
}

View File

@ -38,7 +38,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
setConfig(newConfig);
};
const general = config.general || {};
const general = config.preferences // alias kept so JSX references below don't need renaming || {};
return (
<div className="p-4 space-y-4">
@ -51,7 +51,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<input
type="text"
value={general.devicename || ''}
onChange={(e) => updateSetting(['general', 'devicename'], e.target.value)}
onChange={(e) => updateSetting(['preferences', 'devicename'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
/>
</div>
@ -60,7 +60,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<label className="text-sm text-neutral-500 block mb-2">Appearance</label>
<select
value={general.appearance?.split('|')[0] || 'auto'}
onChange={(e) => updateSetting(['general', 'appearance'], e.target.value)}
onChange={(e) => updateSetting(['preferences', 'appearance'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="light">Light</option>
@ -73,7 +73,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<label className="text-sm text-neutral-500 block mb-2">Language</label>
<select
value={general.language?.split('|')[0] || 'en'}
onChange={(e) => updateSetting(['general', 'language'], e.target.value)}
onChange={(e) => updateSetting(['preferences', 'language'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="en">English</option>
@ -87,7 +87,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
{!tzError && timezones ? (
<select
value={general.timezone || ''}
onChange={(e) => updateSetting(['general', 'timezone'], e.target.value)}
onChange={(e) => updateSetting(['preferences', 'timezone'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
{general.timezone && !timezones.includes(general.timezone) && (
@ -101,7 +101,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<input
type="text"
value={general.timezone || ''}
onChange={(e) => updateSetting(['general', 'timezone'], e.target.value)}
onChange={(e) => updateSetting(['preferences', 'timezone'], e.target.value)}
placeholder={timezones === null && !tzError ? 'Loading…' : 'America/Los_Angeles'}
disabled={timezones === null && !tzError}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg disabled:opacity-50"
@ -114,7 +114,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<input
type="text"
value={general.country || ''}
onChange={(e) => updateSetting(['general', 'country'], e.target.value)}
onChange={(e) => updateSetting(['preferences', 'country'], e.target.value)}
placeholder="US"
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
/>
@ -123,7 +123,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Rotation Sounds</label>
<button
onClick={() => updateSetting(['general', 'rotationsounds'], general.rotationsounds ? 0 : 1)}
onClick={() => updateSetting(['preferences', 'rotationsounds'], general.rotationsounds ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
general.rotationsounds ? 'bg-blue-600' : 'bg-neutral-300'
}`}
@ -139,7 +139,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Config Enabled</label>
<button
onClick={() => updateSetting(['general', 'configenabled'], general.configenabled ? 0 : 1)}
onClick={() => updateSetting(['preferences', 'configenabled'], general.configenabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
general.configenabled ? 'bg-blue-600' : 'bg-neutral-300'
}`}
@ -155,7 +155,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Status Wait Enabled</label>
<button
onClick={() => updateSetting(['general', 'status_wait_enabled'], general.status_wait_enabled ? 0 : 1)}
onClick={() => updateSetting(['preferences', 'status_wait_enabled'], general.status_wait_enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
general.status_wait_enabled ? 'bg-blue-600' : 'bg-neutral-300'
}`}
@ -171,7 +171,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Encrypt Passphrase</label>
<button
onClick={() => updateSetting(['general', 'encrypt_passphrase'], general.encrypt_passphrase ? 0 : 1)}
onClick={() => updateSetting(['preferences', 'encrypt_passphrase'], general.encrypt_passphrase ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
general.encrypt_passphrase ? 'bg-blue-600' : 'bg-neutral-300'
}`}
@ -187,7 +187,7 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">Reset with Host</label>
<button
onClick={() => updateSetting(['general', 'reset_with_host'], general.reset_with_host ? 0 : 1)}
onClick={() => updateSetting(['preferences', 'reset_with_host'], general.reset_with_host ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
general.reset_with_host ? 'bg-blue-600' : 'bg-neutral-300'
}`}

View File

@ -22,14 +22,14 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
setConfig(newConfig);
};
const iec = config.iec || {};
const settings = config.settings || {};
const boolDirEntries = Object.entries(iec.directory || {}).filter(([, v]) => v === 0 || v === 1);
const boolDirEntries = Object.entries(settings.directory || {}).filter(([, v]) => v === 0 || v === 1);
const isCompatMode = boolDirEntries.length > 0 && boolDirEntries.every(([, v]) => v === 0);
const setAllDirBools = (val: 0 | 1) => {
const newConfig = JSON.parse(JSON.stringify(config));
const dir = newConfig.iec?.directory ?? {};
const dir = newConfig.settings?.directory ?? {};
for (const k of Object.keys(dir)) {
if (dir[k] === 0 || dir[k] === 1) dir[k] = val;
}
@ -44,14 +44,14 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">IEC Enabled</label>
<button
onClick={() => updateSetting(['iec', 'enabled'], iec.enabled ? 0 : 1)}
onClick={() => updateSetting(['settings', 'enabled'], settings.enabled ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
iec.enabled ? 'bg-blue-600' : 'bg-neutral-300'
settings.enabled ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
iec.enabled ? 'translate-x-6' : 'translate-x-0.5'
settings.enabled ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
@ -60,14 +60,14 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">VIC-20 Mode</label>
<button
onClick={() => updateSetting(['iec', 'vic20_mode'], iec.vic20_mode ? 0 : 1)}
onClick={() => updateSetting(['settings', 'vic20_mode'], settings.vic20_mode ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
iec.vic20_mode ? 'bg-blue-600' : 'bg-neutral-300'
settings.vic20_mode ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
iec.vic20_mode ? 'translate-x-6' : 'translate-x-0.5'
settings.vic20_mode ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
@ -76,26 +76,26 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
<div className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">VDrive Mode</label>
<button
onClick={() => updateSetting(['iec', 'vdrive_mode'], iec.vdrive_mode ? 0 : 1)}
onClick={() => updateSetting(['settings', 'vdrive_mode'], settings.vdrive_mode ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
iec.vdrive_mode ? 'bg-blue-600' : 'bg-neutral-300'
settings.vdrive_mode ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
iec.vdrive_mode ? 'translate-x-6' : 'translate-x-0.5'
settings.vdrive_mode ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Boot Disk</label>
<label className="text-sm text-neutral-500 block mb-2">Autoboot</label>
<div className="flex gap-2">
<input
type="text"
value={iec.boot_disk || ''}
onChange={(e) => updateSetting(['iec', 'boot_disk'], e.target.value)}
value={settings.autoboot || ''}
onChange={(e) => updateSetting(['settings', 'autoboot'], e.target.value)}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/>
<button
@ -110,9 +110,9 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
{showMediaBrowser && (
<MediaBrowser
currentPath={iec.boot_disk || '/'}
currentPath={settings.autoboot || '/'}
onSelect={(path) => {
updateSetting(['iec', 'boot_disk'], path);
updateSetting(['settings', 'autoboot'], path);
setShowMediaBrowser(false);
}}
onClose={() => setShowMediaBrowser(false)}
@ -128,20 +128,20 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
<button
onClick={() => {
const newConfig = JSON.parse(JSON.stringify(config));
const dr = newConfig.iec.drive_rom ?? {};
const dr = newConfig.settings.drive_roms ?? {};
const current = dr.enabled ?? dr.auto ?? 0;
dr.enabled = current ? 0 : 1;
delete dr.auto;
newConfig.iec.drive_rom = dr;
newConfig.settings.drive_roms = dr;
setConfig(newConfig);
}}
className={`relative w-12 h-6 rounded-full transition-colors ${
(iec.drive_rom?.enabled ?? iec.drive_rom?.auto) ? 'bg-blue-600' : 'bg-neutral-300'
(settings.drive_roms?.enabled ?? settings.drive_roms?.auto) ? 'bg-blue-600' : 'bg-neutral-300'
}`}
>
<div
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
(iec.drive_rom?.enabled ?? iec.drive_rom?.auto) ? 'translate-x-6' : 'translate-x-0.5'
(settings.drive_roms?.enabled ?? settings.drive_roms?.auto) ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
@ -154,8 +154,8 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
<div className="flex gap-2">
<input
type="text"
value={iec.drive_rom?.[key] || ''}
onChange={(e) => updateSetting(['iec', 'drive_rom', key], e.target.value)}
value={settings.drive_roms?.[key] || ''}
onChange={(e) => updateSetting(['settings', 'drive_roms', key], e.target.value)}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/>
<button
@ -172,9 +172,9 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
{driveRomBrowsingKey && (
<MediaBrowser
currentPath={iec.drive_rom?.[driveRomBrowsingKey] || '/'}
currentPath={settings.drive_roms?.[driveRomBrowsingKey] || '/'}
onSelect={(path) => {
updateSetting(['iec', 'drive_rom', driveRomBrowsingKey], path);
updateSetting(['settings', 'drive_roms', driveRomBrowsingKey], path);
setDriveRomBrowsingKey(null);
}}
onClose={() => setDriveRomBrowsingKey(null)}
@ -198,14 +198,14 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
</button>
</div>
{Object.entries(iec.directory || {}).map(([key, value]) => (
{Object.entries(settings.directory || {}).map(([key, value]) => (
<div key={key} className={`p-4 flex items-center justify-between transition-opacity ${isCompatMode ? 'opacity-40' : ''}`}>
<label className="text-sm text-neutral-500">
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</label>
{value === 0 || value === 1 ? (
<button
onClick={() => { if (!isCompatMode) updateSetting(['iec', 'directory', key], value ? 0 : 1); }}
onClick={() => { if (!isCompatMode) updateSetting(['settings', 'directory', key], value ? 0 : 1); }}
className={`relative w-12 h-6 rounded-full transition-colors ${value ? 'bg-blue-600' : 'bg-neutral-300'} ${isCompatMode ? 'cursor-not-allowed' : ''}`}
>
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${value ? 'translate-x-6' : 'translate-x-0.5'}`} />
@ -215,7 +215,7 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
type="number"
value={value}
disabled={isCompatMode}
onChange={(e) => updateSetting(['iec', 'directory', key], parseInt(e.target.value))}
onChange={(e) => updateSetting(['settings', 'directory', key], parseInt(e.target.value))}
className="w-24 px-3 py-1 border border-neutral-300 rounded-lg text-right disabled:cursor-not-allowed"
/>
)}
@ -229,13 +229,13 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
<div className="p-3 bg-neutral-50">
<h3 className="text-xs text-neutral-600">Serial</h3>
</div>
{Object.entries(iec.fastloaders?.hardware?.serial || {}).map(([key, value]) => (
{Object.entries(settings.fastloaders?.hardware?.serial || {}).map(([key, value]) => (
<div key={key} className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</label>
<button
onClick={() => updateSetting(['iec', 'fastloaders', 'hardware', 'serial', key], value ? 0 : 1)}
onClick={() => updateSetting(['settings', 'fastloaders', 'hardware', 'serial', key], value ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
value ? 'bg-blue-600' : 'bg-neutral-300'
}`}
@ -252,13 +252,13 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
<div className="p-3 bg-neutral-50">
<h3 className="text-xs text-neutral-600">Parallel</h3>
</div>
{Object.entries(iec.fastloaders?.hardware?.parallel || {}).map(([key, value]) => (
{Object.entries(settings.fastloaders?.hardware?.parallel || {}).map(([key, value]) => (
<div key={key} className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</label>
<button
onClick={() => updateSetting(['iec', 'fastloaders', 'hardware', 'parallel', key], value ? 0 : 1)}
onClick={() => updateSetting(['settings', 'fastloaders', 'hardware', 'parallel', key], value ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
value ? 'bg-blue-600' : 'bg-neutral-300'
}`}
@ -276,13 +276,13 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
<h2 className="text-sm text-neutral-500 pt-4 flex items-center gap-2"><Code2 className="w-4 h-4" /> Software Fastloaders</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
{Object.entries(iec.fastloaders?.software || {}).map(([key, value]) => (
{Object.entries(settings.fastloaders?.software || {}).map(([key, value]) => (
<div key={key} className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</label>
<button
onClick={() => updateSetting(['iec', 'fastloaders', 'software', key], value ? 0 : 1)}
onClick={() => updateSetting(['settings', 'fastloaders', 'software', key], value ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
value ? 'bg-blue-600' : 'bg-neutral-300'
}`}
@ -300,13 +300,13 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
<h2 className="text-sm text-neutral-500 pt-4 flex items-center gap-2"><Link className="w-4 h-4" /> Chainloaders</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
{Object.entries(iec.chainloaders || {}).map(([key, value]) => (
{Object.entries(settings.chainloaders || {}).map(([key, value]) => (
<div key={key} className="p-4 flex items-center justify-between">
<label className="text-sm text-neutral-500">
{key.toUpperCase()}
</label>
<button
onClick={() => updateSetting(['iec', 'chainloaders', key], value ? 0 : 1)}
onClick={() => updateSetting(['settings', 'chainloaders', key], value ? 0 : 1)}
className={`relative w-12 h-6 rounded-full transition-colors ${
value ? 'bg-blue-600' : 'bg-neutral-300'
}`}

View File

@ -625,10 +625,10 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
if (!mountEntry || !setConfig || !config) return;
const newConfig = JSON.parse(JSON.stringify(config));
if (!newConfig.iec) newConfig.iec = {};
if (!newConfig.iec.devices) newConfig.iec.devices = {};
if (!newConfig.iec.devices[key]) newConfig.iec.devices[key] = { type: deviceType };
const dev = newConfig.iec.devices[key];
if (!newConfig.devices) newConfig.devices = {};
if (!newConfig.devices.iec) newConfig.devices.iec = {};
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
const dev = newConfig.devices.iec[key];
if (mountEntry.name.toLowerCase().endsWith('.lst')) {
try {
@ -1165,7 +1165,7 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
<DialogDescription className="truncate">{mountEntry?.name}</DialogDescription>
</DialogHeader>
{(() => {
const allDevices = Object.entries(config?.iec?.devices ?? {});
const allDevices = Object.entries(config?.devices?.iec ?? {});
const drives = allDevices
.filter(([, v]: [string, any]) => (v as any)?.type === 'drive')
.map(([k, v]: [string, any]) => ({ type: 'drive' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled }));

View File

@ -121,8 +121,8 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
const handleMount = (deviceNum: string, result: SearchResult) => {
const newConfig = JSON.parse(JSON.stringify(config));
if (newConfig.iec?.devices?.[deviceNum]) {
newConfig.iec.devices[deviceNum].url = result.path;
if (newConfig.devices?.iec?.[deviceNum]) {
newConfig.devices.iec[deviceNum].url = result.path;
setConfig(newConfig);
toast.success(`Mounted ${result.name} on Device #${deviceNum}`);
setShowDeviceMenu(null);
@ -131,8 +131,8 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
const getAvailableDevices = () => {
const devices: { number: string; name: string; url?: string }[] = [];
if (config.iec?.devices) {
for (const [num, device] of Object.entries(config.iec.devices)) {
if (config.devices?.iec) {
for (const [num, device] of Object.entries(config.devices.iec)) {
const d = device as any;
if (d.type === 'drive' && d.enabled) {
devices.push({ number: num, name: `Drive ${num}`, url: d.url });

View File

@ -29,8 +29,8 @@ export default function StatusPage({ config, setConfig, onOpenFileManager }: Sta
const [showDeviceOverlay, setShowDeviceOverlay] = useState(false);
// Find the first enabled device as the active device
const findActiveDevice = () => {
if (config.iec?.devices) {
for (const [num, device] of Object.entries(config.iec.devices)) {
if (config.devices?.iec) {
for (const [num, device] of Object.entries(config.devices.iec)) {
const d = device as any;
if (d.type === 'drive' && d.enabled) {
return { number: num, ...d };
@ -57,8 +57,8 @@ export default function StatusPage({ config, setConfig, onOpenFileManager }: Sta
const switchActiveMedia = (file: string) => {
const newConfig = JSON.parse(JSON.stringify(config));
if (newConfig.iec?.devices?.[activeDevice!.number]) {
newConfig.iec.devices[activeDevice!.number].url = file;
if (newConfig.devices?.iec?.[activeDevice!.number]) {
newConfig.devices.iec[activeDevice!.number].url = file;
setConfig(newConfig);
}
};

View File

@ -62,7 +62,7 @@ export async function readSettings(): Promise<SettingsConfig | null> {
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).
// top-level objects in config (e.g. devices.devices.iec → config.devices.iec).
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)) {
@ -87,10 +87,9 @@ export async function readSettings(): Promise<SettingsConfig | null> {
export async function writeSettings(config: SettingsConfig): Promise<void> {
try { await createFolder(SETTINGS_DIR, true); } catch { /* exists */ }
const { iec, ...mainConfig } = config;
const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any;
const configJson = JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n';
const devicesJson = JSON.stringify({ iec: { devices: iecDevices } }, null, 2) + '\n';
const { devices, ...mainConfig } = config;
const configJson = JSON.stringify(mainConfig, null, 2) + '\n';
const devicesJson = JSON.stringify({ devices }, null, 2) + '\n';
await Promise.all([
putFileContents(SETTINGS_PATH, configJson),
@ -220,17 +219,16 @@ export function useSettings(): UseSettingsResult {
// Modern browsers: `fetch` with `keepalive: true` continues even
// after the page is being unloaded. We don't await it.
try {
const { iec, ...mainConfig } = configRef.current;
const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any;
const { devices, ...mainConfig } = configRef.current;
void fetch(absoluteUrl(SETTINGS_PATH), {
method: 'PUT',
body: JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n',
body: JSON.stringify(mainConfig, 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',
body: JSON.stringify({ devices }, null, 2) + '\n',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
});

View File

@ -1,49 +1,20 @@
{
"general": {
"devicename": "Meatloaf",
"appearance": "light",
"preferences": {
"appearance": "auto",
"language": "en",
"timezone": "America/New_York",
"timezone": "America/Los_Angeles",
"country": "US",
"hsioindex": -1,
"rotationsounds": 1,
"configenabled": 1,
"altconfigfile": "",
"boot_mode": 0,
"fnconfig_on_spifs": 1,
"status_wait_enabled": 1,
"encrypt_passphrase": 0,
"devicename": "Meatloaf",
"rotationsounds": 0,
"reset_with_host": 0
},
"host": {
"model": "c64",
"video": "ntsc",
"language": "en",
"kernal": "Stock",
"basic": "BASIC 2.0"
"kernal": "stock",
"basic": "2"
},
"hardware": {
"ps2": 1,
"userport": {
"enabled": 0,
"mode": ""
}
},
"wifi": [
{
"enabled": 1,
"ssid": "meatloaf",
"passphrase": "meatloaf"
},
{
"ssid": "CODA-23B0",
"passphrase": "meatloaf"
},
{
"ssid": "EEP_OPP_ORK_AH_AH",
"passphrase": "meatloaf"
}
],
"network": {
"hostname": "meatloaf.local",
"sntpserver": "pool.ntp.org",
@ -62,27 +33,29 @@
"tcp_console": 1
}
},
"bluetooth": {
"enabled": 0,
"devicename": "meatloaf",
"baud": 19200
},
"modem": {
"modem_enabled": 1,
"sniffer_enabled": 0
},
"cassette": {
"enabled": 0,
"play_record": "0 Play",
"pulldown": "1 Pulldown Resistor",
"url": "/"
},
"iec": {
"wifi": [
{
"ssid": "EEP_OPP_ORK_AH_AH",
"passphrase": "meatloaf",
"enabled": 1
},
{
"enabled": 0,
"ssid": "meatloaf",
"passphrase": "meatloaf"
},
{
"ssid": "CODA-23B0",
"passphrase": "meatloaf",
"enabled": 0
}
],
"settings": {
"enabled": 1,
"vic20_mode": 0,
"vdrive": 0,
"boot_disk": "",
"rom": {
"vdrive_mode": 0,
"autoboot": "",
"drive_roms": {
"enabled": 0,
"default": "",
"d64": "",
@ -136,60 +109,63 @@
"sid": 1,
"koa": 1,
"bbs": 1
},
"devices": {
}
},
"devices": {
"iec": {
"4": {
"enabled": 1,
"type": "printer",
"name": "MPS803"
"name": "MPS803",
"url": "/.print"
},
"8": {
"enabled": 1,
"type": "drive",
"url": "/sd",
"mode": 1
"mode": 1,
"url": "/"
},
"9": {
"enabled": 0,
"type": "drive",
"url": "/",
"mode": 1
"mode": 1,
"url": "/"
},
"10": {
"enabled": 0,
"type": "drive",
"url": "/",
"mode": 1
"mode": 1,
"url": "/"
},
"11": {
"enabled": 0,
"type": "drive",
"url": "/",
"mode": 1
"mode": 1,
"url": "/"
},
"12": {
"enabled": 0,
"type": "drive",
"url": "/",
"mode": 1
"mode": 1,
"url": "/"
},
"13": {
"enabled": 0,
"type": "drive",
"url": "/",
"mode": 1
"mode": 1,
"url": "/"
},
"14": {
"enabled": 0,
"type": "drive",
"url": "/",
"mode": 1
"mode": 1,
"url": "/"
},
"15": {
"enabled": 0,
"type": "drive",
"url": "/",
"mode": 1
"mode": 1,
"url": "/"
},
"16": {
"enabled": 1,
@ -229,14 +205,20 @@
"30": {
"enabled": 1,
"type": "meatloaf",
"url": "/",
"mode": 1
"mode": 1,
"url": "/"
}
},
"ps2": 0,
"userport": {
"enabled": 0,
"mode": "parallel"
},
"cassette": {
"enabled": 0,
"play_record": "0 Play",
"pulldown": "1 Pulldown Resistor",
"url": "/sd/choppin_mall.t64"
}
},
"boip": {
"enabled": 0,
"host": "",
"port": ""
}
}