From b70c98d69af0c5579209d5a83401c4831e9d4471 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Mon, 8 Jun 2026 13:35:07 -0400 Subject: [PATCH] feat(ConfigEditor, MediaManager, DeviceDetailOverlay, StatusPage): implement config editing functionality and update media set handling --- src/app/components/ConfigEditor.tsx | 100 +++++++++++++++++++++ src/app/components/DeviceDetailOverlay.tsx | 10 +-- src/app/components/MediaManager.tsx | 46 +++++++--- src/app/components/StatusPage.tsx | 4 +- 4 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 src/app/components/ConfigEditor.tsx diff --git a/src/app/components/ConfigEditor.tsx b/src/app/components/ConfigEditor.tsx new file mode 100644 index 0000000..b5d169a --- /dev/null +++ b/src/app/components/ConfigEditor.tsx @@ -0,0 +1,100 @@ +import { useRef, useState } from 'react'; +import { Plus, Save, Trash2 } from 'lucide-react'; + +interface Pair { id: string; key: string; value: string; } + +function parse(text: string): Pair[] { + let n = 0; + return text.split('\n') + .filter(line => { const t = line.trim(); return t && !t.startsWith('#'); }) + .map(line => { + const eq = line.indexOf('='); + return eq < 0 + ? { id: String(n++), key: line.trim(), value: '' } + : { id: String(n++), key: line.slice(0, eq).trim(), value: line.slice(eq + 1).trim() }; + }); +} + +function serialize(pairs: Pair[]): string { + return pairs.map(p => `${p.key}=${p.value}`).join('\n') + (pairs.length ? '\n' : ''); +} + +interface ConfigEditorProps { + text: string; + onSave?: (text: string) => Promise; +} + +export default function ConfigEditor({ text, onSave }: ConfigEditorProps) { + const [pairs, setPairs] = useState(() => parse(text)); + const [saving, setSaving] = useState(false); + const counter = useRef(parse(text).length); + + const newId = () => String(counter.current++); + + const updateKey = (id: string, key: string) => setPairs(ps => ps.map(p => p.id === id ? { ...p, key } : p)); + const updateValue = (id: string, value: string) => setPairs(ps => ps.map(p => p.id === id ? { ...p, value } : p)); + const deleteRow = (id: string) => setPairs(ps => ps.filter(p => p.id !== id)); + const addRow = () => setPairs(ps => [...ps, { id: newId(), key: '', value: '' }]); + + const handleSave = async () => { + if (!onSave) return; + setSaving(true); + try { await onSave(serialize(pairs)); } + finally { setSaving(false); } + }; + + return ( +
+
+ + {onSave && ( + + )} +
+ +
+ {pairs.length === 0 && ( +
+ No entries — click Add to create one +
+ )} +
+ {pairs.map(pair => ( +
+ updateKey(pair.id, e.target.value)} + placeholder="name" + className="w-36 flex-shrink-0 px-2 py-1.5 bg-neutral-800 border border-neutral-600 rounded text-sm text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-blue-500" + /> + = + updateValue(pair.id, e.target.value)} + placeholder="value" + className="flex-1 min-w-0 px-2 py-1.5 bg-neutral-800 border border-neutral-600 rounded text-sm text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-blue-500" + /> + +
+ ))} +
+
+
+ ); +} diff --git a/src/app/components/DeviceDetailOverlay.tsx b/src/app/components/DeviceDetailOverlay.tsx index 7b60882..164cf1d 100644 --- a/src/app/components/DeviceDetailOverlay.tsx +++ b/src/app/components/DeviceDetailOverlay.tsx @@ -148,8 +148,8 @@ export default function DeviceDetailOverlay({ // Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection. const mediaSetFiles: string[] | null = (() => { - if (Array.isArray(deviceData.mediaSet) && deviceData.mediaSet.length > 0) { - return deviceData.mediaSet as string[]; + if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 0) { + return deviceData.media_set as string[]; } const detected = detectMediaSet(); return detected ? detected.files : null; @@ -175,7 +175,7 @@ export default function DeviceDetailOverlay({ let dev = newConfig; for (const k of devicePath) dev = dev[k]; dev.url = files[0]; - dev.mediaSet = files; + dev.media_set = files; setConfig(newConfig); } catch (e: any) { toast.error(`Failed to read swap list: ${e?.message ?? e}`); @@ -185,7 +185,7 @@ export default function DeviceDetailOverlay({ let dev = newConfig; for (const k of devicePath) dev = dev[k]; dev.url = selectedPath; - delete dev.mediaSet; + delete dev.media_set; setConfig(newConfig); } }; @@ -340,7 +340,7 @@ export default function DeviceDetailOverlay({ )} - +
diff --git a/src/app/components/MediaManager.tsx b/src/app/components/MediaManager.tsx index 6fea34c..daf4a14 100644 --- a/src/app/components/MediaManager.tsx +++ b/src/app/components/MediaManager.tsx @@ -25,6 +25,7 @@ import { Loader2, MoreVertical, Move, + SlidersHorizontal, Pencil, RefreshCw, Save, @@ -41,6 +42,7 @@ import CodeMirror, { EditorView } from '@uiw/react-codemirror'; import { oneDark } from '@codemirror/theme-one-dark'; import HexEditor from './HexEditor'; import CodeEditor from './CodeEditor'; +import ConfigEditor from './ConfigEditor'; import { copyPath, createFolder, @@ -69,7 +71,7 @@ import { type SortKey = 'name' | 'size' | 'date'; type Clipboard = { op: 'copy' | 'move'; paths: string[] }; type FileCategory = 'text' | 'image' | 'disk' | 'binary'; -type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image'; +type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config'; // ─── Extension sets ────────────────────────────────────────────────────────── @@ -78,24 +80,39 @@ const MD_EXTS = new Set(['md', 'markdown']); const JSON_EXTS = new Set(['json']); const XML_EXTS = new Set(['xml', 'svg', 'html', 'htm', 'rss', 'atom', 'xsl']); const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']); -const DISK_EXTS = new Set(['d64', 'd71', 'd81', 'd82', 'g64', 'g71', 't64', 'tap', 'crt', 'nib']); +const AUDIO_EXTS = new Set(['sid', 'psid', 'mus', 'vgm']); +const ROM_EXTS = new Set(['bin', 'rom', 'crt']); +const TAPE_EXTS = new Set(['tap', 't64', 'tcrt']); +const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', 'd90', 'd8b', 'dfi', 'g64', 'g71', 'g81', 'p64', 'p71', 'p81', 'nib']); +const DISC_EXTS = new Set(['iso', 'img', 'cue']); +const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'dhd', 'hdd']); +const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']); +const CONFIG_EXTS = new Set(['config']); function fileCategory(entry: EntryInfo): FileCategory { if (entry.type === 'folder') return 'binary'; const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; if (IMAGE_EXTS.has(ext)) return 'image'; if (DISK_EXTS.has(ext)) return 'disk'; + if (HD_EXTS.has(ext)) return 'disk'; + if (ROM_EXTS.has(ext)) return 'disk'; + if (TAPE_EXTS.has(ext)) return 'disk'; + if (AUDIO_EXTS.has(ext)) return 'disk'; + if (ARCHIVE_EXTS.has(ext)) return 'disk'; + if (DISC_EXTS.has(ext)) return 'disk'; + if (CONFIG_EXTS.has(ext)) return 'text'; if (TEXT_EXTS.has(ext) || MD_EXTS.has(ext) || JSON_EXTS.has(ext) || XML_EXTS.has(ext)) return 'text'; return 'binary'; } function defaultViewMode(entry: EntryInfo): ViewMode { const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; - if (IMAGE_EXTS.has(ext)) return 'image'; - if (MD_EXTS.has(ext)) return 'markdown'; - if (JSON_EXTS.has(ext)) return 'json'; - if (XML_EXTS.has(ext)) return 'xml'; - if (TEXT_EXTS.has(ext)) return 'text'; + if (IMAGE_EXTS.has(ext)) return 'image'; + if (CONFIG_EXTS.has(ext)) return 'config'; + if (MD_EXTS.has(ext)) return 'markdown'; + if (JSON_EXTS.has(ext)) return 'json'; + if (XML_EXTS.has(ext)) return 'xml'; + if (TEXT_EXTS.has(ext)) return 'text'; return 'hex'; } @@ -103,6 +120,7 @@ function availableViewers(entry: EntryInfo): ViewMode[] { const def = defaultViewMode(entry); const map: Record = { image: ['image', 'hex'], + config: ['config', 'text', 'hex'], markdown: ['markdown', 'text', 'hex'], json: ['json', 'text', 'hex'], xml: ['xml', 'text', 'hex'], @@ -113,7 +131,7 @@ function availableViewers(entry: EntryInfo): ViewMode[] { } const VIEWER_LABEL: Record = { - text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image', + text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image', config: 'Config', }; // ─── Viewer components ─────────────────────────────────────────────────────── @@ -126,7 +144,8 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin case 'json': return ; case 'xml': return ; case 'hex': return ; - case 'image': return ; + case 'image': return ; + case 'config': return ; } } @@ -734,14 +753,14 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi return; } dev.url = files[0]; - dev.mediaSet = files; + dev.media_set = files; } catch (e: any) { toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`); return; } } else { dev.url = mountEntry.path; - delete dev.mediaSet; + delete dev.media_set; } if (!dev.enabled) dev.enabled = 1; @@ -755,7 +774,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi const deviceId = `${deviceType}-${key}`; const isLst = mountEntry.name.toLowerCase().endsWith('.lst'); const label = isLst - ? `Loaded swap list "${mountEntry.name}" (${(dev.mediaSet as string[]).length} disks) on ${deviceType} #${key}` + ? `Loaded swap list "${mountEntry.name}" (${(dev.media_set as string[]).length} disks) on ${deviceType} #${key}` : `Mounted "${mountEntry.name}" on ${deviceType} #${key}`; toast.success(label, { action: onNavigateToDevice @@ -1247,6 +1266,9 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi {!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && ( saveViewFile(s)} /> )} + {!viewLoading && viewMode === 'config' && viewText !== null && ( + saveViewFile(s)} /> + )}
)} diff --git a/src/app/components/StatusPage.tsx b/src/app/components/StatusPage.tsx index b74287b..5d09b72 100644 --- a/src/app/components/StatusPage.tsx +++ b/src/app/components/StatusPage.tsx @@ -37,8 +37,8 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) { const mediaSetFiles: string[] | null = (() => { if (!activeDevice?.url) return null; - if (Array.isArray(activeDevice.mediaSet) && activeDevice.mediaSet.length > 0) - return activeDevice.mediaSet as string[]; + if (Array.isArray(activeDevice.media_set) && activeDevice.media_set.length > 0) + return activeDevice.media_set as string[]; const match = (activeDevice.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/); if (!match) return null; const [, prefix, , ext] = match;