Compare commits

..

No commits in common. "e2197c33fd3dd605f5612e487254ef07c403890e" and "e902cc4d4a4e61f61c14463e38f9e3b0060ce3de" have entirely different histories.

6 changed files with 108 additions and 367 deletions

View File

@ -1,100 +0,0 @@
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>
);
}

View File

@ -37,7 +37,7 @@ export default function DeviceDetailOverlay({
}: DeviceDetailOverlayProps) { }: DeviceDetailOverlayProps) {
const [touchStart, setTouchStart] = useState(0); const [touchStart, setTouchStart] = useState(0);
const [touchEnd, setTouchEnd] = useState(0); const [touchEnd, setTouchEnd] = useState(0);
const [browsingField, setBrowsingField] = useState<'url' | 'base_url' | 'cache' | null>(null); const [showMediaBrowser, setShowMediaBrowser] = useState(false);
const [showCommandMenu, setShowCommandMenu] = useState(false); const [showCommandMenu, setShowCommandMenu] = useState(false);
const minSwipeDistance = 50; const minSwipeDistance = 50;
@ -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.media_set) && deviceData.media_set.length > 0) { if (Array.isArray(deviceData.mediaSet) && deviceData.mediaSet.length > 0) {
return deviceData.media_set as string[]; return deviceData.mediaSet 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.media_set = files; dev.mediaSet = 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.media_set; delete dev.mediaSet;
setConfig(newConfig); setConfig(newConfig);
} }
}; };
@ -318,29 +318,7 @@ export default function DeviceDetailOverlay({
</button> </button>
</div> </div>
{deviceData.base_url !== undefined && ( {deviceData.url !== undefined && (
<div>
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
<div className="flex gap-2">
<input
type="text"
value={deviceData.base_url ?? ''}
onChange={(e) => {
const path = getDevicePath();
updateDeviceSetting([...path, 'base_url'], e.target.value);
}}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/>
<button
onClick={() => setBrowsingField('base_url')}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
>
<FolderOpen className="w-5 h-5" />
</button>
</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">
@ -354,7 +332,7 @@ export default function DeviceDetailOverlay({
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg" className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/> />
<button <button
onClick={() => setBrowsingField('url')} onClick={() => setShowMediaBrowser(true)}
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"
> >
<FolderOpen className="w-5 h-5" /> <FolderOpen className="w-5 h-5" />
@ -367,28 +345,6 @@ export default function DeviceDetailOverlay({
</div> </div>
)} )}
</div> </div>
{deviceData.cache !== undefined && (
<div>
<label className="text-sm text-neutral-500 block mb-2">Cache</label>
<div className="flex gap-2">
<input
type="text"
value={deviceData.cache ?? ''}
onChange={(e) => {
const path = getDevicePath();
updateDeviceSetting([...path, 'cache'], e.target.value);
}}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/>
<button
onClick={() => setBrowsingField('cache')}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
>
<FolderOpen className="w-5 h-5" />
</button>
</div>
</div>
)} )}
{deviceData.mode !== undefined && ( {deviceData.mode !== undefined && (
@ -439,23 +395,11 @@ export default function DeviceDetailOverlay({
</div> </div>
</div> </div>
{browsingField && ( {showMediaBrowser && (
<MediaBrowser <MediaBrowser
currentPath={ currentPath={deviceData.url || '/'}
browsingField === 'cache' ? (deviceData.cache || '/') : onSelect={(path) => { void handleFileSelect(path); setShowMediaBrowser(false); }}
browsingField === 'base_url' ? (deviceData.base_url || '/') : onClose={() => setShowMediaBrowser(false)}
(deviceData.url || '/')
}
onSelect={(selectedPath: string) => {
if (browsingField === 'url') {
void handleFileSelect(selectedPath);
} else {
const devPath = getDevicePath();
updateDeviceSetting([...devPath, browsingField], selectedPath);
}
setBrowsingField(null);
}}
onClose={() => setBrowsingField(null)}
/> />
)} )}
</motion.div> </motion.div>

View File

@ -9,7 +9,6 @@ interface Device {
type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf'; type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf';
name?: string; name?: string;
enabled: boolean | number; enabled: boolean | number;
base_url?: string;
url?: string; url?: string;
mode?: number; mode?: number;
} }
@ -60,7 +59,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
type: 'drive', type: 'drive',
name: `Drive ${key}`, name: `Drive ${key}`,
enabled: value.enabled, enabled: value.enabled,
base_url: value.base_url,
url: value.url, url: value.url,
mode: value.mode mode: value.mode
}); });
@ -77,7 +75,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
type: 'network', type: 'network',
name: `Network ${num}`, name: `Network ${num}`,
enabled: device.enabled, enabled: device.enabled,
base_url: device.base_url,
url: device.url url: device.url
}); });
}); });
@ -105,7 +102,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
type: 'meatloaf', type: 'meatloaf',
name: `Meatloaf ${num}`, name: `Meatloaf ${num}`,
enabled: device.enabled, enabled: device.enabled,
base_url: device.base_url,
url: device.url, url: device.url,
mode: device.mode mode: device.mode
}); });
@ -277,10 +273,8 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
#{device.number} #{device.number}
</span> </span>
</div> </div>
{(device.base_url || device.url) && ( {device.url && (
<div className="text-sm text-neutral-500 truncate mt-0.5"> <div className="text-sm text-neutral-500 truncate mt-0.5">{device.url}</div>
{[device.base_url, device.url].filter(Boolean).join('')}
</div>
)} )}
</button> </button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@ -1,30 +1,20 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
ArrowLeft, Folder,
BookOpen,
Braces,
CassetteTape,
Check,
ChevronRight,
Code2,
Cpu,
Disc,
Save,
File, File,
FileText, FileText,
Folder,
FolderPlus,
HardDrive, HardDrive,
Home,
Image as ImageIcon, Image as ImageIcon,
ChevronRight,
Home,
RefreshCw,
Upload,
FolderPlus,
ArrowLeft,
Loader2, Loader2,
MoreVertical, MoreVertical,
Music, Check,
Package,
RefreshCw,
SlidersHorizontal,
Trash2, Trash2,
Upload,
} from 'lucide-react'; } from 'lucide-react';
import { import {
createFolder, createFolder,
@ -47,35 +37,15 @@ import {
DialogDescription, DialogDescription,
} from './ui/dialog'; } from './ui/dialog';
const TEXT_EXTS = new Set(['txt','cfg','ini','bas','asm','seq','rel','prg','log','csv','s','lst']); const TEXT_EXTS = new Set(['txt','cfg','ini','bas','asm','seq','rel','prg','log','csv','s','lst','md','markdown','json','xml','svg','html','htm']);
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 IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','bmp','webp']);
const AUDIO_EXTS = new Set(['sid','psid','mus','vgm']); const DISK_EXTS = new Set(['d64','d71','d81','d82','g64','g71','t64','tap','crt','nib']);
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','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','d90','dhd','hdd']);
const ARCHIVE_EXTS = new Set(['zip','7z','tar','gz','bz2','xz','rar','arj','lzh','ace','z','lha','cab','lbr','arc','ark','lnx']);
const CONFIG_EXTS = new Set(['config']);
function EntryIcon({ entry }: { entry: EntryInfo }) { function EntryIcon({ entry }: { entry: EntryInfo }) {
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />; if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
if (IMAGE_EXTS.has(ext)) return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />; if (IMAGE_EXTS.has(ext)) return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
if (DISK_EXTS.has(ext)) return <Save className="w-5 h-5 text-amber-500 flex-shrink-0" />; if (DISK_EXTS.has(ext)) return <HardDrive className="w-5 h-5 text-amber-500 flex-shrink-0" />;
if (HD_EXTS.has(ext)) return <HardDrive className="w-5 h-5 text-orange-500 flex-shrink-0" />;
if (DISC_EXTS.has(ext)) return <Disc className="w-5 h-5 text-sky-500 flex-shrink-0" />;
if (TAPE_EXTS.has(ext)) return <CassetteTape className="w-5 h-5 text-rose-400 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 (ARCHIVE_EXTS.has(ext)) return <Package className="w-5 h-5 text-yellow-600 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 (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
if (MD_EXTS.has(ext)) return <BookOpen className="w-5 h-5 text-sky-400 flex-shrink-0" />;
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />; if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />; return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
} }

View File

@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AlignLeft, AlignLeft,
ArrowLeft, ArrowLeft,
BookOpen, BookOpen,
Braces, Braces,
CassetteTape,
Check, Check,
CheckSquare, CheckSquare,
ChevronLeft, ChevronLeft,
@ -12,8 +11,6 @@ import {
ClipboardPaste, ClipboardPaste,
Code2, Code2,
Copy, Copy,
Cpu,
Disc,
Download, Download,
Eye, Eye,
File, File,
@ -26,11 +23,9 @@ import {
Home, Home,
Image as ImageIcon, Image as ImageIcon,
Loader2, Loader2,
Menu,
MoreVertical, MoreVertical,
Move, Move,
Music,
Package,
SlidersHorizontal,
Pencil, Pencil,
RefreshCw, RefreshCw,
Save, Save,
@ -47,7 +42,6 @@ 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,
@ -75,7 +69,8 @@ import {
type SortKey = 'name' | 'size' | 'date'; type SortKey = 'name' | 'size' | 'date';
type Clipboard = { op: 'copy' | 'move'; paths: string[] }; type Clipboard = { op: 'copy' | 'move'; paths: string[] };
type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config'; type FileCategory = 'text' | 'image' | 'disk' | 'binary';
type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image';
// ─── Extension sets ────────────────────────────────────────────────────────── // ─── Extension sets ──────────────────────────────────────────────────────────
@ -84,19 +79,20 @@ 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 AUDIO_EXTS = new Set(['sid', 'psid', 'mus', 'vgm']); const DISK_EXTS = new Set(['d64', 'd71', 'd81', 'd82', 'g64', 'g71', 't64', 'tap', 'crt', 'nib']);
const ROM_EXTS = new Set(['bin', 'rom', 'crt']);
const TAPE_EXTS = new Set(['tap', 'htap', 't64', 'tcrt']); function fileCategory(entry: EntryInfo): FileCategory {
const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', 'g64', 'g71', 'g81', 'p64', 'p71', 'p81', 'nib']); if (entry.type === 'folder') return 'binary';
const DISC_EXTS = new Set(['iso', 'img', 'cue']); const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd']); if (IMAGE_EXTS.has(ext)) return 'image';
const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx', 'bbt', 'd8b', 'dfi']); if (DISK_EXTS.has(ext)) return 'disk';
const CONFIG_EXTS = new Set(['config']); 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 { 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';
@ -108,7 +104,6 @@ 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'],
@ -119,7 +114,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', config: 'Config', text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image',
}; };
// ─── Viewer components ─────────────────────────────────────────────────────── // ─── Viewer components ───────────────────────────────────────────────────────
@ -133,7 +128,6 @@ 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} />;
} }
} }
@ -247,27 +241,16 @@ function MarkdownEditor({ text, onSave }: { text: string; onSave?: (s: string) =
function EntryIcon({ entry }: { entry: EntryInfo }) { function EntryIcon({ entry }: { entry: EntryInfo }) {
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />; if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; const cat = fileCategory(entry);
if (IMAGE_EXTS.has(ext)) return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />; if (cat === 'image') return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
if (DISK_EXTS.has(ext)) return <Save className="w-5 h-5 text-amber-500 flex-shrink-0" />; if (cat === 'disk') return <HardDrive className="w-5 h-5 text-amber-500 flex-shrink-0" />;
if (HD_EXTS.has(ext)) return <HardDrive className="w-5 h-5 text-orange-500 flex-shrink-0" />; if (cat === 'text') return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
if (DISC_EXTS.has(ext)) return <Disc className="w-5 h-5 text-sky-500 flex-shrink-0" />;
if (TAPE_EXTS.has(ext)) return <CassetteTape className="w-5 h-5 text-rose-400 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 (ARCHIVE_EXTS.has(ext)) return <Package className="w-5 h-5 text-yellow-600 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 (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
if (MD_EXTS.has(ext)) return <BookOpen className="w-5 h-5 text-sky-400 flex-shrink-0" />;
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />; return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
} }
// ─── ActionsModal ───────────────────────────────────────────────────────────── // ─── ActionsModal ─────────────────────────────────────────────────────────────
interface FolderManagementActions { interface FolderManagementActions {
onMountFolder: () => void;
onNewFolder: () => void; onNewFolder: () => void;
onNewFile: () => void; onNewFile: () => void;
onUpload: () => void; onUpload: () => void;
@ -311,11 +294,6 @@ function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onRename, o
{/* Folder management items — current-folder context (header Actions) */} {/* Folder management items — current-folder context (header Actions) */}
{fm && ( {fm && (
<> <>
<button onClick={() => { onClose(); fm.onMountFolder(); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3">
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount Folder</span>
</button>
<div className="border-t border-neutral-100" />
<button onClick={() => { onClose(); fm.onNewFolder(); }} <button onClick={() => { onClose(); fm.onNewFolder(); }}
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">
<FolderPlus className="w-4 h-4 text-neutral-500" /> <span>New Folder</span> <FolderPlus className="w-4 h-4 text-neutral-500" /> <span>New Folder</span>
@ -531,29 +509,6 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
useEffect(() => { localStorage.setItem('fileManager.sortKey', sortKey); }, [sortKey]); useEffect(() => { localStorage.setItem('fileManager.sortKey', sortKey); }, [sortKey]);
useEffect(() => { localStorage.setItem('fileManager.sortAsc', String(sortAsc)); }, [sortAsc]); useEffect(() => { localStorage.setItem('fileManager.sortAsc', String(sortAsc)); }, [sortAsc]);
// ── Folder config (.config) ──────────────────────────────────────────────
const [folderConfig, setFolderConfig] = useState<Record<string, string> | null>(null);
useEffect(() => {
let cancelled = false;
getFileContents(joinPath(path, '.config'))
.then(async blob => {
if (cancelled) return;
const cfg: Record<string, string> = {};
for (const line of (await blob.text()).split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
cfg[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
}
setFolderConfig(cfg);
})
.catch(() => { if (!cancelled) setFolderConfig(null); });
return () => { cancelled = true; };
}, [path]);
const navigateTo = (p: string) => { const navigateTo = (p: string) => {
let norm = normalizePath(p); let norm = normalizePath(p);
if (rootPath && !norm.startsWith(rootPath)) norm = rootPath; if (rootPath && !norm.startsWith(rootPath)) norm = rootPath;
@ -721,8 +676,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
try { try {
await movePath(renameEntry.path, dest); await movePath(renameEntry.path, dest);
toast.success(`Renamed to "${newName}"`); toast.success(`Renamed to "${newName}"`);
if (renameEntry.path === path) navigateTo(dest); void load(path);
else void load(path);
} catch (e: any) { toast.error(`Rename failed: ${e?.message ?? e}`); } } catch (e: any) { toast.error(`Rename failed: ${e?.message ?? e}`); }
finally { setRenameEntry(null); } finally { setRenameEntry(null); }
}; };
@ -751,28 +705,23 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
return; return;
} }
dev.url = files[0]; dev.url = files[0];
dev.media_set = files; dev.mediaSet = 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.media_set; delete dev.mediaSet;
} }
if (!dev.enabled) dev.enabled = 1; if (!dev.enabled) dev.enabled = 1;
if (folderConfig?.['base_url']) {
dev.base_url = folderConfig['base_url'];
delete dev.url;
}
if (folderConfig?.['cache'] === '.') dev.cache = path;
setConfig(newConfig); setConfig(newConfig);
setMountEntry(null); setMountEntry(null);
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.media_set as string[]).length} disks) on ${deviceType} #${key}` ? `Loaded swap list "${mountEntry.name}" (${(dev.mediaSet 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
@ -875,6 +824,22 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
const pathParts = path.split('/').filter(Boolean); const pathParts = path.split('/').filter(Boolean);
const selCount = selected.size; const selCount = selected.size;
const deviceBaseUrl = useMemo(() => {
if (!config?.iec?.devices) return null;
const groups = ['drive', 'meatloaf', 'printer', 'network', 'other'];
for (const t of groups) {
for (const dev of Object.values(config.iec.devices[t] ?? {}) as any[]) {
if (dev?.base_url && (path.startsWith(dev.base_url) || dev.base_url === rootPath))
return dev.base_url as string;
}
}
for (const t of groups) {
for (const dev of Object.values(config.iec.devices[t] ?? {}) as any[]) {
if (dev?.base_url) return dev.base_url as string;
}
}
return null;
}, [config, path, rootPath]);
// ── Render ─────────────────────────────────────────────────────────────── // ── Render ───────────────────────────────────────────────────────────────
@ -918,42 +883,20 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
<button onClick={() => navigateTo(rootPath ?? '/')} className="p-1 rounded hover:bg-neutral-100 flex-shrink-0" title="Root"> <button onClick={() => navigateTo(rootPath ?? '/')} className="p-1 rounded hover:bg-neutral-100 flex-shrink-0" title="Root">
<Home className="w-4 h-4" /> <Home className="w-4 h-4" />
</button> </button>
{pathParts.map((part, i) => { {pathParts.map((part, i) => (
const isLast = i === pathParts.length - 1;
const isEditing = isLast && renameEntry !== null && renameEntry.path === path;
return (
<div key={i} className="flex items-center gap-1 flex-shrink-0"> <div key={i} className="flex items-center gap-1 flex-shrink-0">
<ChevronRight className="w-3 h-3 text-neutral-400" /> <ChevronRight className="w-3 h-3 text-neutral-400" />
{isEditing ? (
<input
ref={renameInputRef}
value={renameName}
onChange={e => setRenameName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void commitRename();
if (e.key === 'Escape') setRenameEntry(null);
}}
onBlur={() => void commitRename()}
onFocus={e => e.currentTarget.select()}
className="px-1 py-0 border border-blue-400 rounded text-sm max-w-[120px] min-w-[60px]"
autoFocus
/>
) : (
<button <button
onClick={() => navigateTo('/' + pathParts.slice(0, i + 1).join('/'))} onClick={() => navigateTo('/' + pathParts.slice(0, i + 1).join('/'))}
className="hover:text-blue-600 hover:underline max-w-[120px] truncate" className="hover:text-blue-600 hover:underline max-w-[120px] truncate"
> >
{part} {part}
</button> </button>
)}
</div> </div>
); ))}
})}
</div>
{(folderConfig?.['base_url']) && (
<div className="text-xs text-neutral-400 mt-0.5 truncate">
Base: {folderConfig?.['base_url']}
</div> </div>
{deviceBaseUrl && (
<div className="text-xs text-neutral-400 mt-0.5 truncate">Base: {deviceBaseUrl}</div>
)} )}
{showNewFile && ( {showNewFile && (
@ -1200,7 +1143,6 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
onCut={e => cutOrCopyEntry(e, 'move')} onCut={e => cutOrCopyEntry(e, 'move')}
onDelete={e => void deleteEntry(e)} onDelete={e => void deleteEntry(e)}
folderManagement={folderActionOpen ? { folderManagement={folderActionOpen ? {
onMountFolder: () => setMountEntry({ name: splitPath(path).name || '/', path, type: 'folder', size: 0, lastModified: null, contentType: null }),
onNewFolder: () => { setShowNewFolder(true); setShowNewFile(false); }, onNewFolder: () => { setShowNewFolder(true); setShowNewFile(false); },
onNewFile: () => { setShowNewFile(true); setShowNewFolder(false); }, onNewFile: () => { setShowNewFile(true); setShowNewFolder(false); },
onUpload: () => fileInputRef.current?.click(), onUpload: () => fileInputRef.current?.click(),
@ -1264,9 +1206,6 @@ 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>
)} )}
@ -1281,9 +1220,9 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
{(() => { {(() => {
const drives = Object.entries(config?.iec?.devices?.drive ?? {}) const drives = Object.entries(config?.iec?.devices?.drive ?? {})
.filter(([k]) => k !== 'vdrive' && k !== 'rom') .filter(([k]) => k !== 'vdrive' && k !== 'rom')
.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 })); .map(([k, v]: [string, any]) => ({ type: 'drive' as const, key: k, url: v?.url as string | undefined, enabled: !!v?.enabled }));
const meatloafs = Object.entries(config?.iec?.devices?.meatloaf ?? {}) const meatloafs = Object.entries(config?.iec?.devices?.meatloaf ?? {})
.map(([k, v]: [string, any]) => ({ type: 'meatloaf' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled })); .map(([k, v]: [string, any]) => ({ type: 'meatloaf' as const, key: k, url: v?.url as string | undefined, enabled: !!v?.enabled }));
const devices = [...drives, ...meatloafs]; const devices = [...drives, ...meatloafs];
if (!devices.length) if (!devices.length)
return <p className="text-sm text-neutral-500 text-center py-4">No drive devices found in config.</p>; return <p className="text-sm text-neutral-500 text-center py-4">No drive devices found in config.</p>;
@ -1298,11 +1237,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} /> <HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium text-sm">Device #{dev.key}</div> <div className="font-medium text-sm">Device #{dev.key}</div>
{(dev.base_url || dev.url) && ( {dev.url && <div className="text-xs text-neutral-500 truncate">{dev.url}</div>}
<div className="text-xs text-neutral-500 truncate">
{[dev.base_url, dev.url].filter(Boolean).join('')}
</div>
)}
</div> </div>
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>} {!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
</button> </button>

View File

@ -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.media_set) && activeDevice.media_set.length > 0) if (Array.isArray(activeDevice.mediaSet) && activeDevice.mediaSet.length > 0)
return activeDevice.media_set as string[]; return activeDevice.mediaSet 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;
@ -146,9 +146,7 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
</div> </div>
<div> <div>
<div className="font-medium">Device #{activeDevice.number}</div> <div className="font-medium">Device #{activeDevice.number}</div>
<div className="text-sm text-neutral-500"> <div className="text-sm text-neutral-500">{activeDevice.url}</div>
{[activeDevice.base_url, activeDevice.url].filter(Boolean).join('') || '—'}
</div>
</div> </div>
</button> </button>
<div className="w-2 h-2 rounded-full bg-green-500" /> <div className="w-2 h-2 rounded-full bg-green-500" />