feat(MediaManager): enhance file handling for playlists and add duplicate entry functionality
This commit is contained in:
parent
fed75c77df
commit
ce2dd7eab1
|
|
@ -299,7 +299,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setCurrentPage('iec')} className="flex-1 flex flex-col items-center gap-1 py-2">
|
<button onClick={() => setCurrentPage('iec')} className="flex-1 flex flex-col items-center gap-1 py-2">
|
||||||
<Cpu className="w-5 h-5 text-white" />
|
<Cpu className="w-5 h-5 text-white" />
|
||||||
<span className="text-xs text-white">IEC</span>
|
<span className="text-xs text-white">Options</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setCurrentPage('network')} className="flex-1 flex flex-col items-center gap-1 py-2">
|
<button onClick={() => setCurrentPage('network')} className="flex-1 flex flex-col items-center gap-1 py-2">
|
||||||
<Network className="w-5 h-5 text-white" />
|
<Network className="w-5 h-5 text-white" />
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { fileExists, getFileContents, joinPath } from '../webdav';
|
import { fileExists, getFileContents, joinPath } from '../webdav';
|
||||||
import MediaBrowser from './MediaBrowser';
|
import MediaBrowser from './MediaBrowser';
|
||||||
import MediaSet, { type MediaSetEntry } from './MediaSet';
|
import MediaSet, { mediaSetEntryUrl, type MediaSetEntry } from './MediaSet';
|
||||||
|
|
||||||
interface Device {
|
interface Device {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -160,16 +160,29 @@ export default function DeviceDetailOverlay({
|
||||||
|
|
||||||
const handleFileSelect = async (selectedPath: string) => {
|
const handleFileSelect = async (selectedPath: string) => {
|
||||||
const devicePath = getDevicePath();
|
const devicePath = getDevicePath();
|
||||||
if (selectedPath.toLowerCase().endsWith('.lst')) {
|
const selExt = selectedPath.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (selExt === 'lst' || selExt === 'vms') {
|
||||||
|
const isVms = selExt === 'vms';
|
||||||
try {
|
try {
|
||||||
const text = await (await getFileContents(selectedPath)).text();
|
const text = await (await getFileContents(selectedPath)).text();
|
||||||
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
|
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
|
||||||
const candidates = text.split('\n')
|
const candidates: MediaSetEntry[] = text.split('\n')
|
||||||
.map(l => l.trim())
|
.map(l => l.trim())
|
||||||
.filter(l => l.length > 0 && !l.startsWith('#'))
|
.filter(l => l.length > 0 && !l.startsWith('#'))
|
||||||
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
|
.map(l => {
|
||||||
|
if (isVms) {
|
||||||
|
const comma = l.indexOf(',');
|
||||||
|
if (comma > 0) {
|
||||||
|
const url = l.slice(0, comma).trim();
|
||||||
|
const name = l.slice(comma + 1).trim();
|
||||||
|
const resolved = url.startsWith('/') ? url : joinPath(dir, url);
|
||||||
|
return name ? { url: resolved, name } : resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l.startsWith('/') ? l : joinPath(dir, l);
|
||||||
|
});
|
||||||
if (candidates.length === 0) { toast.error('Swap list is empty'); return; }
|
if (candidates.length === 0) { toast.error('Swap list is empty'); return; }
|
||||||
const existsArr = await Promise.all(candidates.map(f => fileExists(f).catch(() => false)));
|
const existsArr = await Promise.all(candidates.map(e => fileExists(mediaSetEntryUrl(e)).catch(() => false)));
|
||||||
const files = candidates.filter((_, i) => existsArr[i]);
|
const files = candidates.filter((_, i) => existsArr[i]);
|
||||||
if (files.length === 0) { toast.error('No files in swap list exist on device'); return; }
|
if (files.length === 0) { toast.error('No files in swap list exist on device'); return; }
|
||||||
if (files.length < candidates.length) {
|
if (files.length < candidates.length) {
|
||||||
|
|
@ -178,8 +191,8 @@ export default function DeviceDetailOverlay({
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
let dev = newConfig;
|
let dev = newConfig;
|
||||||
for (const k of devicePath) dev = dev[k];
|
for (const k of devicePath) dev = dev[k];
|
||||||
if (isOutsideBase(files[0], dev.base_url || '')) clearBaseAndCache(dev);
|
if (isOutsideBase(mediaSetEntryUrl(files[0]), dev.base_url || '')) clearBaseAndCache(dev);
|
||||||
dev.url = files[0];
|
dev.url = mediaSetEntryUrl(files[0]);
|
||||||
dev.media_set = files;
|
dev.media_set = files;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Folder,
|
Folder,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
Layers,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Music,
|
Music,
|
||||||
Package,
|
Package,
|
||||||
|
|
@ -23,7 +24,8 @@ import { humanFileSize, type EntryInfo } from '../webdav';
|
||||||
|
|
||||||
// ─── Extension sets ───────────────────────────────────────────────────────────
|
// ─── Extension sets ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']);
|
export const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv']);
|
||||||
|
export const PLAYLIST_EXTS = new Set(['lst', 'vms']);
|
||||||
export const DOC_EXTS = new Set(['doc', 'docx', 'odt', 'rtf', 'pdf', 'pages', 'tex', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']);
|
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 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 MD_EXTS = new Set(['md', 'markdown']);
|
||||||
|
|
@ -52,6 +54,7 @@ export function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||||
if (ROM_EXTS.has(ext)) return <Cpu className="w-5 h-5 text-red-500 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 (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 (ARCHIVE_EXTS.has(ext)) return <Package className="w-5 h-5 text-yellow-600 flex-shrink-0" />;
|
||||||
|
if (PLAYLIST_EXTS.has(ext)) return <Layers className="w-5 h-5 text-indigo-500 flex-shrink-0" />;
|
||||||
if (CONFIG_EXTS.has(ext)) return <SlidersHorizontal className="w-5 h-5 text-slate-400 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 (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 (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ import {
|
||||||
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 { MediaEntry, TEXT_EXTS, DOC_EXTS, CODE_EXTS, MD_EXTS, JSON_EXTS, XML_EXTS, IMAGE_EXTS, CONFIG_EXTS, PLAYLIST_EXTS } from './MediaEntry';
|
||||||
|
import { mediaSetEntryUrl, type MediaSetEntry } from './MediaSet';
|
||||||
import type { ViewMode } from './MediaViewerEditor';
|
import type { ViewMode } from './MediaViewerEditor';
|
||||||
const MediaViewerEditor = lazy(() => import('./MediaViewerEditor'));
|
const MediaViewerEditor = lazy(() => import('./MediaViewerEditor'));
|
||||||
|
|
||||||
|
|
@ -145,6 +146,7 @@ interface ActionsModalProps {
|
||||||
onOpen: (entry: EntryInfo, mode?: ViewMode) => void;
|
onOpen: (entry: EntryInfo, mode?: ViewMode) => void;
|
||||||
onMount: (entry: EntryInfo) => void;
|
onMount: (entry: EntryInfo) => void;
|
||||||
onDownload: (entry: EntryInfo) => void;
|
onDownload: (entry: EntryInfo) => void;
|
||||||
|
onDuplicate: (entry: EntryInfo) => void;
|
||||||
onRename: (entry: EntryInfo) => void;
|
onRename: (entry: EntryInfo) => void;
|
||||||
onCopy: (entry: EntryInfo) => void;
|
onCopy: (entry: EntryInfo) => void;
|
||||||
onCut: (entry: EntryInfo) => void;
|
onCut: (entry: EntryInfo) => void;
|
||||||
|
|
@ -152,7 +154,7 @@ interface ActionsModalProps {
|
||||||
folderManagement?: FolderManagementActions;
|
folderManagement?: FolderManagementActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onRename, onCopy, onCut, onDelete, folderManagement }: ActionsModalProps) {
|
function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onDuplicate, onRename, onCopy, onCut, onDelete, folderManagement }: ActionsModalProps) {
|
||||||
const isFolder = entry?.type === 'folder';
|
const isFolder = entry?.type === 'folder';
|
||||||
const fm = folderManagement;
|
const fm = folderManagement;
|
||||||
|
|
||||||
|
|
@ -240,6 +242,10 @@ function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onRename, o
|
||||||
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">
|
||||||
<Download className="w-4 h-4" /> <span>Download</span>
|
<Download className="w-4 h-4" /> <span>Download</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => { onClose(); onDuplicate(entry); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
||||||
|
<Copy className="w-4 h-4" /> <span>Duplicate</span>
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -630,19 +636,32 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
||||||
const dev = newConfig.devices.iec[key];
|
const dev = newConfig.devices.iec[key];
|
||||||
|
|
||||||
if (mountEntry.name.toLowerCase().endsWith('.lst')) {
|
const mountExt = mountEntry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (PLAYLIST_EXTS.has(mountExt)) {
|
||||||
try {
|
try {
|
||||||
const text = await (await getFileContents(mountEntry.path)).text();
|
const text = await (await getFileContents(mountEntry.path)).text();
|
||||||
const dir = splitPath(mountEntry.path).parent;
|
const dir = splitPath(mountEntry.path).parent;
|
||||||
const candidates = text.split('\n')
|
const isVms = mountExt === 'vms';
|
||||||
|
const candidates: MediaSetEntry[] = text.split('\n')
|
||||||
.map(l => l.trim())
|
.map(l => l.trim())
|
||||||
.filter(l => l.length > 0 && !l.startsWith('#'))
|
.filter(l => l.length > 0 && !l.startsWith('#'))
|
||||||
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
|
.map(l => {
|
||||||
|
if (isVms) {
|
||||||
|
const comma = l.indexOf(',');
|
||||||
|
if (comma > 0) {
|
||||||
|
const url = l.slice(0, comma).trim();
|
||||||
|
const name = l.slice(comma + 1).trim();
|
||||||
|
const resolved = url.startsWith('/') ? url : joinPath(dir, url);
|
||||||
|
return name ? { url: resolved, name } : resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l.startsWith('/') ? l : joinPath(dir, l);
|
||||||
|
});
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
toast.error(`${mountEntry.name}: swap list is empty`);
|
toast.error(`${mountEntry.name}: swap list is empty`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const exists = await Promise.all(candidates.map(f => fileExists(f).catch(() => false)));
|
const exists = await Promise.all(candidates.map(e => fileExists(mediaSetEntryUrl(e)).catch(() => false)));
|
||||||
const files = candidates.filter((_, i) => exists[i]);
|
const files = candidates.filter((_, i) => exists[i]);
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
toast.error(`${mountEntry.name}: no files in swap list exist on device`);
|
toast.error(`${mountEntry.name}: no files in swap list exist on device`);
|
||||||
|
|
@ -651,7 +670,7 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
if (files.length < candidates.length) {
|
if (files.length < candidates.length) {
|
||||||
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
|
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
|
||||||
}
|
}
|
||||||
dev.url = files[0];
|
dev.url = mediaSetEntryUrl(files[0]);
|
||||||
dev.media_set = files;
|
dev.media_set = files;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`);
|
toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`);
|
||||||
|
|
@ -677,9 +696,9 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
setMountEntry(null);
|
setMountEntry(null);
|
||||||
const deviceId = `${deviceType}-${key}`;
|
const deviceId = `${deviceType}-${key}`;
|
||||||
const isLst = mountEntry.name.toLowerCase().endsWith('.lst');
|
const isPlaylist = PLAYLIST_EXTS.has(mountExt);
|
||||||
const label = isLst
|
const label = isPlaylist
|
||||||
? `Loaded swap list "${mountEntry.name}" (${(dev.media_set as string[]).length} disks) on ${deviceType} #${key}`
|
? `Loaded "${mountEntry.name}" (${(dev.media_set as MediaSetEntry[]).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
|
||||||
|
|
@ -696,6 +715,28 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
setActionEntry(null);
|
setActionEntry(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const duplicateEntry = async (entry: EntryInfo) => {
|
||||||
|
const { parent } = splitPath(entry.path);
|
||||||
|
const dotIdx = entry.name.lastIndexOf('.');
|
||||||
|
const base = dotIdx > 0 ? entry.name.slice(0, dotIdx) : entry.name;
|
||||||
|
const ext = dotIdx > 0 ? entry.name.slice(dotIdx) : '';
|
||||||
|
let destName = `${base} copy${ext}`;
|
||||||
|
let destPath = joinPath(parent, destName);
|
||||||
|
let n = 2;
|
||||||
|
while (await fileExists(destPath)) {
|
||||||
|
destName = `${base} copy ${n}${ext}`;
|
||||||
|
destPath = joinPath(parent, destName);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await copyPath(entry.path, destPath);
|
||||||
|
toast.success(`Duplicated as "${destName}"`);
|
||||||
|
void load(path);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Duplicate failed: ${e?.message ?? e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const copySelected = () => {
|
const copySelected = () => {
|
||||||
setClipboard({ op: 'copy', paths: [...selected] });
|
setClipboard({ op: 'copy', paths: [...selected] });
|
||||||
toast.success(`${selected.size} item${selected.size !== 1 ? 's' : ''} copied — navigate and paste`);
|
toast.success(`${selected.size} item${selected.size !== 1 ? 's' : ''} copied — navigate and paste`);
|
||||||
|
|
@ -1109,6 +1150,7 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
onOpen={(e, mode) => void openEntry(e, mode)}
|
onOpen={(e, mode) => void openEntry(e, mode)}
|
||||||
onMount={e => setMountEntry(e)}
|
onMount={e => setMountEntry(e)}
|
||||||
onDownload={e => void downloadEntry(e)}
|
onDownload={e => void downloadEntry(e)}
|
||||||
|
onDuplicate={e => void duplicateEntry(e)}
|
||||||
onRename={e => startRename(e)}
|
onRename={e => startRename(e)}
|
||||||
onCopy={e => cutOrCopyEntry(e, 'copy')}
|
onCopy={e => cutOrCopyEntry(e, 'copy')}
|
||||||
onCut={e => cutOrCopyEntry(e, 'move')}
|
onCut={e => cutOrCopyEntry(e, 'move')}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user