Compare commits
5 Commits
e89a4b25c0
...
488d2304be
| Author | SHA1 | Date | |
|---|---|---|---|
| 488d2304be | |||
| 242358fb0d | |||
| ce2dd7eab1 | |||
| fed75c77df | |||
| e43ba5dd61 |
58
AGENTS.md
58
AGENTS.md
|
|
@ -22,7 +22,10 @@ src/
|
||||||
App.tsx # Root component: routing, nav, layout, WsProvider wrapper
|
App.tsx # Root component: routing, nav, layout, WsProvider wrapper
|
||||||
# fileManagerInitialPath / fileManagerReturnPage state for
|
# fileManagerInitialPath / fileManagerReturnPage state for
|
||||||
# deep-linking into MediaManager from StatusPage
|
# deep-linking into MediaManager from StatusPage
|
||||||
settings.ts # useSettings() hook — loads config.json + devices.json, saves split
|
settings.ts # useSettings() hook — loads config.json + devices.json, saves split;
|
||||||
|
# version-based migration: migrateConfig() renames old keys and
|
||||||
|
# restructures; deepMergeDefaults() fills missing keys from bundled
|
||||||
|
# defaults; auto-saves migrated config immediately on load
|
||||||
webdav.ts # WebDAV abstraction (listDirectory, stat, put/get, fileExists, etc.)
|
webdav.ts # WebDAV abstraction (listDirectory, stat, put/get, fileExists, etc.)
|
||||||
ws.tsx # WsProvider + useWs() — single shared WebSocket connection context
|
ws.tsx # WsProvider + useWs() — single shared WebSocket connection context
|
||||||
components/
|
components/
|
||||||
|
|
@ -40,17 +43,22 @@ src/
|
||||||
WiFiScanOverlay.tsx # WiFi scan results; Connect sends "connect <ssid> [<pass>]" via WS
|
WiFiScanOverlay.tsx # WiFi scan results; Connect sends "connect <ssid> [<pass>]" via WS
|
||||||
MediaManager.tsx # WebDAV file browser: file icons by type, kebab actions,
|
MediaManager.tsx # WebDAV file browser: file icons by type, kebab actions,
|
||||||
# Configure Folder (.config editor), base_url mount logic,
|
# Configure Folder (.config editor), base_url mount logic,
|
||||||
# media set existence check, navigate into new folder
|
# media set existence check, navigate into new folder;
|
||||||
# initialPath takes priority over localStorage when set
|
# initialPath takes priority over localStorage when set;
|
||||||
|
# Duplicate action: "name copy.ext" / "name copy N.ext" via copyPath;
|
||||||
|
# .lst and .vms playlist mount: parses lines into MediaSetEntry[]
|
||||||
MediaBrowser.tsx # Lightweight file picker (used in device mount dialogs)
|
MediaBrowser.tsx # Lightweight file picker (used in device mount dialogs)
|
||||||
MediaEntry.tsx # Shared entry row: icon by extension, hover highlight (blue left border),
|
MediaEntry.tsx # Shared entry row: icon by extension, hover highlight (blue left border),
|
||||||
# leftSlot / nameSlot props; exports EntryIcon + extension sets
|
# leftSlot / nameSlot props; exports EntryIcon + extension sets;
|
||||||
|
# PLAYLIST_EXTS = {lst, vms} → Layers icon (indigo)
|
||||||
MediaViewerEditor.tsx # Viewer/editor shell: tiled icon background (z-index:-1), toolbar
|
MediaViewerEditor.tsx # Viewer/editor shell: tiled icon background (z-index:-1), toolbar
|
||||||
MediaSet.tsx # Disk-swap button row; MediaSetEntry = string | {url, name?};
|
MediaSet.tsx # Disk-swap button row; MediaSetEntry = string | {url, name?};
|
||||||
# exports mediaSetEntryUrl() helper
|
# exports mediaSetEntryUrl() helper
|
||||||
DirectorySlideshow.tsx # Auto-advancing image slideshow from a WebDAV directory;
|
DirectorySlideshow.tsx # Auto-advancing image slideshow from a WebDAV directory;
|
||||||
# prev/next arrows, dot indicators, centered pause/play button;
|
# prev/next arrows, dot indicators, centered pause/play button;
|
||||||
# controls appear on hover or tap; paused + idx persisted to localStorage
|
# controls appear on hover or tap; paused + idx persisted to localStorage;
|
||||||
|
# module-level imgCache preloads all images; dotIdx/showIdx split
|
||||||
|
# prevents flicker — showIdx only advances once target is loaded
|
||||||
HexEditor.tsx # Hex viewer: responsive 8/16 col via ResizeObserver; bright address col
|
HexEditor.tsx # Hex viewer: responsive 8/16 col via ResizeObserver; bright address col
|
||||||
ConfigEditor.tsx # YAML-style .config editor
|
ConfigEditor.tsx # YAML-style .config editor
|
||||||
CodeEditor.tsx # CodeMirror code editor (transparent background)
|
CodeEditor.tsx # CodeMirror code editor (transparent background)
|
||||||
|
|
@ -81,9 +89,13 @@ public/
|
||||||
service-worker.js # PWA service worker
|
service-worker.js # PWA service worker
|
||||||
files/
|
files/
|
||||||
.sys/
|
.sys/
|
||||||
config.json # Runtime config on device (no devices.iec)
|
config.json # Runtime config: version, preferences, host, wifi, network, settings
|
||||||
devices.json # Runtime device list: { "iec": { "devices": { ... } } }
|
devices.json # Runtime devices: { "devices": { "iec": {...}, "userport": {...}, ... } }
|
||||||
webdav3.py # Dev Python WebDAV + WebSocket server (port 80, serves files/)
|
webdav3.py # Dev Python WebDAV + WebSocket server (port 80, serves files/);
|
||||||
|
# _log(tag, msg) timestamped logger; ws_broadcast(text) module-level
|
||||||
|
# broadcast returns sent count; _repl_thread() daemon reads stdin
|
||||||
|
# and broadcasts each line; log_message() override emits
|
||||||
|
# [DAV] METHOD /path → status for every request
|
||||||
index.html
|
index.html
|
||||||
vite.config.ts
|
vite.config.ts
|
||||||
```
|
```
|
||||||
|
|
@ -104,7 +116,7 @@ Bottom tab bar + header icons:
|
||||||
| (profile) | `general` | GeneralPage |
|
| (profile) | `general` | GeneralPage |
|
||||||
| (profile) | `about-meatloaf` | AboutMeatloafPage |
|
| (profile) | `about-meatloaf` | AboutMeatloafPage |
|
||||||
|
|
||||||
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))"`).
|
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))"`) with `richColors`, `closeButton`, and 5 s duration.
|
||||||
|
|
||||||
## Apps Page
|
## Apps Page
|
||||||
|
|
||||||
|
|
@ -169,6 +181,14 @@ Grid of app cards grouped by category, each navigates to a stub `AppPage` unless
|
||||||
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)
|
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[]`
|
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
|
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
|
||||||
|
52. **Config structure restructure** — top-level keys renamed: `general`→`preferences`, `iec`(bus settings)→`settings`, `iec.devices`→`devices.iec`; peripheral config moved into `devices`: `hardware.userport`→`devices.userport`, `hardware.ps2`→`devices.ps2`, `cassette`→`devices.cassette`; `bluetooth`/`modem` also moved into `devices`; `boip` removed; `version: "0.5.0"` added; all page components updated (`IECPage` uses `config.settings`, `GeneralPage` uses `config.preferences`, etc.)
|
||||||
|
53. **DevicesPage: physical/virtual device model** — IEC devices are virtual by default; "Rescan Bus" simulates discovery of physical devices (`SD2IEC`, `1541`, `1571`, `1581`, `PI1541`, etc.) at random IDs 8–29; physical device shadows virtual at same address (virtual hidden); physical badge shown; per-device Action Dialog (Radix `Dialog`) with type-specific actions (Initialize, Format, etc.); device number displayed above icon stacked vertically
|
||||||
|
54. **Config migration system** — `settings.ts` `readSettings()` now returns `{ config, migrated } | null`; `migrateConfig()` handles all old→new key renames and structural moves; `deepMergeDefaults()` recursively fills missing keys from bundled defaults without overwriting existing values; version mismatch triggers migration; migrated config is written back to device immediately (bypasses 3-second debounce); write failure falls back to dirty state
|
||||||
|
55. **DirectorySlideshow: flicker-free image transitions** — module-level `imgCache: Map<url, HTMLImageElement>` persists across re-renders; all images preloaded eagerly when directory is fetched; `dotIdx` (dot indicator, advances immediately) separated from `showIdx` (displayed image, advances only when target is confirmed loaded in browser cache); `key` prop removed from `<img>` so element stays mounted; auto-advance interval based on `showIdxRef` via ref to avoid stale closure
|
||||||
|
56. **`webdav3.py` REPL + logging** — `_log(tag, msg)` timestamped printer used throughout; module-level `ws_broadcast(text) -> int` builds WS frame and sends to all clients (removes dead sockets, returns sent count); `_repl_thread()` daemon reads stdin lines and broadcasts each, printing `> ` prompt and sent count; `log_message` override parses `requestline`/status into `[DAV] METHOD /path → status`; WS connect/recv/disconnect use `_log('WS', ...)`; REPL thread started as daemon before `serve_forever()`
|
||||||
|
57. **Toast visibility improvements** — `<Toaster>` in `App.tsx` gains `richColors` (vivid green/red/amber/blue for success/error/warning/loading), `closeButton` (explicit ✕), `duration={5000}` (up from 4 s default), and `toastOptions` with `fontSize: 0.9rem`, `fontWeight: 500`, `padding: 14px 16px`
|
||||||
|
58. **MediaManager: Duplicate action** — `duplicateEntry()` generates unique `"name copy.ext"` / `"name copy 2.ext"` etc. via sequential `fileExists` checks, then calls `copyPath`; button appears in the file Actions Dialog between Download and Rename; reloads current directory on success
|
||||||
|
59. **`.vms` Virtual Media Stack format** — `.vms` files are like `.lst` but each line is `path,name` (comma-separated); name after comma becomes the `MediaSetEntry.name` display label; `PLAYLIST_EXTS = new Set(['lst', 'vms'])` exported from `MediaEntry.tsx` with `Layers` icon (indigo); `MediaManager` and `DeviceDetailOverlay` both parse `.vms` into `MediaSetEntry[]`; `mediaSetEntryUrl()` used for `fileExists` checks and `dev.url`
|
||||||
|
|
||||||
## Known Issues / Open Work
|
## Known Issues / Open Work
|
||||||
|
|
||||||
|
|
@ -184,12 +204,24 @@ Config is split across two files on the device:
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/.sys/config.json` | `general`, `host`, `hardware`, `wifi`, `network`, `bluetooth`, `modem`, `cassette`, `boip`, `iec` (bus settings only — no `devices.iec`) |
|
| `/.sys/config.json` | `version`, `preferences`, `host`, `wifi`, `network`, `settings` (IEC bus — no `devices`) |
|
||||||
| `/.sys/devices.json` | `{ "iec": { "devices": { "printer": {...}, "drive": {...}, "network": {...}, "other": {...}, "meatloaf": {...} } } }` |
|
| `/.sys/devices.json` | `{ "devices": { "iec": {...}, "ps2": 0, "userport": {...}, "cassette": {...}, "bluetooth": {...}, "modem": {...} } }` |
|
||||||
|
|
||||||
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 `useSettings()` hook in `settings.ts` loads both files on mount, merges `devices.json`'s top-level keys one level deep into the config object, then splits them back on save (`devices` key → `devices.json`, everything else → `config.json`). 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 `devices.iec`) 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. Its `version` field is compared against the loaded config: a mismatch triggers `migrateConfig()` which renames old keys, restructures peripheral config into `devices`, fills missing keys from bundled defaults, and immediately saves the migrated result.
|
||||||
|
|
||||||
|
### Top-level config keys
|
||||||
|
|
||||||
|
| Key | Component | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `version` | — | `"0.5.0"`; migration guard |
|
||||||
|
| `preferences` | GeneralPage | Was `general` |
|
||||||
|
| `host` | — | Model, video, kernal |
|
||||||
|
| `wifi` | NetworkPage | Array of `{ssid, passphrase, enabled}` |
|
||||||
|
| `network` | NetworkPage | Hostname, SNTP, services |
|
||||||
|
| `settings` | IECPage | IEC bus settings; was `iec_config` in old firmware |
|
||||||
|
| `devices` | DevicesPage | `iec` flat device map + `userport`, `ps2`, `cassette`, `bluetooth`, `modem` |
|
||||||
|
|
||||||
### media_set format
|
### media_set format
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,16 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
return (
|
return (
|
||||||
<WsProvider>
|
<WsProvider>
|
||||||
<div className="size-full flex flex-col bg-neutral-50">
|
<div className="size-full flex flex-col bg-neutral-50">
|
||||||
<Toaster position="bottom-center" offset="calc(4rem + env(safe-area-inset-bottom))" />
|
<Toaster
|
||||||
|
position="bottom-center"
|
||||||
|
offset="calc(4rem + env(safe-area-inset-bottom))"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
duration={5000}
|
||||||
|
toastOptions={{
|
||||||
|
style: { fontSize: '0.9rem', fontWeight: 500, padding: '14px 16px' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<header className="bg-[#4d4d4d] px-0 py-0 flex-shrink-0" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
|
<header className="bg-[#4d4d4d] px-0 py-0 flex-shrink-0" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
|
||||||
<div className="flex items-stretch justify-between min-h-[56px]">
|
<div className="flex items-stretch justify-between min-h-[56px]">
|
||||||
<button onClick={() => setCurrentPage('status')} className="flex items-center h-full">
|
<button onClick={() => setCurrentPage('status')} className="flex items-center h-full">
|
||||||
|
|
@ -290,7 +299,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setCurrentPage('iec')} className="flex-1 flex flex-col items-center gap-1 py-2">
|
<button onClick={() => setCurrentPage('iec')} className="flex-1 flex flex-col items-center gap-1 py-2">
|
||||||
<Cpu className="w-5 h-5 text-white" />
|
<Cpu className="w-5 h-5 text-white" />
|
||||||
<span className="text-xs text-white">IEC</span>
|
<span className="text-xs text-white">Options</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setCurrentPage('network')} className="flex-1 flex flex-col items-center gap-1 py-2">
|
<button onClick={() => setCurrentPage('network')} className="flex-1 flex flex-col items-center gap-1 py-2">
|
||||||
<Network className="w-5 h-5 text-white" />
|
<Network className="w-5 h-5 text-white" />
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { fileExists, getFileContents, joinPath } from '../webdav';
|
import { fileExists, getFileContents, joinPath } from '../webdav';
|
||||||
import MediaBrowser from './MediaBrowser';
|
import MediaBrowser from './MediaBrowser';
|
||||||
import MediaSet, { type MediaSetEntry } from './MediaSet';
|
import MediaSet, { mediaSetEntryUrl, type MediaSetEntry } from './MediaSet';
|
||||||
|
|
||||||
interface Device {
|
interface Device {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -94,7 +94,7 @@ export default function DeviceDetailOverlay({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDevicePath = (): string[] => {
|
const getDevicePath = (): string[] => {
|
||||||
return ['iec', 'devices', device.number];
|
return ['devices', 'iec', device.number];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDeviceData = () => {
|
const getDeviceData = () => {
|
||||||
|
|
@ -160,16 +160,29 @@ export default function DeviceDetailOverlay({
|
||||||
|
|
||||||
const handleFileSelect = async (selectedPath: string) => {
|
const handleFileSelect = async (selectedPath: string) => {
|
||||||
const devicePath = getDevicePath();
|
const devicePath = getDevicePath();
|
||||||
if (selectedPath.toLowerCase().endsWith('.lst')) {
|
const selExt = selectedPath.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (selExt === 'lst' || selExt === 'vms') {
|
||||||
|
const isVms = selExt === 'vms';
|
||||||
try {
|
try {
|
||||||
const text = await (await getFileContents(selectedPath)).text();
|
const text = await (await getFileContents(selectedPath)).text();
|
||||||
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
|
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
|
||||||
const candidates = text.split('\n')
|
const candidates: MediaSetEntry[] = text.split('\n')
|
||||||
.map(l => l.trim())
|
.map(l => l.trim())
|
||||||
.filter(l => l.length > 0 && !l.startsWith('#'))
|
.filter(l => l.length > 0 && !l.startsWith('#'))
|
||||||
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
|
.map(l => {
|
||||||
|
if (isVms) {
|
||||||
|
const comma = l.indexOf(',');
|
||||||
|
if (comma > 0) {
|
||||||
|
const url = l.slice(0, comma).trim();
|
||||||
|
const name = l.slice(comma + 1).trim();
|
||||||
|
const resolved = url.startsWith('/') ? url : joinPath(dir, url);
|
||||||
|
return name ? { url: resolved, name } : resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l.startsWith('/') ? l : joinPath(dir, l);
|
||||||
|
});
|
||||||
if (candidates.length === 0) { toast.error('Swap list is empty'); return; }
|
if (candidates.length === 0) { toast.error('Swap list is empty'); return; }
|
||||||
const existsArr = await Promise.all(candidates.map(f => fileExists(f).catch(() => false)));
|
const existsArr = await Promise.all(candidates.map(e => fileExists(mediaSetEntryUrl(e)).catch(() => false)));
|
||||||
const files = candidates.filter((_, i) => existsArr[i]);
|
const files = candidates.filter((_, i) => existsArr[i]);
|
||||||
if (files.length === 0) { toast.error('No files in swap list exist on device'); return; }
|
if (files.length === 0) { toast.error('No files in swap list exist on device'); return; }
|
||||||
if (files.length < candidates.length) {
|
if (files.length < candidates.length) {
|
||||||
|
|
@ -178,8 +191,8 @@ export default function DeviceDetailOverlay({
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
let dev = newConfig;
|
let dev = newConfig;
|
||||||
for (const k of devicePath) dev = dev[k];
|
for (const k of devicePath) dev = dev[k];
|
||||||
if (isOutsideBase(files[0], dev.base_url || '')) clearBaseAndCache(dev);
|
if (isOutsideBase(mediaSetEntryUrl(files[0]), dev.base_url || '')) clearBaseAndCache(dev);
|
||||||
dev.url = files[0];
|
dev.url = mediaSetEntryUrl(files[0]);
|
||||||
dev.media_set = files;
|
dev.media_set = files;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -227,15 +240,10 @@ export default function DeviceDetailOverlay({
|
||||||
<button onClick={onClose} className="p-2 -m-2">
|
<button onClick={onClose} className="p-2 -m-2">
|
||||||
<X className="w-6 h-6" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
<div className={`flex flex-col items-center gap-0.5 ${deviceData.enabled ? 'text-blue-600' : 'text-neutral-400'}`}>
|
||||||
<div className={deviceData.enabled ? 'text-blue-600' : 'text-neutral-400'}>
|
<span className="text-sm font-semibold leading-none">{device.number}</span>
|
||||||
{getDeviceIcon(device.type)}
|
{getDeviceIcon(device.type)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-xs text-neutral-500 capitalize">{device.type}</div>
|
|
||||||
<div className="font-medium">#{device.number}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCommandMenu(!showCommandMenu)}
|
onClick={() => setShowCommandMenu(!showCommandMenu)}
|
||||||
className="p-2 -m-2 relative"
|
className="p-2 -m-2 relative"
|
||||||
|
|
@ -285,26 +293,6 @@ export default function DeviceDetailOverlay({
|
||||||
|
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Device Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={deviceData.name || device.name || `Device ${device.number}`}
|
|
||||||
onChange={(e) => {
|
|
||||||
const path = getDevicePath();
|
|
||||||
updateDeviceSetting([...path, 'name'], e.target.value);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Type</label>
|
|
||||||
<div className="px-3 py-2 bg-neutral-50 border border-neutral-200 rounded-lg text-neutral-700">
|
|
||||||
{device.type.charAt(0).toUpperCase() + device.type.slice(1)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm text-neutral-500">Enabled</label>
|
<label className="text-sm text-neutral-500">Enabled</label>
|
||||||
<button
|
<button
|
||||||
|
|
@ -324,7 +312,26 @@ export default function DeviceDetailOverlay({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deviceData.base_url !== undefined && (
|
<div>
|
||||||
|
<label className="text-sm text-neutral-500 block mb-2">Type</label>
|
||||||
|
<div className="px-3 py-2 bg-neutral-50 border border-neutral-200 rounded-lg text-neutral-700">
|
||||||
|
{device.type.charAt(0).toUpperCase() + device.type.slice(1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-neutral-500 block mb-2">Device Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deviceData.name || device.name || `Device ${device.number}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const path = getDevicePath();
|
||||||
|
updateDeviceSetting([...path, 'name'], e.target.value);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
|
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -340,19 +347,29 @@ export default function DeviceDetailOverlay({
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('base_url')}
|
onClick={() => setBrowsingField('base_url')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
||||||
|
title="Browse"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const path = getDevicePath();
|
||||||
|
updateDeviceSetting([...path, 'base_url'], '');
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-red-50 hover:border-red-300 hover:text-red-500"
|
||||||
|
title="Clear Base URL"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">URL</label>
|
<label className="text-sm text-neutral-500 block mb-2">URL</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={deviceData.url}
|
value={deviceData.url ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newUrl = e.target.value;
|
const newUrl = e.target.value;
|
||||||
const devicePath = getDevicePath();
|
const devicePath = getDevicePath();
|
||||||
|
|
@ -368,9 +385,25 @@ export default function DeviceDetailOverlay({
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('url')}
|
onClick={() => setBrowsingField('url')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
||||||
|
title="Browse"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const devicePath = getDevicePath();
|
||||||
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
|
let dev = newConfig;
|
||||||
|
for (const k of devicePath) dev = dev[k];
|
||||||
|
delete dev.url;
|
||||||
|
delete dev.media_set;
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-red-50 hover:border-red-300 hover:text-red-500"
|
||||||
|
title="Clear URL and media set"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mediaSetFiles && (
|
{mediaSetFiles && (
|
||||||
|
|
@ -418,15 +451,6 @@ export default function DeviceDetailOverlay({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deviceData.type && (
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Device Type</label>
|
|
||||||
<div className="px-3 py-2 bg-neutral-50 border border-neutral-200 rounded-lg text-neutral-700">
|
|
||||||
{deviceData.type}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deviceData.baud !== undefined && (
|
{deviceData.baud !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Baud Rate</label>
|
<label className="text-sm text-neutral-500 block mb-2">Baud Rate</label>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Folder,
|
Folder,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
Layers,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Music,
|
Music,
|
||||||
Package,
|
Package,
|
||||||
|
|
@ -23,7 +24,8 @@ import { humanFileSize, type EntryInfo } from '../webdav';
|
||||||
|
|
||||||
// ─── Extension sets ───────────────────────────────────────────────────────────
|
// ─── Extension sets ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']);
|
export const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv']);
|
||||||
|
export const PLAYLIST_EXTS = new Set(['lst', 'vms']);
|
||||||
export const DOC_EXTS = new Set(['doc', 'docx', 'odt', 'rtf', 'pdf', 'pages', 'tex', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']);
|
export const DOC_EXTS = new Set(['doc', 'docx', 'odt', 'rtf', 'pdf', 'pages', 'tex', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']);
|
||||||
export 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']);
|
export 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']);
|
||||||
export const MD_EXTS = new Set(['md', 'markdown']);
|
export const MD_EXTS = new Set(['md', 'markdown']);
|
||||||
|
|
@ -52,6 +54,7 @@ export function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||||
if (ROM_EXTS.has(ext)) return <Cpu className="w-5 h-5 text-red-500 flex-shrink-0" />;
|
if (ROM_EXTS.has(ext)) return <Cpu className="w-5 h-5 text-red-500 flex-shrink-0" />;
|
||||||
if (AUDIO_EXTS.has(ext)) return <Music className="w-5 h-5 text-teal-500 flex-shrink-0" />;
|
if (AUDIO_EXTS.has(ext)) return <Music className="w-5 h-5 text-teal-500 flex-shrink-0" />;
|
||||||
if (ARCHIVE_EXTS.has(ext)) return <Package className="w-5 h-5 text-yellow-600 flex-shrink-0" />;
|
if (ARCHIVE_EXTS.has(ext)) return <Package className="w-5 h-5 text-yellow-600 flex-shrink-0" />;
|
||||||
|
if (PLAYLIST_EXTS.has(ext)) return <Layers className="w-5 h-5 text-indigo-500 flex-shrink-0" />;
|
||||||
if (CONFIG_EXTS.has(ext)) return <SlidersHorizontal className="w-5 h-5 text-slate-400 flex-shrink-0" />;
|
if (CONFIG_EXTS.has(ext)) return <SlidersHorizontal className="w-5 h-5 text-slate-400 flex-shrink-0" />;
|
||||||
if (JSON_EXTS.has(ext)) return <Braces className="w-5 h-5 text-yellow-500 flex-shrink-0" />;
|
if (JSON_EXTS.has(ext)) return <Braces className="w-5 h-5 text-yellow-500 flex-shrink-0" />;
|
||||||
if (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
|
if (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ import {
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { MediaEntry, TEXT_EXTS, DOC_EXTS, CODE_EXTS, MD_EXTS, JSON_EXTS, XML_EXTS, IMAGE_EXTS, CONFIG_EXTS } from './MediaEntry';
|
import { MediaEntry, TEXT_EXTS, DOC_EXTS, CODE_EXTS, MD_EXTS, JSON_EXTS, XML_EXTS, IMAGE_EXTS, CONFIG_EXTS, PLAYLIST_EXTS } from './MediaEntry';
|
||||||
|
import { mediaSetEntryUrl, type MediaSetEntry } from './MediaSet';
|
||||||
import type { ViewMode } from './MediaViewerEditor';
|
import type { ViewMode } from './MediaViewerEditor';
|
||||||
const MediaViewerEditor = lazy(() => import('./MediaViewerEditor'));
|
const MediaViewerEditor = lazy(() => import('./MediaViewerEditor'));
|
||||||
|
|
||||||
|
|
@ -80,6 +81,7 @@ function defaultViewMode(entry: EntryInfo): ViewMode {
|
||||||
if (CODE_EXTS.has(ext)) return 'code';
|
if (CODE_EXTS.has(ext)) return 'code';
|
||||||
if (DOC_EXTS.has(ext)) return 'doc';
|
if (DOC_EXTS.has(ext)) return 'doc';
|
||||||
if (TEXT_EXTS.has(ext)) return 'text';
|
if (TEXT_EXTS.has(ext)) return 'text';
|
||||||
|
if (PLAYLIST_EXTS.has(ext)) return 'text';
|
||||||
return 'hex';
|
return 'hex';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +147,7 @@ interface ActionsModalProps {
|
||||||
onOpen: (entry: EntryInfo, mode?: ViewMode) => void;
|
onOpen: (entry: EntryInfo, mode?: ViewMode) => void;
|
||||||
onMount: (entry: EntryInfo) => void;
|
onMount: (entry: EntryInfo) => void;
|
||||||
onDownload: (entry: EntryInfo) => void;
|
onDownload: (entry: EntryInfo) => void;
|
||||||
|
onDuplicate: (entry: EntryInfo) => void;
|
||||||
onRename: (entry: EntryInfo) => void;
|
onRename: (entry: EntryInfo) => void;
|
||||||
onCopy: (entry: EntryInfo) => void;
|
onCopy: (entry: EntryInfo) => void;
|
||||||
onCut: (entry: EntryInfo) => void;
|
onCut: (entry: EntryInfo) => void;
|
||||||
|
|
@ -152,7 +155,7 @@ interface ActionsModalProps {
|
||||||
folderManagement?: FolderManagementActions;
|
folderManagement?: FolderManagementActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onRename, onCopy, onCut, onDelete, folderManagement }: ActionsModalProps) {
|
function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onDuplicate, onRename, onCopy, onCut, onDelete, folderManagement }: ActionsModalProps) {
|
||||||
const isFolder = entry?.type === 'folder';
|
const isFolder = entry?.type === 'folder';
|
||||||
const fm = folderManagement;
|
const fm = folderManagement;
|
||||||
|
|
||||||
|
|
@ -229,17 +232,21 @@ function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onRename, o
|
||||||
<span className="flex-1">Open / View</span>
|
<span className="flex-1">Open / View</span>
|
||||||
<span className="text-xs text-neutral-400">{VIEWER_LABEL[defaultViewMode(entry)]}</span>
|
<span className="text-xs text-neutral-400">{VIEWER_LABEL[defaultViewMode(entry)]}</span>
|
||||||
</button>
|
</button>
|
||||||
{availableViewers(entry).filter(m => m !== defaultViewMode(entry)).map(mode => (
|
{/* {availableViewers(entry).filter(m => m !== defaultViewMode(entry)).map(mode => (
|
||||||
<button key={mode} onClick={() => { onClose(); onOpen(entry, mode); }}
|
<button key={mode} onClick={() => { onClose(); onOpen(entry, mode); }}
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
||||||
<ViewerModeIcon mode={mode} className="w-4 h-4 text-neutral-500" />
|
<ViewerModeIcon mode={mode} className="w-4 h-4 text-neutral-500" />
|
||||||
<span>Open as {VIEWER_LABEL[mode]}</span>
|
<span>Open as {VIEWER_LABEL[mode]}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))} */}
|
||||||
<button onClick={() => { onClose(); onDownload(entry); }}
|
<button onClick={() => { onClose(); onDownload(entry); }}
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
||||||
<Download className="w-4 h-4" /> <span>Download</span>
|
<Download className="w-4 h-4" /> <span>Download</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => { onClose(); onDuplicate(entry); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
||||||
|
<Copy className="w-4 h-4" /> <span>Duplicate</span>
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -630,19 +637,32 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
||||||
const dev = newConfig.devices.iec[key];
|
const dev = newConfig.devices.iec[key];
|
||||||
|
|
||||||
if (mountEntry.name.toLowerCase().endsWith('.lst')) {
|
const mountExt = mountEntry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (PLAYLIST_EXTS.has(mountExt)) {
|
||||||
try {
|
try {
|
||||||
const text = await (await getFileContents(mountEntry.path)).text();
|
const text = await (await getFileContents(mountEntry.path)).text();
|
||||||
const dir = splitPath(mountEntry.path).parent;
|
const dir = splitPath(mountEntry.path).parent;
|
||||||
const candidates = text.split('\n')
|
const isVms = mountExt === 'vms';
|
||||||
|
const candidates: MediaSetEntry[] = text.split('\n')
|
||||||
.map(l => l.trim())
|
.map(l => l.trim())
|
||||||
.filter(l => l.length > 0 && !l.startsWith('#'))
|
.filter(l => l.length > 0 && !l.startsWith('#'))
|
||||||
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
|
.map(l => {
|
||||||
|
if (isVms) {
|
||||||
|
const comma = l.indexOf(',');
|
||||||
|
if (comma > 0) {
|
||||||
|
const url = l.slice(0, comma).trim();
|
||||||
|
const name = l.slice(comma + 1).trim();
|
||||||
|
const resolved = url.startsWith('/') ? url : joinPath(dir, url);
|
||||||
|
return name ? { url: resolved, name } : resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l.startsWith('/') ? l : joinPath(dir, l);
|
||||||
|
});
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
toast.error(`${mountEntry.name}: swap list is empty`);
|
toast.error(`${mountEntry.name}: swap list is empty`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const exists = await Promise.all(candidates.map(f => fileExists(f).catch(() => false)));
|
const exists = await Promise.all(candidates.map(e => fileExists(mediaSetEntryUrl(e)).catch(() => false)));
|
||||||
const files = candidates.filter((_, i) => exists[i]);
|
const files = candidates.filter((_, i) => exists[i]);
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
toast.error(`${mountEntry.name}: no files in swap list exist on device`);
|
toast.error(`${mountEntry.name}: no files in swap list exist on device`);
|
||||||
|
|
@ -651,7 +671,7 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
if (files.length < candidates.length) {
|
if (files.length < candidates.length) {
|
||||||
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
|
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
|
||||||
}
|
}
|
||||||
dev.url = files[0];
|
dev.url = mediaSetEntryUrl(files[0]);
|
||||||
dev.media_set = files;
|
dev.media_set = files;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`);
|
toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`);
|
||||||
|
|
@ -677,9 +697,9 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
setMountEntry(null);
|
setMountEntry(null);
|
||||||
const deviceId = `${deviceType}-${key}`;
|
const deviceId = `${deviceType}-${key}`;
|
||||||
const isLst = mountEntry.name.toLowerCase().endsWith('.lst');
|
const isPlaylist = PLAYLIST_EXTS.has(mountExt);
|
||||||
const label = isLst
|
const label = isPlaylist
|
||||||
? `Loaded swap list "${mountEntry.name}" (${(dev.media_set as string[]).length} disks) on ${deviceType} #${key}`
|
? `Loaded "${mountEntry.name}" (${(dev.media_set as MediaSetEntry[]).length} disks) on ${deviceType} #${key}`
|
||||||
: `Mounted "${mountEntry.name}" on ${deviceType} #${key}`;
|
: `Mounted "${mountEntry.name}" on ${deviceType} #${key}`;
|
||||||
toast.success(label, {
|
toast.success(label, {
|
||||||
action: onNavigateToDevice
|
action: onNavigateToDevice
|
||||||
|
|
@ -696,6 +716,28 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
setActionEntry(null);
|
setActionEntry(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const duplicateEntry = async (entry: EntryInfo) => {
|
||||||
|
const { parent } = splitPath(entry.path);
|
||||||
|
const dotIdx = entry.name.lastIndexOf('.');
|
||||||
|
const base = dotIdx > 0 ? entry.name.slice(0, dotIdx) : entry.name;
|
||||||
|
const ext = dotIdx > 0 ? entry.name.slice(dotIdx) : '';
|
||||||
|
let destName = `${base} copy${ext}`;
|
||||||
|
let destPath = joinPath(parent, destName);
|
||||||
|
let n = 2;
|
||||||
|
while (await fileExists(destPath)) {
|
||||||
|
destName = `${base} copy ${n}${ext}`;
|
||||||
|
destPath = joinPath(parent, destName);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await copyPath(entry.path, destPath);
|
||||||
|
toast.success(`Duplicated as "${destName}"`);
|
||||||
|
void load(path);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Duplicate failed: ${e?.message ?? e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const copySelected = () => {
|
const copySelected = () => {
|
||||||
setClipboard({ op: 'copy', paths: [...selected] });
|
setClipboard({ op: 'copy', paths: [...selected] });
|
||||||
toast.success(`${selected.size} item${selected.size !== 1 ? 's' : ''} copied — navigate and paste`);
|
toast.success(`${selected.size} item${selected.size !== 1 ? 's' : ''} copied — navigate and paste`);
|
||||||
|
|
@ -1109,6 +1151,7 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
onOpen={(e, mode) => void openEntry(e, mode)}
|
onOpen={(e, mode) => void openEntry(e, mode)}
|
||||||
onMount={e => setMountEntry(e)}
|
onMount={e => setMountEntry(e)}
|
||||||
onDownload={e => void downloadEntry(e)}
|
onDownload={e => void downloadEntry(e)}
|
||||||
|
onDuplicate={e => void duplicateEntry(e)}
|
||||||
onRename={e => startRename(e)}
|
onRename={e => startRename(e)}
|
||||||
onCopy={e => cutOrCopyEntry(e, 'copy')}
|
onCopy={e => cutOrCopyEntry(e, 'copy')}
|
||||||
onCut={e => cutOrCopyEntry(e, 'move')}
|
onCut={e => cutOrCopyEntry(e, 'move')}
|
||||||
|
|
|
||||||
|
|
@ -273,16 +273,16 @@ export default function StatusPage({ config, setConfig, onOpenFileManager }: Sta
|
||||||
{/* Reset Activity Modal */}
|
{/* Reset Activity Modal */}
|
||||||
<Dialog open={!!showResetModal} onOpenChange={open => !open && setShowResetModal(null)}>
|
<Dialog open={!!showResetModal} onOpenChange={open => !open && setShowResetModal(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogTitle>{showResetModal === 'meatloaf' ? 'Reset Meatloaf' : 'Reset Host'}</DialogTitle>
|
<DialogTitle>{showResetModal === 'meatloaf' ? 'Reboot Meatloaf' : 'Reset Host'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{resetStatus === 'idle' && (
|
{resetStatus === 'idle' && (
|
||||||
<>
|
<>
|
||||||
Are you sure you want to reset {showResetModal === 'meatloaf' ? 'the Meatloaf device' : 'the Host'}?
|
Are you sure you want to {showResetModal === 'meatloaf' ? 'reboot the Meatloaf device' : 'reset the Host'}?
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
<button
|
<button
|
||||||
className="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
|
className="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
wsSend(showResetModal === 'meatloaf' ? 'reset' : 'reset hard');
|
wsSend(showResetModal === 'meatloaf' ? 'reboot' : 'reset host');
|
||||||
setResetStatus('in-progress');
|
setResetStatus('in-progress');
|
||||||
setTimeout(() => setResetStatus('done'), 2000);
|
setTimeout(() => setResetStatus('done'), 2000);
|
||||||
}}
|
}}
|
||||||
|
|
@ -501,7 +501,7 @@ export default function StatusPage({ config, setConfig, onOpenFileManager }: Sta
|
||||||
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-blue-600 text-white hover:bg-blue-700 transition text-base font-medium w-full"
|
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-blue-600 text-white hover:bg-blue-700 transition text-base font-medium w-full"
|
||||||
onClick={() => { setShowResetModal('meatloaf'); setResetStatus('idle'); }}
|
onClick={() => { setShowResetModal('meatloaf'); setResetStatus('idle'); }}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-5 h-5" /> Reset Meatloaf
|
<RefreshCw className="w-5 h-5" /> Reboot Meatloaf
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-blue-600 text-white hover:bg-blue-700 transition text-base font-medium w-full"
|
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-blue-600 text-white hover:bg-blue-700 transition text-base font-medium w-full"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user