Compare commits

..

No commits in common. "e0ac2549c6f621b77dbe796d7535bfd66e80ee48" and "8d5de3f92fbfc9c03edd6e416d04726419c03056" have entirely different histories.

5 changed files with 442 additions and 364 deletions

View File

@ -1,20 +1,34 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
ArrowLeft, ArrowLeft,
BookOpen,
Braces,
CassetteTape,
Check, Check,
ChevronRight, ChevronRight,
Code2,
Cpu,
Disc,
Save,
File,
FileText,
Folder, Folder,
FolderPlus, FolderPlus,
HardDrive,
Home, Home,
Image as ImageIcon,
Loader2, Loader2,
MoreVertical,
Music,
Package,
RefreshCw, RefreshCw,
SlidersHorizontal,
Trash2, Trash2,
Upload, Upload,
} from 'lucide-react'; } from 'lucide-react';
import { import {
createFolder, createFolder,
deletePath, deletePath,
fileExists,
humanFileSize, humanFileSize,
joinPath, joinPath,
listDirectory, listDirectory,
@ -32,7 +46,39 @@ import {
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} from './ui/dialog'; } from './ui/dialog';
import { MediaEntry } from './MediaEntry';
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 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']);
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" />;
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
}
interface MediaBrowserProps { interface MediaBrowserProps {
currentPath: string; currentPath: string;
@ -41,6 +87,8 @@ interface MediaBrowserProps {
} }
export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBrowserProps) { export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBrowserProps) {
// Resolve the initial path: if `currentPath` is itself a file, jump
// to its parent so we never try to list a file as if it were a folder.
const [path, setPath] = useState<string | null>(null); const [path, setPath] = useState<string | null>(null);
const [entries, setEntries] = useState<EntryInfo[]>([]); const [entries, setEntries] = useState<EntryInfo[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -50,67 +98,130 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null); const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Resolve the initial path on mount.
useEffect(() => { useEffect(() => {
const initial = normalizePath(currentPath || '/'); const initial = normalizePath(currentPath || '/');
if (initial === '/') { setPath('/'); return; } if (initial === '/') {
setPath('/');
return;
}
let cancelled = false; let cancelled = false;
stat(initial) stat(initial)
.then(info => { .then((info) => {
if (cancelled) return; if (cancelled) return;
if (info?.type === 'file') setPath(splitPath(info.path).parent); if (info && info.type === 'file') {
else if (info?.type === 'folder') setPath(info.path); setPath(splitPath(info.path).parent);
else setPath(splitPath(initial).parent); } else if (info && info.type === 'folder') {
setPath(info.path);
} else {
setPath(splitPath(initial).parent);
}
}) })
.catch(() => { if (!cancelled) setPath(splitPath(initial).parent); }); .catch(() => {
return () => { cancelled = true; }; if (cancelled) return;
// eslint-disable-next-line react-hooks/exhaustive-deps setPath(splitPath(initial).parent);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const load = async (p: string) => { const load = async (p: string) => {
setLoading(true); setError(null); setLoading(true);
try { setEntries(await listDirectory(p)); } setError(null);
catch (e: any) { setError(e?.message ?? 'Failed to load directory'); setEntries([]); } try {
finally { setLoading(false); } const items = await listDirectory(p);
setEntries(items);
} catch (e: any) {
const msg = (e && e.message) || 'Failed to load directory';
setError(msg);
setEntries([]);
} finally {
setLoading(false);
}
}; };
useEffect(() => { if (path !== null) void load(path); }, [path]); useEffect(() => {
if (path === null) return;
void load(path);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path]);
const navigateUp = () => { if (path && path !== '/') setPath(splitPath(path).parent); }; const navigateUp = () => {
const navigateToFolder = (name: string) => { if (path) setPath(joinPath(path, name)); }; if (path === '/' || path === null) return;
const selectEntry = (entry: EntryInfo) => { if (entry.type !== 'file') return; onSelect(entry.path); onClose(); }; setPath(splitPath(path).parent);
const selectCurrentFolder = () => { if (path) { onSelect(path); onClose(); } }; };
const navigateToFolder = (folderName: string) => {
if (path === null) return;
setPath(joinPath(path, folderName));
};
const selectEntry = (entry: EntryInfo) => {
if (entry.type !== 'file') return;
onSelect(entry.path);
onClose();
};
const selectCurrentFolder = () => {
if (path === null) return;
onSelect(path);
onClose();
};
const refresh = () => {
if (path !== null) void load(path);
};
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
const name = newFolderName.trim(); const name = newFolderName.trim();
if (!name || !path) return; if (!name || path === null) return;
try { try {
await createFolder(joinPath(path, name), true); await createFolder(joinPath(path, name), true);
setNewFolderName(''); setShowNewFolder(false); setNewFolderName('');
setShowNewFolder(false);
toast.success(`Created folder "${name}"`); toast.success(`Created folder "${name}"`);
void load(path); void load(path);
} catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); } } catch (e: any) {
toast.error(`Failed to create folder: ${e?.message || e}`);
}
}; };
const handleDelete = async (entry: EntryInfo) => { const handleDelete = async (entry: EntryInfo) => {
if (!window.confirm(entry.type === 'folder' ? `Delete folder "${entry.name}" and all its contents?` : `Delete file "${entry.name}"?`)) return; const ok = window.confirm(
entry.type === 'folder'
? `Delete folder "${entry.name}" and all its contents?`
: `Delete file "${entry.name}"?`,
);
if (!ok) return;
setActionEntry(null); setActionEntry(null);
try { await deletePath(entry.path); toast.success(`Deleted ${entry.name}`); if (path) void load(path); } try {
catch (e: any) { toast.error(`Failed to delete: ${e?.message ?? e}`); } await deletePath(entry.path);
toast.success(`Deleted ${entry.name}`);
if (path !== null) void load(path);
} catch (e: any) {
toast.error(`Failed to delete: ${e?.message || e}`);
}
}; };
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
if (!path) return; if (path === null) return;
const target = joinPath(path, file.name); const target = joinPath(path, file.name);
if (await fileExists(target)) { try {
if (!window.confirm(`"${file.name}" already exists. Overwrite?`)) return; const buf = await file.arrayBuffer();
await putFileContents(target, buf);
toast.success(`Uploaded ${file.name}`);
void load(path);
} catch (e: any) {
toast.error(`Failed to upload ${file.name}: ${e?.message || e}`);
} }
try { await putFileContents(target, await file.arrayBuffer()); toast.success(`Uploaded ${file.name}`); void load(path); }
catch (e: any) { toast.error(`Failed to upload ${file.name}: ${e?.message ?? e}`); }
}; };
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => { const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return; const files = e.target.files;
Array.from(e.target.files).forEach(f => void handleUpload(f)); if (!files) return;
Array.from(files).forEach((f) => void handleUpload(f));
e.target.value = ''; e.target.value = '';
}; };
@ -118,34 +229,96 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
return ( return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end" onClick={onClose}> <div className="fixed inset-0 bg-black/50 z-50 flex items-end" onClick={onClose}>
<div className="bg-white w-full max-h-[80vh] rounded-t-xl flex flex-col" onClick={e => e.stopPropagation()}> <div
className="bg-white w-full max-h-[80vh] rounded-t-xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white border-b border-neutral-200 p-4"> <div className="sticky top-0 bg-white border-b border-neutral-200 p-4">
<div className="flex items-center justify-between mb-3 gap-2"> <div className="flex items-center justify-between mb-3 gap-2">
<h3 className="font-medium">Browse Files</h3> <h3 className="font-medium">Browse Files</h3>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={() => path && void load(path)} className="p-2 rounded hover:bg-neutral-100" title="Refresh"><RefreshCw className="w-4 h-4" /></button> <button
<button onClick={() => setShowNewFolder(s => !s)} className="p-2 rounded hover:bg-neutral-100" title="New folder"><FolderPlus className="w-4 h-4" /></button> onClick={() => void refresh()}
<button onClick={() => fileInputRef.current?.click()} className="p-2 rounded hover:bg-neutral-100" title="Upload"><Upload className="w-4 h-4" /></button> className="p-2 rounded hover:bg-neutral-100"
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onPickFiles} /> aria-label="Refresh"
<button onClick={onClose} className="text-sm text-blue-600 ml-1">Cancel</button> title="Refresh"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={() => setShowNewFolder((s) => !s)}
className="p-2 rounded hover:bg-neutral-100"
aria-label="New folder"
title="New folder"
>
<FolderPlus className="w-4 h-4" />
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 rounded hover:bg-neutral-100"
aria-label="Upload"
title="Upload file"
>
<Upload className="w-4 h-4" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={onPickFiles}
/>
<button onClick={onClose} className="text-sm text-blue-600 ml-1">
Cancel
</button>
</div> </div>
</div> </div>
{showNewFolder && ( {showNewFolder && (
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} <input
onKeyDown={e => { if (e.key === 'Enter') void handleCreateFolder(); if (e.key === 'Escape') { setShowNewFolder(false); setNewFolderName(''); } }} value={newFolderName}
placeholder="New folder name" className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded" autoFocus /> onChange={(e) => setNewFolderName(e.target.value)}
<button onClick={() => void handleCreateFolder()} className="px-2 py-1 text-sm bg-blue-600 text-white rounded">Create</button> onKeyDown={(e) => {
if (e.key === 'Enter') void handleCreateFolder();
if (e.key === 'Escape') {
setShowNewFolder(false);
setNewFolderName('');
}
}}
placeholder="New folder name"
className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded"
autoFocus
/>
<button
onClick={() => void handleCreateFolder()}
className="px-2 py-1 text-sm bg-blue-600 text-white rounded"
>
Create
</button>
</div> </div>
)} )}
<div className="flex items-center gap-2 text-sm text-neutral-600 overflow-x-auto"> <div className="flex items-center gap-2 text-sm text-neutral-600 overflow-x-auto">
<button onClick={() => setPath('/')} className="flex-shrink-0 p-1 rounded hover:bg-neutral-100" title="Root"><Home className="w-4 h-4" /></button> <button
{pathParts.map((part, i) => ( onClick={() => setPath('/')}
<div key={i} className="flex items-center gap-2 flex-shrink-0"> className="flex-shrink-0 p-1 rounded hover:bg-neutral-100"
title="Root"
>
<Home className="w-4 h-4" />
</button>
{pathParts.map((part, index) => (
<div key={index} className="flex items-center gap-2 flex-shrink-0">
<ChevronRight className="w-3 h-3" /> <ChevronRight className="w-3 h-3" />
<button onClick={() => setPath('/' + pathParts.slice(0, i + 1).join('/'))} className="hover:text-blue-600">{part}</button> <button
onClick={() => {
const newPath = '/' + pathParts.slice(0, index + 1).join('/');
setPath(newPath);
}}
className="hover:text-blue-600"
>
{part}
</button>
</div> </div>
))} ))}
</div> </div>
@ -154,77 +327,155 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
<div className="overflow-y-auto flex-1"> <div className="overflow-y-auto flex-1">
{path === null && ( {path === null && (
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2"> <div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
<Loader2 className="w-6 h-6 animate-spin" /> Resolving location <Loader2 className="w-6 h-6 animate-spin" />
Resolving location
</div> </div>
)} )}
{loading && ( {loading && (
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2"> <div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
<Loader2 className="w-6 h-6 animate-spin" /> Loading <Loader2 className="w-6 h-6 animate-spin" />
Loading
</div> </div>
)} )}
{!loading && error && ( {!loading && error && (
<div className="p-4 text-sm"> <div className="p-4 text-sm">
<div className="text-red-600 mb-2">Failed to load directory</div> <div className="text-red-600 mb-2">Failed to load directory</div>
<div className="text-neutral-500 text-xs break-all">{error}</div> <div className="text-neutral-500 text-xs break-all">{error}</div>
<button onClick={() => path && void load(path)} className="mt-3 inline-flex items-center gap-1 text-blue-600 text-sm"> <button
onClick={() => path !== null && void load(path)}
className="mt-3 inline-flex items-center gap-1 text-blue-600 text-sm"
>
<RefreshCw className="w-3 h-3" /> Retry <RefreshCw className="w-3 h-3" /> Retry
</button> </button>
</div> </div>
)} )}
{!loading && !error && path !== null && ( {!loading && !error && path !== null && (
<> <>
{path !== '/' && ( {path !== '/' && (
<button onClick={navigateUp} className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100 text-left"> <button
<ArrowLeft className="w-5 h-5 text-neutral-400" /> onClick={navigateUp}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100 text-left"
>
<div className="text-neutral-400">
<ArrowLeft className="w-5 h-5" />
</div>
<span className="text-neutral-600">..</span> <span className="text-neutral-600">..</span>
</button> </button>
)} )}
{entries.map(entry => (
<MediaEntry {entries.map((entry) => (
<div
key={entry.path} key={entry.path}
entry={entry} className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100"
onPrimaryClick={() => entry.type === 'folder' ? navigateToFolder(entry.name) : selectEntry(entry)} >
onActionsClick={e => { e.stopPropagation(); setActionEntry(entry); }} <button
/> onClick={() => {
if (entry.type === 'folder') {
navigateToFolder(entry.name);
} else {
selectEntry(entry);
}
}}
className="flex-1 flex items-center gap-3 text-left min-w-0"
>
<EntryIcon entry={entry} />
<div className="min-w-0 flex-1">
<div className="text-neutral-900 truncate">{entry.name}</div>
{entry.type === 'file' && (
<div className="text-xs text-neutral-500 truncate">
{humanFileSize(entry.size)}
{entry.lastModified
? ` · ${entry.lastModified.toLocaleDateString()}`
: ''}
</div>
)}
</div>
{entry.type === 'folder' && (
<ChevronRight className="w-4 h-4 text-neutral-400" />
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
setActionEntry(entry);
}}
className="p-2 rounded hover:bg-neutral-100"
aria-label={`Actions for ${entry.name}`}
title="Actions"
>
<MoreVertical className="w-4 h-4" />
</button>
</div>
))} ))}
{entries.length === 0 && <div className="p-8 text-center text-neutral-500 text-sm">Empty folder</div>}
{entries.length === 0 && (
<div className="p-8 text-center text-neutral-500 text-sm">
Empty folder
</div>
)}
</> </>
)} )}
</div> </div>
<div className="sticky bottom-0 bg-white border-t border-neutral-200 p-4"> <div className="sticky bottom-0 bg-white border-t border-neutral-200 p-4">
<button onClick={selectCurrentFolder} disabled={path === null} <button
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50 inline-flex items-center justify-center gap-2"> onClick={selectCurrentFolder}
<Check className="w-4 h-4" /> Select Folder: {path ?? '/'} disabled={path === null}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50 inline-flex items-center justify-center gap-2"
title="Use the current folder as your selection"
>
<Check className="w-4 h-4" />
Select Folder: {path ?? '/'}
</button> </button>
</div> </div>
</div> </div>
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}> {/* Action menu modal */}
<Dialog
open={actionEntry !== null}
onOpenChange={(open) => !open && setActionEntry(null)}
>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="truncate">{actionEntry?.name}</DialogTitle> <DialogTitle className="truncate">{actionEntry?.name}</DialogTitle>
<DialogDescription> <DialogDescription>
{actionEntry?.type === 'folder' ? 'Folder' : `File · ${humanFileSize(actionEntry?.size ?? 0)}`} {actionEntry?.type === 'folder' ? 'Folder' : 'File'}
{actionEntry?.type === 'file' && actionEntry.size > 0
? ` · ${humanFileSize(actionEntry.size)}`
: ''}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{actionEntry?.type === 'file' && ( {actionEntry?.type === 'file' && (
<button onClick={() => actionEntry && selectEntry(actionEntry)} <button
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"> onClick={() => actionEntry && selectEntry(actionEntry)}
<Check className="w-4 h-4 text-blue-600" /> <span>Select this file</span> 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"
>
<Check className="w-4 h-4 text-blue-600" />
<span>Select this file</span>
</button> </button>
)} )}
{actionEntry?.type === 'folder' && ( {actionEntry?.type === 'folder' && (
<button onClick={() => { actionEntry && navigateToFolder(actionEntry.name); setActionEntry(null); }} <button
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"> onClick={() => {
<Folder className="w-4 h-4 text-blue-600" /> <span>Open this folder</span> if (actionEntry) navigateToFolder(actionEntry.name);
setActionEntry(null);
}}
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"
>
<Folder className="w-4 h-4 text-blue-600" />
<span>Open this folder</span>
</button> </button>
)} )}
<div className="border-t border-neutral-100" /> <button
<button onClick={() => actionEntry && void handleDelete(actionEntry)} onClick={() => actionEntry && void handleDelete(actionEntry)}
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3"> className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3"
<Trash2 className="w-4 h-4" /> <span>Delete</span> >
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</button> </button>
</div> </div>
</DialogContent> </DialogContent>

View File

@ -1,103 +0,0 @@
import {
BookOpen,
Braces,
CassetteTape,
ChevronRight,
Code2,
Cpu,
Disc,
File,
FileText,
FileType,
Folder,
HardDrive,
Image as ImageIcon,
MoreVertical,
Music,
Package,
Save,
SlidersHorizontal,
Terminal,
} from 'lucide-react';
import { humanFileSize, type EntryInfo } from '../webdav';
// ─── Extension sets ───────────────────────────────────────────────────────────
export const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']);
export const DOC_EXTS = new Set(['doc', 'docx', 'odt', 'rtf', 'pdf', 'pages', 'tex', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']);
export const CODE_EXTS = new Set(['asm', 'bas', 's', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'py', 'c', 'cpp', 'h', 'hpp', 'lua', 'sh', 'bash', 'php', 'rb', 'rs', 'go', 'java', 'cs', 'kt', 'sql', 'pl']);
export const MD_EXTS = new Set(['md', 'markdown']);
export const JSON_EXTS = new Set(['json', 'webmanifest']);
export const XML_EXTS = new Set(['xml', 'html', 'htm', 'rss', 'atom', 'xsl']);
export const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']);
export const AUDIO_EXTS = new Set(['sid', 'psid', 'rsid', 'mus', 'vgm']);
export const ROM_EXTS = new Set(['bin', 'rom', 'crt']);
export const TAPE_EXTS = new Set(['tap', 'htap', 't64', 'tcrt']);
export const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', 'g64', 'g71', 'g81', 'p64', 'p71', 'p81', 'nib']);
export const DISC_EXTS = new Set(['iso', 'img', 'cue']);
export const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd', 'bbt', 'd8b', 'dfi']);
export const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']);
export const CONFIG_EXTS = new Set(['config']);
// ─── EntryIcon ────────────────────────────────────────────────────────────────
export 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 (DOC_EXTS.has(ext)) return <FileType className="w-5 h-5 text-blue-400 flex-shrink-0" />;
if (CODE_EXTS.has(ext)) return <Terminal 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" />;
}
// ─── MediaEntry ───────────────────────────────────────────────────────────────
export interface MediaEntryProps {
entry: EntryInfo;
onPrimaryClick: () => void;
onActionsClick: (e: React.MouseEvent) => void;
/** Optional leading slot — e.g. a checkbox in MediaManager. */
leftSlot?: React.ReactNode;
/** Replaces the filename text — e.g. an inline rename input. */
nameSlot?: React.ReactNode;
selected?: boolean;
className?: string;
}
export function MediaEntry({
entry, onPrimaryClick, onActionsClick, leftSlot, nameSlot, selected, className,
}: MediaEntryProps) {
return (
<div className={`px-4 py-3 flex items-center gap-3 border-b border-neutral-100 hover:bg-neutral-50 ${selected ? 'bg-blue-50' : ''} ${className ?? ''}`}>
{leftSlot}
<button className="flex-1 flex items-center gap-3 text-left min-w-0" onClick={onPrimaryClick}>
<EntryIcon entry={entry} />
<div className="min-w-0 flex-1">
{nameSlot ?? <div className="text-neutral-900 truncate text-sm">{entry.name}</div>}
{entry.type === 'file' && (
<div className="text-xs text-neutral-400 truncate">
{humanFileSize(entry.size)}
{entry.lastModified ? ` · ${entry.lastModified.toLocaleDateString()}` : ''}
</div>
)}
</div>
{entry.type === 'folder' && <ChevronRight className="w-4 h-4 text-neutral-400 flex-shrink-0" />}
</button>
<button onClick={onActionsClick} className="p-2 rounded hover:bg-neutral-200 flex-shrink-0" title="Actions">
<MoreVertical className="w-4 h-4" />
</button>
</div>
);
}

View File

@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
AlignLeft, AlignLeft,
ArrowLeft, ArrowLeft,
Book,
BookOpen, BookOpen,
Braces, Braces,
CassetteTape,
Check, Check,
CheckSquare, CheckSquare,
ChevronLeft, ChevronLeft,
@ -12,9 +12,13 @@ import {
ClipboardPaste, ClipboardPaste,
Code2, Code2,
Copy, Copy,
Cpu,
Disc,
Download, Download,
Eye, Eye,
File,
FilePlus, FilePlus,
FileText,
Folder, Folder,
FolderPlus, FolderPlus,
HardDrive, HardDrive,
@ -24,17 +28,18 @@ import {
Loader2, Loader2,
MoreVertical, MoreVertical,
Move, Move,
Music,
Package,
SlidersHorizontal,
Pencil, Pencil,
RefreshCw, RefreshCw,
Save, Save,
Search, Search,
SlidersHorizontal,
Terminal, Terminal,
Trash2, Trash2,
Upload, Upload,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { MediaEntry, TEXT_EXTS, DOC_EXTS, CODE_EXTS, MD_EXTS, JSON_EXTS, XML_EXTS, IMAGE_EXTS, CONFIG_EXTS } from './MediaEntry';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
@ -50,7 +55,6 @@ import {
deletePath, deletePath,
fileExists, fileExists,
getFileContents, getFileContents,
getWebDAVBaseUrl,
humanFileSize, humanFileSize,
joinPath, joinPath,
listDirectory, listDirectory,
@ -73,9 +77,24 @@ 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' | 'code' | 'doc'; type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config' | 'code';
// Extension sets are imported from MediaEntry. // ─── Extension sets ──────────────────────────────────────────────────────────
const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']);
const CODE_EXTS = new Set(['asm', 'bas', 's', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'py', 'c', 'cpp', 'h', 'hpp', 'lua', 'sh', 'bash', 'php', 'rb', 'rs', 'go', 'java', 'cs', 'kt', 'sql', 'pl']);
const MD_EXTS = new Set(['md', 'markdown']);
const JSON_EXTS = new Set(['json', 'webmanifest']);
const XML_EXTS = new Set(['xml', 'html', 'htm', 'rss', 'atom', 'xsl']);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']);
const AUDIO_EXTS = new Set(['sid', 'psid', 'rsid', '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', 'bbt', 'd8b', 'dfi']);
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 defaultViewMode(entry: EntryInfo): ViewMode { function defaultViewMode(entry: EntryInfo): ViewMode {
const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
@ -85,22 +104,19 @@ function defaultViewMode(entry: EntryInfo): ViewMode {
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';
if (CODE_EXTS.has(ext)) return 'code'; if (CODE_EXTS.has(ext)) return 'code';
if (DOC_EXTS.has(ext)) return 'doc';
if (TEXT_EXTS.has(ext)) return 'text'; if (TEXT_EXTS.has(ext)) return 'text';
return 'hex'; return 'hex';
} }
function availableViewers(entry: EntryInfo): ViewMode[] { function availableViewers(entry: EntryInfo): ViewMode[] {
const def = defaultViewMode(entry); const def = defaultViewMode(entry);
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
const map: Record<ViewMode, ViewMode[]> = { const map: Record<ViewMode, ViewMode[]> = {
image: ext === 'svg' ? ['image', 'xml', 'hex'] : ['image', 'hex'], image: ['image', 'hex'],
config: ['config', 'text', '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'],
code: ['code', 'text', 'hex'], code: ['code', 'text', 'hex'],
doc: ['doc'],
text: ['text', 'hex'], text: ['text', 'hex'],
hex: ['hex', 'text'], hex: ['hex', 'text'],
}; };
@ -108,7 +124,7 @@ function availableViewers(entry: EntryInfo): ViewMode[] {
} }
const VIEWER_LABEL: Record<ViewMode, string> = { const VIEWER_LABEL: Record<ViewMode, string> = {
code: 'Code', text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'HTML/XML', hex: 'Hex', image: 'Image', config: 'Config', doc: 'Document', code: 'Code', text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'HTML/XML', hex: 'Hex', image: 'Image', config: 'Config',
}; };
const EXT_TO_LANG: Record<string, string> = { const EXT_TO_LANG: Record<string, string> = {
@ -134,7 +150,6 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin
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} />; case 'config': return <SlidersHorizontal className={cls} />;
case 'doc': return <Book className={cls} />;
} }
} }
@ -244,7 +259,26 @@ function MarkdownEditor({ text, onSave }: { text: string; onSave?: (s: string) =
); );
} }
// EntryIcon is imported from MediaEntry. // ─── Entry icon ───────────────────────────────────────────────────────────────
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" />;
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
}
// ─── ActionsModal ───────────────────────────────────────────────────────────── // ─── ActionsModal ─────────────────────────────────────────────────────────────
@ -584,7 +618,6 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
if (renameEntry !== null) return; if (renameEntry !== null) return;
if (entry.type === 'folder') { navigateTo(joinPath(path, entry.name)); return; } if (entry.type === 'folder') { navigateTo(joinPath(path, entry.name)); return; }
const targetMode = mode ?? defaultViewMode(entry); const targetMode = mode ?? defaultViewMode(entry);
if (targetMode === 'doc') { window.open(getWebDAVBaseUrl() + entry.path, '_blank'); return; }
setViewEntry(entry); setViewEntry(entry);
setViewMode(targetMode); setViewMode(targetMode);
setViewText(null); setViewText(null);
@ -876,12 +909,8 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
// ── Upload ─────────────────────────────────────────────────────────────── // ── Upload ───────────────────────────────────────────────────────────────
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
const target = joinPath(path, file.name);
if (await fileExists(target)) {
if (!window.confirm(`"${file.name}" already exists. Overwrite?`)) return;
}
try { try {
await putFileContents(target, await file.arrayBuffer()); await putFileContents(joinPath(path, file.name), await file.arrayBuffer());
toast.success(`Uploaded ${file.name}`); toast.success(`Uploaded ${file.name}`);
void load(path); void load(path);
} catch (e: any) { toast.error(`Upload failed for ${file.name}: ${e?.message ?? e}`); } } catch (e: any) { toast.error(`Upload failed for ${file.name}: ${e?.message ?? e}`); }
@ -1141,45 +1170,65 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
)} )}
{visible.map(entry => ( {visible.map(entry => (
<MediaEntry <div
key={entry.path} key={entry.path}
entry={entry} className={`px-4 py-3 flex items-center gap-3 border-b border-neutral-100 hover:bg-neutral-50 ${selected.has(entry.path) ? 'bg-blue-50' : ''}`}
selected={selected.has(entry.path)} >
onPrimaryClick={() => { <input
if (renameEntry !== null) return; type="checkbox"
if (entry.type === 'folder') navigateTo(joinPath(path, entry.name)); checked={selected.has(entry.path)}
else setMountEntry(entry); onChange={() => toggleSelect(entry.path)}
}} onClick={e => e.stopPropagation()}
onActionsClick={e => { e.stopPropagation(); if (renameEntry !== null) return; setActionEntry(entry); }} className="w-4 h-4 flex-shrink-0"
leftSlot={ />
<input <button
type="checkbox" onClick={() => {
checked={selected.has(entry.path)} if (renameEntry !== null) return;
onChange={() => toggleSelect(entry.path)} if (entry.type === 'folder') navigateTo(joinPath(path, entry.name));
onClick={e => e.stopPropagation()} else setMountEntry(entry);
className="w-4 h-4 flex-shrink-0" }}
/> className="flex-1 flex items-center gap-3 text-left min-w-0"
} >
nameSlot={renameEntry?.path === entry.path ? ( <EntryIcon entry={entry} />
<input <div className="min-w-0 flex-1">
ref={renameInputRef} {renameEntry?.path === entry.path ? (
value={renameName} <input
onChange={e => setRenameName(e.target.value)} ref={renameInputRef}
onKeyDown={e => { value={renameName}
if (e.key === 'Enter') void commitRename(); onChange={e => setRenameName(e.target.value)}
if (e.key === 'Escape') setRenameEntry(null); onKeyDown={e => {
}} if (e.key === 'Enter') void commitRename();
onBlur={() => void commitRename()} if (e.key === 'Escape') setRenameEntry(null);
onClick={e => e.stopPropagation()} }}
onFocus={e => { onBlur={() => void commitRename()}
const dot = renameName.lastIndexOf('.'); onClick={e => e.stopPropagation()}
e.currentTarget.setSelectionRange(0, dot > 0 ? dot : renameName.length); onFocus={e => {
}} const dot = renameName.lastIndexOf('.');
className="w-full px-1 py-0 border border-blue-400 rounded text-sm" e.currentTarget.setSelectionRange(0, dot > 0 ? dot : renameName.length);
autoFocus }}
/> className="w-full px-1 py-0 border border-blue-400 rounded text-sm"
) : undefined} autoFocus
/> />
) : (
<div className="text-neutral-900 truncate text-sm">{entry.name}</div>
)}
{entry.type === 'file' && (
<div className="text-xs text-neutral-400 truncate">
{humanFileSize(entry.size)}
{entry.lastModified ? ` · ${entry.lastModified.toLocaleDateString()}` : ''}
</div>
)}
</div>
{entry.type === 'folder' && <ChevronRight className="w-4 h-4 text-neutral-400 flex-shrink-0" />}
</button>
<button
onClick={e => { e.stopPropagation(); if (renameEntry !== null) return; setActionEntry(entry); }}
className="p-2 rounded hover:bg-neutral-200 flex-shrink-0"
title="Actions"
>
<MoreVertical className="w-4 h-4" />
</button>
</div>
))} ))}
{visible.length === 0 && entries.length === 0 && ( {visible.length === 0 && entries.length === 0 && (

View File

@ -1,13 +1,10 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2, Printer, Power, Computer, Download, Trash2, Eye } from 'lucide-react'; import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2 } from 'lucide-react';
import { listDirectory, deletePath, getFileContents, getWebDAVBaseUrl, humanFileSize, type EntryInfo } from '../webdav';
import { toast } from 'sonner';
import { MediaEntry } from './MediaEntry';
import { useWs } from '../ws'; import { useWs } from '../ws';
import DeviceDetailOverlay from './DeviceDetailOverlay'; import DeviceDetailOverlay from './DeviceDetailOverlay';
import MediaSet from './MediaSet'; import MediaSet from './MediaSet';
import { ImageWithFallback } from './figma/ImageWithFallback'; import { ImageWithFallback } from './figma/ImageWithFallback';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
interface StatusPageProps { interface StatusPageProps {
config: any; config: any;
@ -83,52 +80,12 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
const [showResetModal, setShowResetModal] = useState<null | 'meatloaf' | 'host'>(null); const [showResetModal, setShowResetModal] = useState<null | 'meatloaf' | 'host'>(null);
const [resetStatus, setResetStatus] = useState('idle'); // 'idle' | 'in-progress' | 'done' const [resetStatus, setResetStatus] = useState('idle'); // 'idle' | 'in-progress' | 'done'
// Print Log
const [printFiles, setPrintFiles] = useState<EntryInfo[]>([]);
const [printLoading, setPrintLoading] = useState(true);
const [printActionEntry, setPrintActionEntry] = useState<EntryInfo | null>(null);
const loadPrintFiles = () => {
setPrintLoading(true);
listDirectory('/sd/.print')
.then(entries => setPrintFiles(entries.filter(e => e.type === 'file')))
.catch(() => setPrintFiles([]))
.finally(() => setPrintLoading(false));
};
useEffect(() => { loadPrintFiles(); }, []);
const printFileUrl = (entry: EntryInfo) => getWebDAVBaseUrl() + entry.path;
const downloadPrintFile = async (entry: EntryInfo) => {
try {
const blob = await getFileContents(entry.path);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = entry.name; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e: any) { toast.error(`Download failed: ${e?.message ?? e}`); }
};
const deletePrintFile = async (entry: EntryInfo) => {
if (!window.confirm(`Delete "${entry.name}"?`)) return;
try {
await deletePath(entry.path);
toast.success(`Deleted ${entry.name}`);
loadPrintFiles();
} catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); }
};
return ( return (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{activeDevice && ( {activeDevice && (
<> <>
<h2 className="text-sm text-neutral-500 flex items-center gap-2"> <h2 className="text-sm text-neutral-500 pt-2">Active Device</h2>
<Power className="w-4 h-4" />
Active Device
</h2>
<div <div
className="bg-white border border-neutral-200 rounded-lg p-4 relative" className="bg-white border border-neutral-200 rounded-lg p-4 relative"
@ -281,65 +238,6 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
</div> </div>
)} )}
<h2 className="text-sm text-neutral-500 flex items-center gap-2">
<Printer className="w-4 h-4" />
Print Log
</h2>
<div className="bg-white border border-neutral-200 rounded-lg overflow-hidden">
{printLoading ? (
<div className="flex items-center gap-2 p-4 text-sm text-neutral-500">
<Loader2 className="w-4 h-4 animate-spin" /> Loading
</div>
) : printFiles.length === 0 ? (
<div className="p-4 text-sm text-neutral-400">No print files found in /sd/.print</div>
) : (
printFiles.map(entry => (
<MediaEntry
key={entry.path}
entry={entry}
onPrimaryClick={() => window.open(printFileUrl(entry), '_blank')}
onActionsClick={e => { e.stopPropagation(); setPrintActionEntry(entry); }}
className="last:border-b-0"
/>
))
)}
</div>
{/* Print Log action modal */}
<Dialog open={printActionEntry !== null} onOpenChange={open => !open && setPrintActionEntry(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="truncate">{printActionEntry?.name}</DialogTitle>
<DialogDescription>{humanFileSize(printActionEntry?.size ?? 0)}</DialogDescription>
</DialogHeader>
{printActionEntry && (
<div className="flex flex-col gap-2">
<button
onClick={() => { setPrintActionEntry(null); window.open(printFileUrl(printActionEntry), '_blank'); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
>
<Eye className="w-4 h-4 text-blue-600" /> <span>Open / View</span>
</button>
<button
onClick={() => { setPrintActionEntry(null); void downloadPrintFile(printActionEntry); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
>
<Download className="w-4 h-4" /> <span>Download</span>
</button>
<div className="border-t border-neutral-100" />
<button
onClick={() => { setPrintActionEntry(null); void deletePrintFile(printActionEntry); }}
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3"
>
<Trash2 className="w-4 h-4" /> <span>Delete</span>
</button>
</div>
)}
</DialogContent>
</Dialog>
<h2 className="text-sm text-neutral-500 pt-2 flex items-center gap-2"> <h2 className="text-sm text-neutral-500 pt-2 flex items-center gap-2">
<Activity className="w-4 h-4" /> <Activity className="w-4 h-4" />
Activity Log Activity Log
@ -374,10 +272,7 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
))} ))}
</div> </div>
<h2 className="text-sm text-neutral-500 flex items-center gap-2"> <h2 className="text-sm text-neutral-500">System Status</h2>
<Computer className="w-4 h-4" />
System Status
</h2>
<div className="bg-white border border-neutral-200 rounded-lg p-4"> <div className="bg-white border border-neutral-200 rounded-lg p-4">
{/* System Status Action Buttons at bottom */} {/* System Status Action Buttons at bottom */}

View File

@ -16,14 +16,12 @@ import {
createFolder, createFolder,
getFileContents, getFileContents,
putFileContents, putFileContents,
stat,
} from './webdav'; } from './webdav';
/** The canonical paths on the WebDAV server. */ /** The canonical paths on the WebDAV server. */
export const SETTINGS_PATH = '/.sys/config.json'; export const SETTINGS_PATH = '/.sys/config.json';
export const DEVICES_PATH = '/.sys/devices.json'; export const DEVICES_PATH = '/.sys/devices.json';
const SETTINGS_DIR = '/.sys'; const SETTINGS_DIR = '/.sys';
const SD_SYS_DIR = '/sd/.sys';
/** /**
* How long to wait after the last change before writing to disk. * How long to wait after the last change before writing to disk.
@ -77,29 +75,17 @@ export async function readSettings(): Promise<SettingsConfig | null> {
* indentation so the file is diff-friendly. * indentation so the file is diff-friendly.
*/ */
export async function writeSettings(config: SettingsConfig): Promise<void> { export async function writeSettings(config: SettingsConfig): Promise<void> {
try { await createFolder(SETTINGS_DIR, true); } catch { /* exists */ } try {
await createFolder(SETTINGS_DIR, true);
} catch {
/* directory may already exist; ignore */
}
const { iec, ...mainConfig } = config; const { iec, ...mainConfig } = config;
const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any; const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any;
const configJson = JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n';
const devicesJson = JSON.stringify({ iec: { devices: iecDevices } }, null, 2) + '\n';
await Promise.all([ await Promise.all([
putFileContents(SETTINGS_PATH, configJson), putFileContents(SETTINGS_PATH, JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n'),
putFileContents(DEVICES_PATH, devicesJson), putFileContents(DEVICES_PATH, JSON.stringify({ iec: { devices: iecDevices } }, null, 2) + '\n'),
]); ]);
// Mirror to /sd/.sys/ if the SD card is mounted.
try {
const sdStat = await stat('/sd');
if (sdStat) {
try { await createFolder(SD_SYS_DIR, true); } catch { /* exists */ }
await Promise.all([
putFileContents(SD_SYS_DIR + '/config.json', configJson),
putFileContents(SD_SYS_DIR + '/devices.json', devicesJson),
]);
}
} catch { /* /sd unreachable — skip mirror */ }
} }
export type SaveStatus = export type SaveStatus =