feat(ConfigEditor, MediaManager, DeviceDetailOverlay, StatusPage): implement config editing functionality and update media set handling
This commit is contained in:
parent
df02223d42
commit
b70c98d69a
100
src/app/components/ConfigEditor.tsx
Normal file
100
src/app/components/ConfigEditor.tsx
Normal file
|
|
@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfigEditor({ text, onSave }: ConfigEditorProps) {
|
||||||
|
const [pairs, setPairs] = useState<Pair[]>(() => 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 (
|
||||||
|
<div className="flex flex-col h-full bg-neutral-950">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-neutral-900 border-b border-neutral-700 flex-shrink-0 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={addRow}
|
||||||
|
className="px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Add
|
||||||
|
</button>
|
||||||
|
{onSave && (
|
||||||
|
<button
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{pairs.length === 0 && (
|
||||||
|
<div className="text-neutral-500 text-sm text-center py-12">
|
||||||
|
No entries — click Add to create one
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pairs.map(pair => (
|
||||||
|
<div key={pair.id} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={pair.key}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-neutral-500 flex-shrink-0">=</span>
|
||||||
|
<input
|
||||||
|
value={pair.value}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteRow(pair.id)}
|
||||||
|
className="flex-shrink-0 p-1.5 rounded hover:bg-neutral-700 text-neutral-500 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -148,8 +148,8 @@ export default function DeviceDetailOverlay({
|
||||||
|
|
||||||
// Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection.
|
// Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection.
|
||||||
const mediaSetFiles: string[] | null = (() => {
|
const mediaSetFiles: string[] | null = (() => {
|
||||||
if (Array.isArray(deviceData.mediaSet) && deviceData.mediaSet.length > 0) {
|
if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 0) {
|
||||||
return deviceData.mediaSet as string[];
|
return deviceData.media_set as string[];
|
||||||
}
|
}
|
||||||
const detected = detectMediaSet();
|
const detected = detectMediaSet();
|
||||||
return detected ? detected.files : null;
|
return detected ? detected.files : null;
|
||||||
|
|
@ -175,7 +175,7 @@ export default function DeviceDetailOverlay({
|
||||||
let dev = newConfig;
|
let dev = newConfig;
|
||||||
for (const k of devicePath) dev = dev[k];
|
for (const k of devicePath) dev = dev[k];
|
||||||
dev.url = files[0];
|
dev.url = files[0];
|
||||||
dev.mediaSet = files;
|
dev.media_set = files;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to read swap list: ${e?.message ?? e}`);
|
toast.error(`Failed to read swap list: ${e?.message ?? e}`);
|
||||||
|
|
@ -185,7 +185,7 @@ export default function DeviceDetailOverlay({
|
||||||
let dev = newConfig;
|
let dev = newConfig;
|
||||||
for (const k of devicePath) dev = dev[k];
|
for (const k of devicePath) dev = dev[k];
|
||||||
dev.url = selectedPath;
|
dev.url = selectedPath;
|
||||||
delete dev.mediaSet;
|
delete dev.media_set;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Move,
|
Move,
|
||||||
|
SlidersHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
|
|
@ -41,6 +42,7 @@ import CodeMirror, { EditorView } from '@uiw/react-codemirror';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
import HexEditor from './HexEditor';
|
import HexEditor from './HexEditor';
|
||||||
import CodeEditor from './CodeEditor';
|
import CodeEditor from './CodeEditor';
|
||||||
|
import ConfigEditor from './ConfigEditor';
|
||||||
import {
|
import {
|
||||||
copyPath,
|
copyPath,
|
||||||
createFolder,
|
createFolder,
|
||||||
|
|
@ -69,7 +71,7 @@ import {
|
||||||
type SortKey = 'name' | 'size' | 'date';
|
type SortKey = 'name' | 'size' | 'date';
|
||||||
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
|
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
|
||||||
type FileCategory = 'text' | 'image' | 'disk' | 'binary';
|
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 ──────────────────────────────────────────────────────────
|
// ─── Extension sets ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -78,13 +80,27 @@ const MD_EXTS = new Set(['md', 'markdown']);
|
||||||
const JSON_EXTS = new Set(['json']);
|
const JSON_EXTS = new Set(['json']);
|
||||||
const XML_EXTS = new Set(['xml', 'svg', 'html', 'htm', 'rss', 'atom', 'xsl']);
|
const XML_EXTS = new Set(['xml', 'svg', 'html', 'htm', 'rss', 'atom', 'xsl']);
|
||||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']);
|
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 {
|
function fileCategory(entry: EntryInfo): FileCategory {
|
||||||
if (entry.type === 'folder') return 'binary';
|
if (entry.type === 'folder') return 'binary';
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
if (IMAGE_EXTS.has(ext)) return 'image';
|
if (IMAGE_EXTS.has(ext)) return 'image';
|
||||||
if (DISK_EXTS.has(ext)) return 'disk';
|
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';
|
if (TEXT_EXTS.has(ext) || MD_EXTS.has(ext) || JSON_EXTS.has(ext) || XML_EXTS.has(ext)) return 'text';
|
||||||
return 'binary';
|
return 'binary';
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +108,7 @@ function fileCategory(entry: EntryInfo): FileCategory {
|
||||||
function defaultViewMode(entry: EntryInfo): ViewMode {
|
function defaultViewMode(entry: EntryInfo): ViewMode {
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
if (IMAGE_EXTS.has(ext)) return 'image';
|
if (IMAGE_EXTS.has(ext)) return 'image';
|
||||||
|
if (CONFIG_EXTS.has(ext)) return 'config';
|
||||||
if (MD_EXTS.has(ext)) return 'markdown';
|
if (MD_EXTS.has(ext)) return 'markdown';
|
||||||
if (JSON_EXTS.has(ext)) return 'json';
|
if (JSON_EXTS.has(ext)) return 'json';
|
||||||
if (XML_EXTS.has(ext)) return 'xml';
|
if (XML_EXTS.has(ext)) return 'xml';
|
||||||
|
|
@ -103,6 +120,7 @@ function availableViewers(entry: EntryInfo): ViewMode[] {
|
||||||
const def = defaultViewMode(entry);
|
const def = defaultViewMode(entry);
|
||||||
const map: Record<ViewMode, ViewMode[]> = {
|
const map: Record<ViewMode, ViewMode[]> = {
|
||||||
image: ['image', 'hex'],
|
image: ['image', 'hex'],
|
||||||
|
config: ['config', 'text', 'hex'],
|
||||||
markdown: ['markdown', 'text', 'hex'],
|
markdown: ['markdown', 'text', 'hex'],
|
||||||
json: ['json', 'text', 'hex'],
|
json: ['json', 'text', 'hex'],
|
||||||
xml: ['xml', 'text', 'hex'],
|
xml: ['xml', 'text', 'hex'],
|
||||||
|
|
@ -113,7 +131,7 @@ function availableViewers(entry: EntryInfo): ViewMode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIEWER_LABEL: Record<ViewMode, string> = {
|
const VIEWER_LABEL: Record<ViewMode, string> = {
|
||||||
text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image',
|
text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image', config: 'Config',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Viewer components ───────────────────────────────────────────────────────
|
// ─── Viewer components ───────────────────────────────────────────────────────
|
||||||
|
|
@ -127,6 +145,7 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin
|
||||||
case 'xml': return <Code2 className={cls} />;
|
case 'xml': return <Code2 className={cls} />;
|
||||||
case 'hex': return <Hash className={cls} />;
|
case 'hex': return <Hash className={cls} />;
|
||||||
case 'image': return <ImageIcon className={cls} />;
|
case 'image': return <ImageIcon className={cls} />;
|
||||||
|
case 'config': return <SlidersHorizontal className={cls} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -734,14 +753,14 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dev.url = files[0];
|
dev.url = files[0];
|
||||||
dev.mediaSet = 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}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dev.url = mountEntry.path;
|
dev.url = mountEntry.path;
|
||||||
delete dev.mediaSet;
|
delete dev.media_set;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dev.enabled) dev.enabled = 1;
|
if (!dev.enabled) dev.enabled = 1;
|
||||||
|
|
@ -755,7 +774,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
const deviceId = `${deviceType}-${key}`;
|
const deviceId = `${deviceType}-${key}`;
|
||||||
const isLst = mountEntry.name.toLowerCase().endsWith('.lst');
|
const isLst = mountEntry.name.toLowerCase().endsWith('.lst');
|
||||||
const label = isLst
|
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}`;
|
: `Mounted "${mountEntry.name}" on ${deviceType} #${key}`;
|
||||||
toast.success(label, {
|
toast.success(label, {
|
||||||
action: onNavigateToDevice
|
action: onNavigateToDevice
|
||||||
|
|
@ -1247,6 +1266,9 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
{!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && (
|
{!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && (
|
||||||
<CodeEditor key={viewEntry.path} text={viewText} mode={viewMode} onSave={s => saveViewFile(s)} />
|
<CodeEditor key={viewEntry.path} text={viewText} mode={viewMode} onSave={s => saveViewFile(s)} />
|
||||||
)}
|
)}
|
||||||
|
{!viewLoading && viewMode === 'config' && viewText !== null && (
|
||||||
|
<ConfigEditor key={viewEntry.path} text={viewText} onSave={s => saveViewFile(s)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,8 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
|
|
||||||
const mediaSetFiles: string[] | null = (() => {
|
const mediaSetFiles: string[] | null = (() => {
|
||||||
if (!activeDevice?.url) return null;
|
if (!activeDevice?.url) return null;
|
||||||
if (Array.isArray(activeDevice.mediaSet) && activeDevice.mediaSet.length > 0)
|
if (Array.isArray(activeDevice.media_set) && activeDevice.media_set.length > 0)
|
||||||
return activeDevice.mediaSet as string[];
|
return activeDevice.media_set as string[];
|
||||||
const match = (activeDevice.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/);
|
const match = (activeDevice.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const [, prefix, , ext] = match;
|
const [, prefix, , ext] = match;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user