Compare commits
No commits in common. "e2197c33fd3dd605f5612e487254ef07c403890e" and "e902cc4d4a4e61f61c14463e38f9e3b0060ce3de" have entirely different histories.
e2197c33fd
...
e902cc4d4a
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export default function DeviceDetailOverlay({
|
|||
}: DeviceDetailOverlayProps) {
|
||||
const [touchStart, setTouchStart] = 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 minSwipeDistance = 50;
|
||||
|
|
@ -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.media_set) && deviceData.media_set.length > 0) {
|
||||
return deviceData.media_set as string[];
|
||||
if (Array.isArray(deviceData.mediaSet) && deviceData.mediaSet.length > 0) {
|
||||
return deviceData.mediaSet 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.media_set = files;
|
||||
dev.mediaSet = 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.media_set;
|
||||
delete dev.mediaSet;
|
||||
setConfig(newConfig);
|
||||
}
|
||||
};
|
||||
|
|
@ -318,29 +318,7 @@ export default function DeviceDetailOverlay({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{deviceData.base_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>
|
||||
)}
|
||||
|
||||
{deviceData.url !== undefined && (
|
||||
<div>
|
||||
<label className="text-sm text-neutral-500 block mb-2">URL</label>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
|
|
@ -367,28 +345,6 @@ export default function DeviceDetailOverlay({
|
|||
</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 && (
|
||||
|
|
@ -439,23 +395,11 @@ export default function DeviceDetailOverlay({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{browsingField && (
|
||||
{showMediaBrowser && (
|
||||
<MediaBrowser
|
||||
currentPath={
|
||||
browsingField === 'cache' ? (deviceData.cache || '/') :
|
||||
browsingField === 'base_url' ? (deviceData.base_url || '/') :
|
||||
(deviceData.url || '/')
|
||||
}
|
||||
onSelect={(selectedPath: string) => {
|
||||
if (browsingField === 'url') {
|
||||
void handleFileSelect(selectedPath);
|
||||
} else {
|
||||
const devPath = getDevicePath();
|
||||
updateDeviceSetting([...devPath, browsingField], selectedPath);
|
||||
}
|
||||
setBrowsingField(null);
|
||||
}}
|
||||
onClose={() => setBrowsingField(null)}
|
||||
currentPath={deviceData.url || '/'}
|
||||
onSelect={(path) => { void handleFileSelect(path); setShowMediaBrowser(false); }}
|
||||
onClose={() => setShowMediaBrowser(false)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ interface Device {
|
|||
type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf';
|
||||
name?: string;
|
||||
enabled: boolean | number;
|
||||
base_url?: string;
|
||||
url?: string;
|
||||
mode?: number;
|
||||
}
|
||||
|
|
@ -60,7 +59,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
type: 'drive',
|
||||
name: `Drive ${key}`,
|
||||
enabled: value.enabled,
|
||||
base_url: value.base_url,
|
||||
url: value.url,
|
||||
mode: value.mode
|
||||
});
|
||||
|
|
@ -77,7 +75,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
type: 'network',
|
||||
name: `Network ${num}`,
|
||||
enabled: device.enabled,
|
||||
base_url: device.base_url,
|
||||
url: device.url
|
||||
});
|
||||
});
|
||||
|
|
@ -105,7 +102,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
type: 'meatloaf',
|
||||
name: `Meatloaf ${num}`,
|
||||
enabled: device.enabled,
|
||||
base_url: device.base_url,
|
||||
url: device.url,
|
||||
mode: device.mode
|
||||
});
|
||||
|
|
@ -277,10 +273,8 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
#{device.number}
|
||||
</span>
|
||||
</div>
|
||||
{(device.base_url || device.url) && (
|
||||
<div className="text-sm text-neutral-500 truncate mt-0.5">
|
||||
{[device.base_url, device.url].filter(Boolean).join('')}
|
||||
</div>
|
||||
{device.url && (
|
||||
<div className="text-sm text-neutral-500 truncate mt-0.5">{device.url}</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -1,30 +1,20 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Braces,
|
||||
CassetteTape,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Cpu,
|
||||
Disc,
|
||||
Save,
|
||||
Folder,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
HardDrive,
|
||||
Home,
|
||||
Image as ImageIcon,
|
||||
ChevronRight,
|
||||
Home,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
FolderPlus,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Music,
|
||||
Package,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Check,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
createFolder,
|
||||
|
|
@ -47,35 +37,15 @@ import {
|
|||
DialogDescription,
|
||||
} from './ui/dialog';
|
||||
|
||||
const TEXT_EXTS = new Set(['txt','cfg','ini','bas','asm','seq','rel','prg','log','csv','s','lst']);
|
||||
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 TEXT_EXTS = new Set(['txt','cfg','ini','bas','asm','seq','rel','prg','log','csv','s','lst','md','markdown','json','xml','svg','html','htm']);
|
||||
const IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','bmp','webp']);
|
||||
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','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']);
|
||||
const DISK_EXTS = new Set(['d64','d71','d81','d82','g64','g71','t64','tap','crt','nib']);
|
||||
|
||||
function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
|
||||
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 (DISK_EXTS.has(ext)) return <Save 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 (DISK_EXTS.has(ext)) return <HardDrive className="w-5 h-5 text-amber-500 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" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlignLeft,
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Braces,
|
||||
CassetteTape,
|
||||
Check,
|
||||
CheckSquare,
|
||||
ChevronLeft,
|
||||
|
|
@ -12,8 +11,6 @@ import {
|
|||
ClipboardPaste,
|
||||
Code2,
|
||||
Copy,
|
||||
Cpu,
|
||||
Disc,
|
||||
Download,
|
||||
Eye,
|
||||
File,
|
||||
|
|
@ -26,11 +23,9 @@ import {
|
|||
Home,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Menu,
|
||||
MoreVertical,
|
||||
Move,
|
||||
Music,
|
||||
Package,
|
||||
SlidersHorizontal,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Save,
|
||||
|
|
@ -47,7 +42,6 @@ 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,
|
||||
|
|
@ -75,7 +69,8 @@ import {
|
|||
|
||||
type SortKey = 'name' | 'size' | 'date';
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -84,19 +79,20 @@ 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 AUDIO_EXTS = new Set(['sid', 'psid', 'mus', 'vgm']);
|
||||
const ROM_EXTS = new Set(['bin', 'rom', 'crt']);
|
||||
const TAPE_EXTS = new Set(['tap', 'htap', 't64', 'tcrt']);
|
||||
const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', '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', 'bbt', 'd8b', 'dfi']);
|
||||
const CONFIG_EXTS = new Set(['config']);
|
||||
const DISK_EXTS = new Set(['d64', 'd71', 'd81', 'd82', 'g64', 'g71', 't64', 'tap', 'crt', 'nib']);
|
||||
|
||||
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 (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 (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';
|
||||
|
|
@ -108,7 +104,6 @@ function availableViewers(entry: EntryInfo): ViewMode[] {
|
|||
const def = defaultViewMode(entry);
|
||||
const map: Record<ViewMode, ViewMode[]> = {
|
||||
image: ['image', 'hex'],
|
||||
config: ['config', 'text', 'hex'],
|
||||
markdown: ['markdown', 'text', 'hex'],
|
||||
json: ['json', 'text', 'hex'],
|
||||
xml: ['xml', 'text', 'hex'],
|
||||
|
|
@ -119,7 +114,7 @@ function availableViewers(entry: EntryInfo): ViewMode[] {
|
|||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
|
@ -133,7 +128,6 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin
|
|||
case 'xml': return <Code2 className={cls} />;
|
||||
case 'hex': return <Hash 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 }) {
|
||||
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
|
||||
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 (DISK_EXTS.has(ext)) return <Save 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" />;
|
||||
const cat = fileCategory(entry);
|
||||
if (cat === 'image') return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
|
||||
if (cat === 'disk') return <HardDrive className="w-5 h-5 text-amber-500 flex-shrink-0" />;
|
||||
if (cat === 'text') 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" />;
|
||||
}
|
||||
|
||||
// ─── ActionsModal ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface FolderManagementActions {
|
||||
onMountFolder: () => void;
|
||||
onNewFolder: () => void;
|
||||
onNewFile: () => void;
|
||||
onUpload: () => void;
|
||||
|
|
@ -311,11 +294,6 @@ function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onRename, o
|
|||
{/* Folder management items — current-folder context (header Actions) */}
|
||||
{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(); }}
|
||||
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>
|
||||
|
|
@ -531,29 +509,6 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
|||
useEffect(() => { localStorage.setItem('fileManager.sortKey', sortKey); }, [sortKey]);
|
||||
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) => {
|
||||
let norm = normalizePath(p);
|
||||
if (rootPath && !norm.startsWith(rootPath)) norm = rootPath;
|
||||
|
|
@ -721,8 +676,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
|||
try {
|
||||
await movePath(renameEntry.path, dest);
|
||||
toast.success(`Renamed to "${newName}"`);
|
||||
if (renameEntry.path === path) navigateTo(dest);
|
||||
else void load(path);
|
||||
void load(path);
|
||||
} catch (e: any) { toast.error(`Rename failed: ${e?.message ?? e}`); }
|
||||
finally { setRenameEntry(null); }
|
||||
};
|
||||
|
|
@ -751,28 +705,23 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
|||
return;
|
||||
}
|
||||
dev.url = files[0];
|
||||
dev.media_set = files;
|
||||
dev.mediaSet = files;
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
dev.url = mountEntry.path;
|
||||
delete dev.media_set;
|
||||
delete dev.mediaSet;
|
||||
}
|
||||
|
||||
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);
|
||||
setMountEntry(null);
|
||||
const deviceId = `${deviceType}-${key}`;
|
||||
const isLst = mountEntry.name.toLowerCase().endsWith('.lst');
|
||||
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}`;
|
||||
toast.success(label, {
|
||||
action: onNavigateToDevice
|
||||
|
|
@ -875,6 +824,22 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
|||
const pathParts = path.split('/').filter(Boolean);
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -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">
|
||||
<Home className="w-4 h-4" />
|
||||
</button>
|
||||
{pathParts.map((part, i) => {
|
||||
const isLast = i === pathParts.length - 1;
|
||||
const isEditing = isLast && renameEntry !== null && renameEntry.path === path;
|
||||
return (
|
||||
{pathParts.map((part, i) => (
|
||||
<div key={i} className="flex items-center gap-1 flex-shrink-0">
|
||||
<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
|
||||
onClick={() => navigateTo('/' + pathParts.slice(0, i + 1).join('/'))}
|
||||
className="hover:text-blue-600 hover:underline max-w-[120px] truncate"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(folderConfig?.['base_url']) && (
|
||||
<div className="text-xs text-neutral-400 mt-0.5 truncate">
|
||||
Base: {folderConfig?.['base_url']}
|
||||
))}
|
||||
</div>
|
||||
{deviceBaseUrl && (
|
||||
<div className="text-xs text-neutral-400 mt-0.5 truncate">Base: {deviceBaseUrl}</div>
|
||||
)}
|
||||
|
||||
{showNewFile && (
|
||||
|
|
@ -1200,7 +1143,6 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
|||
onCut={e => cutOrCopyEntry(e, 'move')}
|
||||
onDelete={e => void deleteEntry(e)}
|
||||
folderManagement={folderActionOpen ? {
|
||||
onMountFolder: () => setMountEntry({ name: splitPath(path).name || '/', path, type: 'folder', size: 0, lastModified: null, contentType: null }),
|
||||
onNewFolder: () => { setShowNewFolder(true); setShowNewFile(false); },
|
||||
onNewFile: () => { setShowNewFile(true); setShowNewFolder(false); },
|
||||
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 && (
|
||||
<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>
|
||||
)}
|
||||
|
|
@ -1281,9 +1220,9 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
|||
{(() => {
|
||||
const drives = Object.entries(config?.iec?.devices?.drive ?? {})
|
||||
.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 ?? {})
|
||||
.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];
|
||||
if (!devices.length)
|
||||
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'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm">Device #{dev.key}</div>
|
||||
{(dev.base_url || dev.url) && (
|
||||
<div className="text-xs text-neutral-500 truncate">
|
||||
{[dev.base_url, dev.url].filter(Boolean).join('')}
|
||||
</div>
|
||||
)}
|
||||
{dev.url && <div className="text-xs text-neutral-500 truncate">{dev.url}</div>}
|
||||
</div>
|
||||
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
|||
|
||||
const mediaSetFiles: string[] | null = (() => {
|
||||
if (!activeDevice?.url) return null;
|
||||
if (Array.isArray(activeDevice.media_set) && activeDevice.media_set.length > 0)
|
||||
return activeDevice.media_set as string[];
|
||||
if (Array.isArray(activeDevice.mediaSet) && activeDevice.mediaSet.length > 0)
|
||||
return activeDevice.mediaSet as string[];
|
||||
const match = (activeDevice.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/);
|
||||
if (!match) return null;
|
||||
const [, prefix, , ext] = match;
|
||||
|
|
@ -146,9 +146,7 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
|||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Device #{activeDevice.number}</div>
|
||||
<div className="text-sm text-neutral-500">
|
||||
{[activeDevice.base_url, activeDevice.url].filter(Boolean).join('') || '—'}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500">{activeDevice.url}</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user