file browser: restore the inline video-download modal, drop the side panel
Reverts the video-download UI from the ephemeral side panel back to the modal (VideoDownloadDialog) that predated it — the "download video" button opens the modal again and downloads inline via /download-video, exactly as before. The six wiring files had no non-download changes since the modal→panel conversion, so they're restored verbatim from that commit's parent; the panel file is removed. The inline ReClip routes it uses are untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Toolbar } from './components/Toolbar';
|
|||||||
import { UploadProgress } from './components/UploadProgress';
|
import { UploadProgress } from './components/UploadProgress';
|
||||||
import { FileViewContainer } from './components/FileViewContainer';
|
import { FileViewContainer } from './components/FileViewContainer';
|
||||||
import { TaskRunnerDialog } from './components/TaskRunnerDialog';
|
import { TaskRunnerDialog } from './components/TaskRunnerDialog';
|
||||||
|
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
|
||||||
import { DictateDialog } from './components/DictateDialog';
|
import { DictateDialog } from './components/DictateDialog';
|
||||||
import { useFileBrowserApp } from './useFileBrowserApp';
|
import { useFileBrowserApp } from './useFileBrowserApp';
|
||||||
|
|
||||||
@@ -25,11 +26,15 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, initialPath, defa
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<Toolbar fileBrowserManager={fileBrowserManager} />
|
<Toolbar fileBrowserManager={fileBrowserManager} />
|
||||||
<Breadcrumb path={fileBrowserManager.currentPath} onNavigate={handleNavigate} basePath={basePath} />
|
<Breadcrumb
|
||||||
|
path={fileBrowserManager.currentPath}
|
||||||
|
onNavigate={handleNavigate} basePath={basePath} />
|
||||||
<UploadProgress fileBrowserManager={fileBrowserManager} />
|
<UploadProgress fileBrowserManager={fileBrowserManager} />
|
||||||
<FileViewContainer fileBrowserManager={fileBrowserManager} />
|
<FileViewContainer fileBrowserManager={fileBrowserManager} />
|
||||||
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
|
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
|
||||||
|
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
|
||||||
<DictateDialog fileBrowserManager={fileBrowserManager} />
|
<DictateDialog fileBrowserManager={fileBrowserManager} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+6
-16
@@ -1,17 +1,5 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import {
|
import { Loader2, Folder, ClipboardPaste, FolderPlus, FolderUp, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download, Mic } from 'lucide-react';
|
||||||
Loader2,
|
|
||||||
Folder,
|
|
||||||
ClipboardPaste,
|
|
||||||
FolderPlus,
|
|
||||||
FolderUp,
|
|
||||||
LayoutGrid,
|
|
||||||
Upload,
|
|
||||||
ClipboardCopy,
|
|
||||||
MessageSquare,
|
|
||||||
Download,
|
|
||||||
Mic,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
@@ -40,7 +28,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
|||||||
handleChatHere,
|
handleChatHere,
|
||||||
handleCreateDir,
|
handleCreateDir,
|
||||||
handleCreateDashboardHere,
|
handleCreateDashboardHere,
|
||||||
openVideoDownload,
|
setShowVideoDownload,
|
||||||
setShowDictate,
|
setShowDictate,
|
||||||
handleUpload,
|
handleUpload,
|
||||||
} = fileBrowserManager;
|
} = fileBrowserManager;
|
||||||
@@ -104,7 +92,9 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : searchResults ? (
|
) : searchResults ? (
|
||||||
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">No results found</div>
|
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -155,7 +145,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
|||||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||||
Create Dashboard here
|
Create Dashboard here
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={() => openVideoDownload()} className="cursor-pointer">
|
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download video
|
Download video
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|||||||
+3
-9
@@ -21,7 +21,7 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
|
|||||||
setShowHidden,
|
setShowHidden,
|
||||||
viewMode,
|
viewMode,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
openVideoDownload,
|
setShowVideoDownload,
|
||||||
setShowDictate,
|
setShowDictate,
|
||||||
hiddenForced,
|
hiddenForced,
|
||||||
} = fileBrowserManager;
|
} = fileBrowserManager;
|
||||||
@@ -76,7 +76,7 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => openVideoDownload()}
|
onClick={() => setShowVideoDownload(true)}
|
||||||
title="Download video"
|
title="Download video"
|
||||||
className="hidden md:block p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
className="hidden md:block p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
@@ -126,13 +126,7 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
|
|||||||
onClick={() => setShowHidden((v) => !v)}
|
onClick={() => setShowHidden((v) => !v)}
|
||||||
disabled={hiddenForced}
|
disabled={hiddenForced}
|
||||||
className={`hidden md:block p-1.5 rounded-md transition-colors ${hiddenForced ? 'opacity-30 cursor-not-allowed' : `cursor-pointer ${showHidden && !hiddenForced ? 'bg-duck-teal text-duck-yellow' : 'text-duck-teal hover:bg-duck-dark/5'}`}`}
|
className={`hidden md:block p-1.5 rounded-md transition-colors ${hiddenForced ? 'opacity-30 cursor-not-allowed' : `cursor-pointer ${showHidden && !hiddenForced ? 'bg-duck-teal text-duck-yellow' : 'text-duck-teal hover:bg-duck-dark/5'}`}`}
|
||||||
title={
|
title={hiddenForced ? 'Hidden files not shown in home directory' : showHidden ? 'Hide hidden files' : 'Show hidden files'}
|
||||||
hiddenForced
|
|
||||||
? 'Hidden files not shown in home directory'
|
|
||||||
: showHidden
|
|
||||||
? 'Hide hidden files'
|
|
||||||
: 'Show hidden files'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{showHidden && !hiddenForced ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
{showHidden && !hiddenForced ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+314
@@ -0,0 +1,314 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Download, Loader2, ChevronLeft, AlertCircle, Check, Music } from 'lucide-react';
|
||||||
|
import type { VideoInfo } from '../../../../hooks/useFilesAPI';
|
||||||
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
|
|
||||||
|
type VideoDownloadDialogProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DlPhase = 'idle' | 'downloading' | 'saving' | 'done' | 'error';
|
||||||
|
type Entry = {
|
||||||
|
url: string;
|
||||||
|
status: 'loading' | 'ready' | 'error';
|
||||||
|
title?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
duration?: number;
|
||||||
|
uploader?: string;
|
||||||
|
error?: string;
|
||||||
|
dl: DlPhase;
|
||||||
|
dlError?: string;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDuration = (sec?: number): string => {
|
||||||
|
if (!sec || sec <= 0) return '';
|
||||||
|
const s = Math.round(sec);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const ss = String(s % 60).padStart(2, '0');
|
||||||
|
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
// Keep a subfolder name to a single safe path segment.
|
||||||
|
const sanitizeFolder = (name: string) => name.replace(/[/\\]/g, '').replace(/^\.+/, '').trim();
|
||||||
|
|
||||||
|
export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogProps) => {
|
||||||
|
const { showVideoDownload, setShowVideoDownload, currentPath, refresh, files } = fileBrowserManager;
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<'input' | 'preview'>('input');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [audioOnly, setAudioOnly] = useState(false);
|
||||||
|
const [subfolder, setSubfolder] = useState('');
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [entries, setEntries] = useState<Entry[]>([]);
|
||||||
|
const entriesRef = useRef(entries);
|
||||||
|
entriesRef.current = entries;
|
||||||
|
|
||||||
|
const isPlaylist = entries.length > 1;
|
||||||
|
const readyCount = entries.filter((e) => e.status === 'ready').length;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setPhase('input');
|
||||||
|
setUrl('');
|
||||||
|
setAudioOnly(false);
|
||||||
|
setSubfolder('');
|
||||||
|
setFetching(false);
|
||||||
|
setEntries([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset whenever the dialog is (re)opened, so a new session starts clean.
|
||||||
|
useEffect(() => {
|
||||||
|
if (showVideoDownload) reset();
|
||||||
|
}, [showVideoDownload]);
|
||||||
|
|
||||||
|
const handleClose = () => setShowVideoDownload(false);
|
||||||
|
|
||||||
|
const patch = (i: number, p: Partial<Entry>) =>
|
||||||
|
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...p } : e)));
|
||||||
|
|
||||||
|
// Fetch metadata: expand a playlist URL to its entries, then prefetch each one's info progressively.
|
||||||
|
const fetchMeta = async () => {
|
||||||
|
const u = url.trim();
|
||||||
|
if (!u) return;
|
||||||
|
setFetching(true);
|
||||||
|
setEntries([]);
|
||||||
|
|
||||||
|
let urls = [u];
|
||||||
|
if (u.includes('list=')) {
|
||||||
|
const pl = await files.videoPlaylist(u).catch(() => null);
|
||||||
|
if (pl?.error) {
|
||||||
|
setEntries([{ url: u, status: 'error', error: pl.error, dl: 'idle' }]);
|
||||||
|
setPhase('preview');
|
||||||
|
setFetching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pl?.urls?.length) urls = pl.urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEntries(urls.map((v) => ({ url: v, status: 'loading', dl: 'idle' })));
|
||||||
|
setPhase('preview');
|
||||||
|
|
||||||
|
// Sequentially (ReClip does yt-dlp per video — parallel would hammer it); cards fill in as they resolve.
|
||||||
|
for (let i = 0; i < urls.length; i++) {
|
||||||
|
const info = await files.videoInfo(urls[i]!).catch((): VideoInfo => ({ error: 'Could not fetch info' }));
|
||||||
|
if (info.error) patch(i, { status: 'error', error: info.error });
|
||||||
|
else
|
||||||
|
patch(i, {
|
||||||
|
status: 'ready',
|
||||||
|
title: info.title,
|
||||||
|
thumbnail: info.thumbnail,
|
||||||
|
duration: info.duration,
|
||||||
|
uploader: info.uploader,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setFetching(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetDir = () => {
|
||||||
|
const sub = sanitizeFolder(subfolder);
|
||||||
|
if (!sub) return currentPath;
|
||||||
|
return currentPath === '/' ? `/${sub}` : `${currentPath}/${sub}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download one entry as a background job (the server delegates to ReClip), polling to completion.
|
||||||
|
const downloadEntry = async (i: number, entryUrl: string) => {
|
||||||
|
patch(i, { dl: 'downloading', dlError: undefined });
|
||||||
|
try {
|
||||||
|
const { jobId } = await files.downloadVideo(entryUrl, targetDir(), audioOnly);
|
||||||
|
const deadline = Date.now() + 60 * 60 * 1000;
|
||||||
|
for (;;) {
|
||||||
|
if (Date.now() > deadline) return patch(i, { dl: 'error', dlError: 'Timed out' });
|
||||||
|
await sleep(2000);
|
||||||
|
const st = await files.downloadVideoStatus(jobId).catch(() => null);
|
||||||
|
if (!st) continue;
|
||||||
|
if (st.status === 'error') return patch(i, { dl: 'error', dlError: st.error || 'Download failed' });
|
||||||
|
if (st.status === 'transferring') patch(i, { dl: 'saving' });
|
||||||
|
if (st.status === 'done') {
|
||||||
|
patch(i, { dl: 'done', filename: st.filename });
|
||||||
|
void refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
patch(i, { dl: 'error', dlError: 'Could not start the download' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [downloadingAll, setDownloadingAll] = useState(false);
|
||||||
|
const downloadAll = async () => {
|
||||||
|
setDownloadingAll(true);
|
||||||
|
const snapshot = entriesRef.current;
|
||||||
|
for (let i = 0; i < snapshot.length; i++) {
|
||||||
|
const e = snapshot[i]!;
|
||||||
|
if (e.status === 'ready' && e.dl !== 'done' && e.dl !== 'downloading' && e.dl !== 'saving') {
|
||||||
|
await downloadEntry(i, e.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDownloadingAll(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dlLabel = (e: Entry) =>
|
||||||
|
e.dl === 'downloading' ? 'Downloading…' : e.dl === 'saving' ? 'Saving…' : e.dl === 'done' ? 'Saved' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showVideoDownload} onOpenChange={(open) => !open && handleClose()}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Download video</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{phase === 'input'
|
||||||
|
? 'Paste a video or playlist URL — it fetches details before downloading.'
|
||||||
|
: 'Review and download.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{phase === 'input' ? (
|
||||||
|
<form
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
void fetchMeta();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={url}
|
||||||
|
onChange={(ev) => setUrl(ev.target.value)}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=… or …/playlist?list=…"
|
||||||
|
className="h-10 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50"
|
||||||
|
/>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-duck-dark/70">
|
||||||
|
<Checkbox checked={audioOnly} onCheckedChange={(v) => setAudioOnly(v === true)} />
|
||||||
|
Extract audio only (mp3)
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="cursor-pointer rounded-md px-4 py-2 text-sm text-duck-dark/70 transition-colors hover:bg-duck-dark/5"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!url.trim() || fetching}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md bg-duck-teal px-4 py-2 text-sm text-duck-yellow transition-colors hover:bg-duck-teal/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{fetching && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
Fetch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPhase('input')}
|
||||||
|
className="flex cursor-pointer items-center gap-1 text-sm text-duck-dark/60 hover:text-duck-dark"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} /> Back
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-duck-dark/50">
|
||||||
|
{audioOnly ? 'Audio only' : 'Video'}
|
||||||
|
{isPlaylist ? ` · ${entries.length} items` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPlaylist && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={subfolder}
|
||||||
|
onChange={(ev) => setSubfolder(ev.target.value)}
|
||||||
|
placeholder="Subfolder (optional) — leave blank for this folder"
|
||||||
|
className="h-9 min-w-0 flex-1 rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void downloadAll()}
|
||||||
|
disabled={downloadingAll || readyCount === 0}
|
||||||
|
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-duck-teal px-3 py-2 text-sm text-duck-yellow transition-colors hover:bg-duck-teal/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{downloadingAll ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
|
||||||
|
Download all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex max-h-[52vh] flex-col gap-2 overflow-y-auto pr-1">
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={`${e.url}-${i}`}
|
||||||
|
className="flex items-center gap-3 rounded-md border border-duck-dark/10 bg-background/40 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
|
||||||
|
{e.status === 'loading' ? (
|
||||||
|
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
|
||||||
|
) : e.status === 'error' ? (
|
||||||
|
<AlertCircle size={16} className="text-red-500/70" />
|
||||||
|
) : e.thumbnail && !audioOnly ? (
|
||||||
|
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Music size={16} className="text-duck-dark/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{e.status === 'loading' ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
|
||||||
|
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
|
||||||
|
</div>
|
||||||
|
) : e.status === 'error' ? (
|
||||||
|
<>
|
||||||
|
<p className="truncate text-sm font-medium text-red-500">Could not fetch</p>
|
||||||
|
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
|
||||||
|
{e.title || e.url}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-duck-dark/50">
|
||||||
|
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
|
||||||
|
</p>
|
||||||
|
{e.dl === 'error' && <p className="truncate text-xs text-red-500">{e.dlError}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{e.status === 'ready' && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
{e.dl === 'done' ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs font-medium text-duck-teal">
|
||||||
|
<Check size={14} /> Saved
|
||||||
|
</span>
|
||||||
|
) : e.dl === 'downloading' || e.dl === 'saving' ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-duck-dark/60">
|
||||||
|
<Loader2 size={13} className="animate-spin" /> {dlLabel(e)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void downloadEntry(i, e.url)}
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 rounded-md border border-duck-teal/40 px-2.5 py-1.5 text-xs font-medium text-duck-teal transition-colors hover:bg-duck-teal/10"
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
{e.dl === 'error' ? 'Retry' : 'Download'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -54,6 +54,7 @@ export const useFileBrowserApp = (
|
|||||||
entry: DirEntry;
|
entry: DirEntry;
|
||||||
selectedNames?: string[];
|
selectedNames?: string[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [showVideoDownload, setShowVideoDownload] = useState(false);
|
||||||
const [showDictate, setShowDictate] = useState(false);
|
const [showDictate, setShowDictate] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const { getMatchingTasks, getMatchingTaskGroups } = useTasks();
|
const { getMatchingTasks, getMatchingTaskGroups } = useTasks();
|
||||||
@@ -476,9 +477,6 @@ export const useFileBrowserApp = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Open the ephemeral video-download side panel, targeting the current folder + browser root.
|
|
||||||
const openVideoDownload = () => setSearchParams({ download: currentPath, downloadRoot: rootOverride ?? homeRoot });
|
|
||||||
|
|
||||||
const handleCut = () => {
|
const handleCut = () => {
|
||||||
const paths = selected.size > 0 ? selectedPaths() : [];
|
const paths = selected.size > 0 ? selectedPaths() : [];
|
||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
@@ -667,8 +665,11 @@ export const useFileBrowserApp = (
|
|||||||
setRunningTask,
|
setRunningTask,
|
||||||
getMatchingTasks,
|
getMatchingTasks,
|
||||||
getMatchingTaskGroups,
|
getMatchingTaskGroups,
|
||||||
// Video download (opens an ephemeral side panel)
|
// Files API (for self-contained dialogs like the video downloader)
|
||||||
openVideoDownload,
|
files,
|
||||||
|
// Video download
|
||||||
|
showVideoDownload,
|
||||||
|
setShowVideoDownload,
|
||||||
// Dictate
|
// Dictate
|
||||||
showDictate,
|
showDictate,
|
||||||
setShowDictate,
|
setShowDictate,
|
||||||
|
|||||||
@@ -1,710 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
Loader2,
|
|
||||||
ChevronLeft,
|
|
||||||
AlertCircle,
|
|
||||||
Check,
|
|
||||||
Music,
|
|
||||||
Film,
|
|
||||||
Folder,
|
|
||||||
ExternalLink,
|
|
||||||
CheckCircle2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useFilesAPI, type VideoInfo } from '../../hooks/useFilesAPI';
|
|
||||||
|
|
||||||
// Compact progress shape mirrored from the download-job executor (done = kept/saved, failed = skipped).
|
|
||||||
type JobCounts = { done: number; failed: number; total: number };
|
|
||||||
type JobProgress = {
|
|
||||||
phase: 'expanding' | 'metadata' | 'download' | 'done';
|
|
||||||
meta: JobCounts;
|
|
||||||
dl: JobCounts;
|
|
||||||
current?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ephemeral side-panel video downloader. Opens on the `download` search param (target folder) with
|
|
||||||
// `downloadRoot` the file-browser root. Self-contained: prefetches metadata (ReClip via the platform
|
|
||||||
// proxy), then downloads per entry in a chosen FORMAT (video or audio) as background jobs, bumping the
|
|
||||||
// shared `files:refresh-signal` so the browser re-lists as files land. Format = the ReClip audioOnly flag.
|
|
||||||
|
|
||||||
type Fmt = 'video' | 'audio';
|
|
||||||
type Phase = 'idle' | 'downloading' | 'saving' | 'done' | 'error';
|
|
||||||
type FmtState = { phase: Phase; error?: string };
|
|
||||||
type Entry = {
|
|
||||||
url: string;
|
|
||||||
status: 'loading' | 'ready' | 'error';
|
|
||||||
title?: string;
|
|
||||||
thumbnail?: string;
|
|
||||||
duration?: number;
|
|
||||||
uploader?: string;
|
|
||||||
error?: string;
|
|
||||||
hasVideo: boolean; // whether ReClip reported video formats (audio-only sources → audio button only)
|
|
||||||
video: FmtState;
|
|
||||||
audio: FmtState;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fmtDuration = (sec?: number): string => {
|
|
||||||
if (!sec || sec <= 0) return '';
|
|
||||||
const s = Math.round(sec);
|
|
||||||
const h = Math.floor(s / 3600);
|
|
||||||
const m = Math.floor((s % 3600) / 60);
|
|
||||||
const ss = String(s % 60).padStart(2, '0');
|
|
||||||
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
const sanitizeFolder = (name: string) => name.replace(/[/\\]/g, '').replace(/^\.+/, '').trim();
|
|
||||||
const sub = (e: Entry) => [e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ');
|
|
||||||
|
|
||||||
// ── Card pieces ──
|
|
||||||
|
|
||||||
const Thumb = ({ e, size }: { e: Entry; size: number }) => {
|
|
||||||
if (e.status === 'loading') return <Loader2 size={size} className="animate-spin text-duck-dark/40" />;
|
|
||||||
if (e.status === 'error') return <AlertCircle size={size} className="text-red-500/70" />;
|
|
||||||
if (e.thumbnail) return <img src={e.thumbnail} alt="" className="h-full w-full object-cover" />;
|
|
||||||
return <Music size={size} className="text-duck-dark/40" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
// One format's download control, reflecting its state (button → spinner → check → retry).
|
|
||||||
const FmtButton = ({ fmt, state, onClick }: { fmt: Fmt; state: FmtState; onClick: () => void }) => {
|
|
||||||
const Icon = fmt === 'video' ? Film : Music;
|
|
||||||
const label = fmt === 'video' ? 'Video' : 'Audio';
|
|
||||||
const base = 'flex flex-1 items-center justify-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium';
|
|
||||||
if (state.phase === 'done')
|
|
||||||
return (
|
|
||||||
<span className={`${base} bg-duck-teal/15 text-duck-teal`}>
|
|
||||||
<Check size={13} /> {label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
if (state.phase === 'downloading' || state.phase === 'saving')
|
|
||||||
return (
|
|
||||||
<span className={`${base} bg-duck-dark/5 text-duck-dark/60`}>
|
|
||||||
<Loader2 size={13} className="animate-spin" /> {state.phase === 'saving' ? 'Saving…' : label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={`${base} cursor-pointer border border-duck-teal/40 text-duck-teal transition-colors hover:bg-duck-teal/10`}
|
|
||||||
>
|
|
||||||
<Icon size={13} /> {state.phase === 'error' ? 'Retry' : label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The action area under a card: format buttons (normal) or format checkboxes (select mode).
|
|
||||||
const CardActions = ({
|
|
||||||
e,
|
|
||||||
mode,
|
|
||||||
sel,
|
|
||||||
onToggle,
|
|
||||||
onDownload,
|
|
||||||
}: {
|
|
||||||
e: Entry;
|
|
||||||
mode: 'normal' | 'select';
|
|
||||||
sel: { video?: boolean; audio?: boolean };
|
|
||||||
onToggle: (fmt: Fmt) => void;
|
|
||||||
onDownload: (fmt: Fmt) => void;
|
|
||||||
}) => {
|
|
||||||
if (mode === 'select')
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
||||||
{e.hasVideo && (
|
|
||||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-duck-dark/80">
|
|
||||||
<Checkbox checked={!!sel.video} onCheckedChange={() => onToggle('video')} /> Video
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-duck-dark/80">
|
|
||||||
<Checkbox checked={!!sel.audio} onCheckedChange={() => onToggle('audio')} /> Audio
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{e.hasVideo && <FmtButton fmt="video" state={e.video} onClick={() => onDownload('video')} />}
|
|
||||||
<FmtButton fmt="audio" state={e.audio} onClick={() => onDownload('audio')} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProgressBar = ({ done, total }: { done: number; total: number }) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-duck-dark/10">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-duck-teal transition-all duration-300"
|
|
||||||
style={{ width: `${total ? (done / total) * 100 : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs font-medium tabular-nums text-duck-dark/60">
|
|
||||||
{done}/{total}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const VideoDownloadPanelHeader = () => (
|
|
||||||
<>
|
|
||||||
<Download className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="flex-1 truncate text-xs font-medium">Download video</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const VideoDownloadPanel = () => {
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const basePath = searchParams.get('download') ?? '/';
|
|
||||||
const root = searchParams.get('downloadRoot') ?? 'home';
|
|
||||||
const files = useFilesAPI(root);
|
|
||||||
const client = useClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [, setRefreshSignal] = usePanelChannel<number>('files:refresh-signal', 0);
|
|
||||||
|
|
||||||
const [phase, setPhase] = useState<'input' | 'decide' | 'preview' | 'job'>('input');
|
|
||||||
const [url, setUrl] = useState('');
|
|
||||||
const [expandedUrls, setExpandedUrls] = useState<string[]>([]); // playlist items (count + inline)
|
|
||||||
const [subfolder, setSubfolder] = useState('');
|
|
||||||
const [fetching, setFetching] = useState(false);
|
|
||||||
const [inputError, setInputError] = useState('');
|
|
||||||
const [entries, setEntries] = useState<Entry[]>([]);
|
|
||||||
const [mode, setMode] = useState<'normal' | 'select'>('normal');
|
|
||||||
const [sel, setSel] = useState<Record<number, { video?: boolean; audio?: boolean }>>({});
|
|
||||||
const [bulk, setBulk] = useState<{ done: number; total: number } | null>(null);
|
|
||||||
const [jobFormat, setJobFormat] = useState<Fmt>('audio');
|
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
|
||||||
const [jobProg, setJobProg] = useState<JobProgress | null>(null);
|
|
||||||
const [jobStatus, setJobStatus] = useState<string>('running');
|
|
||||||
const entriesRef = useRef(entries);
|
|
||||||
entriesRef.current = entries;
|
|
||||||
const lastDlDone = useRef(0);
|
|
||||||
|
|
||||||
const isPlaylist = entries.length > 1;
|
|
||||||
const anyReadyVideo = entries.some((e) => e.status === 'ready' && e.hasVideo);
|
|
||||||
const selCount = entries.reduce((n, e, i) => n + (sel[i]?.video && e.hasVideo ? 1 : 0) + (sel[i]?.audio ? 1 : 0), 0);
|
|
||||||
const folderLabel = basePath === '/' ? 'Home' : basePath.split('/').pop() || 'Home';
|
|
||||||
|
|
||||||
const patch = (i: number, p: Partial<Entry>) =>
|
|
||||||
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...p } : e)));
|
|
||||||
const patchFmt = (i: number, fmt: Fmt, p: Partial<FmtState>) =>
|
|
||||||
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, [fmt]: { ...e[fmt], ...p } } : e)));
|
|
||||||
|
|
||||||
// Fetch = expand. A playlist goes to the decision screen (inline vs job) now that we know the count; a
|
|
||||||
// single video goes straight to the inline card.
|
|
||||||
const onFetch = async () => {
|
|
||||||
const u = url.trim();
|
|
||||||
if (!u) return;
|
|
||||||
setInputError('');
|
|
||||||
setEntries([]);
|
|
||||||
setMode('normal');
|
|
||||||
setBulk(null);
|
|
||||||
setSel({});
|
|
||||||
if (u.includes('list=')) {
|
|
||||||
setFetching(true);
|
|
||||||
const pl = await files.videoPlaylist(u).catch(() => null);
|
|
||||||
setFetching(false);
|
|
||||||
if (pl?.error || !pl?.urls?.length) return setInputError(pl?.error || 'No videos found in that playlist');
|
|
||||||
setExpandedUrls(pl.urls);
|
|
||||||
setPhase('decide');
|
|
||||||
} else {
|
|
||||||
setExpandedUrls([u]);
|
|
||||||
setPhase('preview');
|
|
||||||
void fetchInline([u]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Interactive path: fetch each item's metadata sequentially, populating the cards.
|
|
||||||
const fetchInline = async (urls: string[]) => {
|
|
||||||
setFetching(true);
|
|
||||||
setMode('normal');
|
|
||||||
setBulk(null);
|
|
||||||
setSel({});
|
|
||||||
setEntries(
|
|
||||||
urls.map((v) => ({
|
|
||||||
url: v,
|
|
||||||
status: 'loading',
|
|
||||||
hasVideo: true,
|
|
||||||
video: { phase: 'idle' },
|
|
||||||
audio: { phase: 'idle' },
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
for (let i = 0; i < urls.length; i++) {
|
|
||||||
const info = await files.videoInfo(urls[i]!).catch((): VideoInfo => ({ error: 'Could not fetch info' }));
|
|
||||||
if (info.error) patch(i, { status: 'error', error: info.error, hasVideo: false });
|
|
||||||
else
|
|
||||||
patch(i, {
|
|
||||||
status: 'ready',
|
|
||||||
title: info.title,
|
|
||||||
thumbnail: info.thumbnail,
|
|
||||||
duration: info.duration,
|
|
||||||
uploader: info.uploader,
|
|
||||||
hasVideo: (info.formats?.length ?? 1) > 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setFetching(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Job path: hand the whole playlist to the `download-media` script capability as a background job (it
|
|
||||||
// survives the panel closing). We pass the exact expanded video URLs — already individual, no `list=`,
|
|
||||||
// so the task won't re-expand and a Mix/radio playlist can't drift to a different set. cwd is
|
|
||||||
// home-relative (no leading slash) — the capability writes into it.
|
|
||||||
const startJob = async () => {
|
|
||||||
try {
|
|
||||||
const res = await client.post<{ jobId: string; status: string }>('/jobs', {
|
|
||||||
taskDirName: 'download-media',
|
|
||||||
inputs: { url: expandedUrls.join('\n'), format: jobFormat },
|
|
||||||
cwd: targetDir().replace(/^\/+/, ''),
|
|
||||||
action: 'queue',
|
|
||||||
});
|
|
||||||
lastDlDone.current = 0;
|
|
||||||
setJobId(res.jobId);
|
|
||||||
setJobStatus(res.status === 'pending' ? 'pending' : 'running');
|
|
||||||
setJobProg(null);
|
|
||||||
setPhase('job');
|
|
||||||
} catch {
|
|
||||||
setInputError('Could not start the job');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Poll the running job's progress; refresh the browser as each file lands.
|
|
||||||
useEffect(() => {
|
|
||||||
if (phase !== 'job' || !jobId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const poll = async () => {
|
|
||||||
const j = await client.get<{ status: string; progress: JobProgress | null }>(`/jobs/${jobId}`).catch(() => null);
|
|
||||||
if (cancelled || !j) return;
|
|
||||||
setJobStatus(j.status);
|
|
||||||
setJobProg(j.progress);
|
|
||||||
if (j.progress && j.progress.dl.done > lastDlDone.current) {
|
|
||||||
lastDlDone.current = j.progress.dl.done;
|
|
||||||
setRefreshSignal((n) => n + 1);
|
|
||||||
}
|
|
||||||
if (['completed', 'failed', 'stopped', 'interrupted'].includes(j.status)) clearInterval(timer);
|
|
||||||
};
|
|
||||||
void poll();
|
|
||||||
const timer = setInterval(poll, 2000);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(timer);
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [phase, jobId]);
|
|
||||||
|
|
||||||
const targetDir = () => {
|
|
||||||
const s = sanitizeFolder(subfolder);
|
|
||||||
if (!s) return basePath;
|
|
||||||
return basePath === '/' ? `/${s}` : `${basePath}/${s}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Download one entry in one format (video/audio) as a background job, polling to completion.
|
|
||||||
const downloadFmt = async (i: number, fmt: Fmt) => {
|
|
||||||
patchFmt(i, fmt, { phase: 'downloading', error: undefined });
|
|
||||||
try {
|
|
||||||
const { jobId } = await files.downloadVideo(entriesRef.current[i]!.url, targetDir(), fmt === 'audio');
|
|
||||||
const deadline = Date.now() + 60 * 60 * 1000;
|
|
||||||
for (;;) {
|
|
||||||
if (Date.now() > deadline) return patchFmt(i, fmt, { phase: 'error', error: 'Timed out' });
|
|
||||||
await sleep(2000);
|
|
||||||
const st = await files.downloadVideoStatus(jobId).catch(() => null);
|
|
||||||
if (!st) continue;
|
|
||||||
if (st.status === 'error') return patchFmt(i, fmt, { phase: 'error', error: st.error || 'Download failed' });
|
|
||||||
if (st.status === 'transferring') patchFmt(i, fmt, { phase: 'saving' });
|
|
||||||
if (st.status === 'done') {
|
|
||||||
patchFmt(i, fmt, { phase: 'done' });
|
|
||||||
setRefreshSignal((n) => n + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
patchFmt(i, fmt, { phase: 'error', error: 'Could not start the download' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run a set of (entry, format) downloads sequentially, driving the top progress bar.
|
|
||||||
const runBulk = async (pairs: Array<{ i: number; fmt: Fmt }>) => {
|
|
||||||
if (!pairs.length) return;
|
|
||||||
setBulk({ done: 0, total: pairs.length });
|
|
||||||
for (const { i, fmt } of pairs) {
|
|
||||||
await downloadFmt(i, fmt);
|
|
||||||
setBulk((b) => (b ? { ...b, done: b.done + 1 } : b));
|
|
||||||
}
|
|
||||||
setBulk(null); // per-card "Saved" chips remain as the completion signal
|
|
||||||
};
|
|
||||||
|
|
||||||
const readyPairs = (fmt: Fmt) =>
|
|
||||||
entries.flatMap((e, i) => (e.status === 'ready' && (fmt === 'audio' || e.hasVideo) ? [{ i, fmt }] : []));
|
|
||||||
|
|
||||||
const startSelected = () => {
|
|
||||||
const pairs: Array<{ i: number; fmt: Fmt }> = [];
|
|
||||||
entries.forEach((e, i) => {
|
|
||||||
if (sel[i]?.video && e.hasVideo) pairs.push({ i, fmt: 'video' });
|
|
||||||
if (sel[i]?.audio) pairs.push({ i, fmt: 'audio' });
|
|
||||||
});
|
|
||||||
setMode('normal');
|
|
||||||
void runBulk(pairs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSel = (i: number, fmt: Fmt) => setSel((s) => ({ ...s, [i]: { ...s[i], [fmt]: !s[i]?.[fmt] } }));
|
|
||||||
|
|
||||||
const btn =
|
|
||||||
'cursor-pointer rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col gap-3 overflow-y-auto p-3">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-duck-dark/60">
|
|
||||||
<Folder size={13} className="shrink-0" />
|
|
||||||
<span className="truncate">Saving to {folderLabel}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{phase === 'input' && (
|
|
||||||
<form
|
|
||||||
onSubmit={(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
void onFetch();
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={url}
|
|
||||||
onChange={(ev) => setUrl(ev.target.value)}
|
|
||||||
placeholder="https://www.youtube.com/watch?v=… or …/playlist?list=…"
|
|
||||||
className="h-10 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50"
|
|
||||||
/>
|
|
||||||
{inputError && <p className="text-xs text-red-500">{inputError}</p>}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!url.trim() || fetching}
|
|
||||||
className={`${btn} flex items-center justify-center gap-2 bg-duck-teal text-duck-yellow hover:bg-duck-teal/90`}
|
|
||||||
>
|
|
||||||
{fetching && <Loader2 size={14} className="animate-spin" />}
|
|
||||||
Fetch
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === 'decide' && (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPhase('input')}
|
|
||||||
className="flex cursor-pointer items-center gap-1 self-start text-sm text-duck-dark/60 hover:text-duck-dark"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={15} /> Back
|
|
||||||
</button>
|
|
||||||
<p className="text-sm text-duck-dark">
|
|
||||||
Found <span className="font-semibold">{expandedUrls.length}</span> items.
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
value={subfolder}
|
|
||||||
onChange={(ev) => setSubfolder(ev.target.value)}
|
|
||||||
placeholder="Subfolder (optional) — leave blank for this folder"
|
|
||||||
className="h-9 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{(['audio', 'video'] as Fmt[]).map((f) => (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setJobFormat(f)}
|
|
||||||
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
||||||
jobFormat === f
|
|
||||||
? 'bg-duck-teal text-duck-yellow'
|
|
||||||
: 'border border-duck-dark/20 text-duck-dark/70 hover:bg-duck-dark/5'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{f === 'audio' ? <Music size={14} /> : <Film size={14} />} {f === 'audio' ? 'Audio' : 'Video'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void startJob()}
|
|
||||||
className={`${btn} flex items-center justify-center gap-2 bg-duck-teal text-duck-yellow hover:bg-duck-teal/90`}
|
|
||||||
>
|
|
||||||
<Download size={15} /> Download all as a job
|
|
||||||
</button>
|
|
||||||
{inputError && <p className="text-xs text-red-500">{inputError}</p>}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setPhase('preview');
|
|
||||||
void fetchInline(expandedUrls);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer text-xs text-duck-dark/50 hover:text-duck-dark/80"
|
|
||||||
>
|
|
||||||
or fetch inline to pick individually
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === 'job' && (
|
|
||||||
<JobView
|
|
||||||
prog={jobProg}
|
|
||||||
status={jobStatus}
|
|
||||||
audio={jobFormat === 'audio'}
|
|
||||||
onOpen={() => jobId && navigate(`/jobs/${jobId}`)}
|
|
||||||
onNew={() => {
|
|
||||||
setPhase('input');
|
|
||||||
setUrl('');
|
|
||||||
setJobId(null);
|
|
||||||
setJobProg(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === 'preview' && (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPhase(expandedUrls.length > 1 ? 'decide' : 'input')}
|
|
||||||
className="flex cursor-pointer items-center gap-1 text-sm text-duck-dark/60 hover:text-duck-dark"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={15} /> Back
|
|
||||||
</button>
|
|
||||||
{isPlaylist && <span className="text-xs text-duck-dark/50">{entries.length} items</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isPlaylist && (
|
|
||||||
<input
|
|
||||||
value={subfolder}
|
|
||||||
onChange={(ev) => setSubfolder(ev.target.value)}
|
|
||||||
placeholder="Subfolder (optional) — leave blank for this folder"
|
|
||||||
className="h-9 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bulk action row (playlists only) — progress while running, otherwise the format actions. */}
|
|
||||||
{isPlaylist &&
|
|
||||||
(bulk ? (
|
|
||||||
<ProgressBar done={bulk.done} total={bulk.total} />
|
|
||||||
) : mode === 'select' ? (
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMode('normal')}
|
|
||||||
className={`${btn} text-duck-dark/70 hover:bg-duck-dark/5`}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={selCount === 0}
|
|
||||||
onClick={startSelected}
|
|
||||||
className={`${btn} bg-duck-teal text-duck-yellow hover:bg-duck-teal/90`}
|
|
||||||
>
|
|
||||||
Start download{selCount ? ` (${selCount})` : ''}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{anyReadyVideo && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void runBulk(readyPairs('video'))}
|
|
||||||
className={`${btn} flex items-center gap-1.5 bg-duck-teal text-duck-yellow hover:bg-duck-teal/90`}
|
|
||||||
>
|
|
||||||
<Film size={14} /> All video
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void runBulk(readyPairs('audio'))}
|
|
||||||
className={`${btn} flex items-center gap-1.5 bg-duck-teal text-duck-yellow hover:bg-duck-teal/90`}
|
|
||||||
>
|
|
||||||
<Music size={14} /> All audio
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSel({});
|
|
||||||
setMode('select');
|
|
||||||
}}
|
|
||||||
className={`${btn} border border-duck-teal/40 text-duck-teal hover:bg-duck-teal/10`}
|
|
||||||
>
|
|
||||||
Select
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
||||||
{entries.length === 1 ? (
|
|
||||||
<SingleCard e={entries[0]!} onDownload={(fmt) => void downloadFmt(0, fmt)} />
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-3 @min-[520px]:grid-cols-3 @min-[760px]:grid-cols-4">
|
|
||||||
{entries.map((e, i) => (
|
|
||||||
<GridCard
|
|
||||||
key={`${e.url}-${i}`}
|
|
||||||
e={e}
|
|
||||||
number={i + 1}
|
|
||||||
mode={mode}
|
|
||||||
sel={sel[i] ?? {}}
|
|
||||||
onToggle={(fmt) => toggleSel(i, fmt)}
|
|
||||||
onDownload={(fmt) => void downloadFmt(i, fmt)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Job progress view ──
|
|
||||||
|
|
||||||
const JobView = ({
|
|
||||||
prog,
|
|
||||||
status,
|
|
||||||
audio,
|
|
||||||
onOpen,
|
|
||||||
onNew,
|
|
||||||
}: {
|
|
||||||
prog: JobProgress | null;
|
|
||||||
status: string;
|
|
||||||
audio: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
onNew: () => void;
|
|
||||||
}) => {
|
|
||||||
const terminal = ['completed', 'failed', 'stopped', 'interrupted'].includes(status);
|
|
||||||
const phaseLabel =
|
|
||||||
!prog || prog.phase === 'expanding'
|
|
||||||
? 'Preparing…'
|
|
||||||
: prog.phase === 'metadata'
|
|
||||||
? 'Fetching titles'
|
|
||||||
: prog.phase === 'download'
|
|
||||||
? 'Downloading'
|
|
||||||
: 'Done';
|
|
||||||
const Bar = ({ label, c, saved, failed }: { label: string; c: JobCounts; saved: string; failed: string }) => {
|
|
||||||
const pct = c.total ? ((c.done + c.failed) / c.total) * 100 : 0;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="font-medium text-duck-dark">{label}</span>
|
|
||||||
<span className="tabular-nums text-duck-dark/50">
|
|
||||||
{c.done + c.failed}/{c.total || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-duck-dark/10">
|
|
||||||
<div className="h-full rounded-full bg-duck-teal transition-all duration-300" style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 text-[10px] text-duck-dark/50">
|
|
||||||
<span className="text-duck-teal">
|
|
||||||
{c.done} {saved}
|
|
||||||
</span>
|
|
||||||
{c.failed > 0 && (
|
|
||||||
<span className="text-red-500/80">
|
|
||||||
{c.failed} {failed}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
{terminal ? (
|
|
||||||
status === 'completed' ? (
|
|
||||||
<CheckCircle2 size={16} className="text-duck-teal" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle size={16} className="text-red-500" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Loader2 size={16} className="animate-spin text-amber-500" />
|
|
||||||
)}
|
|
||||||
<span className="font-medium text-duck-dark">
|
|
||||||
{terminal ? status[0]!.toUpperCase() + status.slice(1) : phaseLabel}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto text-xs text-duck-dark/40">{audio ? 'Audio' : 'Video'}</span>
|
|
||||||
</div>
|
|
||||||
<Bar label="Titles" c={prog?.meta ?? { done: 0, failed: 0, total: 0 }} saved="found" failed="skipped" />
|
|
||||||
<Bar label="Download" c={prog?.dl ?? { done: 0, failed: 0, total: 0 }} saved="saved" failed="failed" />
|
|
||||||
{!terminal && prog?.phase === 'download' && prog.current && (
|
|
||||||
<p className="truncate text-xs text-duck-dark/50" title={prog.current}>
|
|
||||||
{prog.current}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpen}
|
|
||||||
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-duck-teal/40 px-3 py-2 text-sm font-medium text-duck-teal hover:bg-duck-teal/10"
|
|
||||||
>
|
|
||||||
<ExternalLink size={14} /> View in Jobs
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onNew}
|
|
||||||
className="cursor-pointer rounded-md px-3 py-2 text-sm text-duck-dark/60 hover:bg-duck-dark/5"
|
|
||||||
>
|
|
||||||
New
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-duck-dark/40">Runs on the server — you can close this panel; it keeps going.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Cards ──
|
|
||||||
|
|
||||||
const GridCard = ({
|
|
||||||
e,
|
|
||||||
number,
|
|
||||||
mode,
|
|
||||||
sel,
|
|
||||||
onToggle,
|
|
||||||
onDownload,
|
|
||||||
}: {
|
|
||||||
e: Entry;
|
|
||||||
number: number;
|
|
||||||
mode: 'normal' | 'select';
|
|
||||||
sel: { video?: boolean; audio?: boolean };
|
|
||||||
onToggle: (fmt: Fmt) => void;
|
|
||||||
onDownload: (fmt: Fmt) => void;
|
|
||||||
}) => (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="relative flex aspect-video items-center justify-center overflow-hidden rounded-md bg-duck-dark/10">
|
|
||||||
<Thumb e={e} size={18} />
|
|
||||||
<span className="absolute left-1 top-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-white">
|
|
||||||
{number}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{e.status !== 'loading' && (
|
|
||||||
<>
|
|
||||||
<p className="truncate text-xs font-medium text-duck-dark" title={e.title}>
|
|
||||||
{e.status === 'error' ? 'Could not fetch' : e.title || e.url}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-[10px] text-duck-dark/50">{e.status === 'error' ? e.error || '' : sub(e)}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{e.status === 'ready' && <CardActions e={e} mode={mode} sel={sel} onToggle={onToggle} onDownload={onDownload} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const SingleCard = ({ e, onDownload }: { e: Entry; onDownload: (fmt: Fmt) => void }) => (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg bg-duck-dark/10">
|
|
||||||
<Thumb e={e} size={36} />
|
|
||||||
</div>
|
|
||||||
{e.status === 'error' ? (
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-red-500">Could not fetch</p>
|
|
||||||
<p className="break-all text-sm text-duck-dark/50">{e.error || e.url}</p>
|
|
||||||
</div>
|
|
||||||
) : e.status === 'ready' ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<p className="text-base font-semibold text-duck-dark" title={e.title}>
|
|
||||||
{e.title || e.url}
|
|
||||||
</p>
|
|
||||||
{sub(e) && <p className="text-sm text-duck-dark/60">{sub(e)}</p>}
|
|
||||||
</div>
|
|
||||||
<CardActions e={e} mode="normal" sel={{}} onToggle={() => {}} onDownload={onDownload} />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -28,12 +28,6 @@ export const singleChatLayout: LayoutNode = {
|
|||||||
appType: null,
|
appType: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const singleDownloadLayout: LayoutNode = {
|
|
||||||
type: 'panel',
|
|
||||||
id: 'files-download',
|
|
||||||
appType: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const viewerWithEphemeralSplitLayout: LayoutNode = {
|
export const viewerWithEphemeralSplitLayout: LayoutNode = {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'files-viewer-group',
|
id: 'files-viewer-group',
|
||||||
|
|||||||
@@ -2,31 +2,11 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import type { EphemeralPanels } from '../../components/Workspace';
|
import type { EphemeralPanels } from '../../components/Workspace';
|
||||||
import { FileViewerHeader, FileViewerBody } from '../../apps/FileViewer';
|
import { FileViewerHeader, FileViewerBody } from '../../apps/FileViewer';
|
||||||
import {
|
import { singleViewerLayout, singleCliampLayout, viewerWithEphemeralLayout, viewerWithEphemeralSplitLayout, singleChatLayout } from './layouts';
|
||||||
singleViewerLayout,
|
|
||||||
singleCliampLayout,
|
|
||||||
viewerWithEphemeralLayout,
|
|
||||||
viewerWithEphemeralSplitLayout,
|
|
||||||
singleChatLayout,
|
|
||||||
singleDownloadLayout,
|
|
||||||
} from './layouts';
|
|
||||||
import { ViewerProvider, EphemeralProvider, Ephemeral2Provider, ChatEphemeralBody } from './Providers';
|
import { ViewerProvider, EphemeralProvider, Ephemeral2Provider, ChatEphemeralBody } from './Providers';
|
||||||
import { CliampPanelHeader, CliampPanelBody } from '../../apps/FileBrowser/CliampPanel';
|
import { CliampPanelHeader, CliampPanelBody } from '../../apps/FileBrowser/CliampPanel';
|
||||||
import { VideoDownloadPanel, VideoDownloadPanelHeader } from '../../apps/FileBrowser/VideoDownloadPanel';
|
|
||||||
|
|
||||||
const EPHEMERAL_KEYS = [
|
const EPHEMERAL_KEYS = ['view', 'ephemeral', 'ephemeralRoot', 'ephemeral2', 'ephemeral2Root', 'ephemeral2Auto', 'chatContext', 'chatType', 'play'];
|
||||||
'view',
|
|
||||||
'ephemeral',
|
|
||||||
'ephemeralRoot',
|
|
||||||
'ephemeral2',
|
|
||||||
'ephemeral2Root',
|
|
||||||
'ephemeral2Auto',
|
|
||||||
'chatContext',
|
|
||||||
'chatType',
|
|
||||||
'play',
|
|
||||||
'download',
|
|
||||||
'downloadRoot',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const useFileViewerPanels = (): EphemeralPanels | null => {
|
export const useFileViewerPanels = (): EphemeralPanels | null => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -50,19 +30,16 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
|||||||
const ephemeral2Path = searchParams.get('ephemeral2');
|
const ephemeral2Path = searchParams.get('ephemeral2');
|
||||||
const chatContext = searchParams.get('chatContext');
|
const chatContext = searchParams.get('chatContext');
|
||||||
const playPath = searchParams.get('play');
|
const playPath = searchParams.get('play');
|
||||||
const downloadPath = searchParams.get('download');
|
|
||||||
|
|
||||||
const layout = downloadPath
|
const layout = playPath
|
||||||
? singleDownloadLayout
|
? singleCliampLayout
|
||||||
: playPath
|
: chatContext
|
||||||
? singleCliampLayout
|
? singleChatLayout
|
||||||
: chatContext
|
: viewPath && ephemeralPath && ephemeral2Path
|
||||||
? singleChatLayout
|
? viewerWithEphemeralSplitLayout
|
||||||
: viewPath && ephemeralPath && ephemeral2Path
|
: viewPath && ephemeralPath
|
||||||
? viewerWithEphemeralSplitLayout
|
? viewerWithEphemeralLayout
|
||||||
: viewPath && ephemeralPath
|
: singleViewerLayout;
|
||||||
? viewerWithEphemeralLayout
|
|
||||||
: singleViewerLayout;
|
|
||||||
|
|
||||||
const onCloseViewer = useCallback(() => setSearchParams({}), [setSearchParams]);
|
const onCloseViewer = useCallback(() => setSearchParams({}), [setSearchParams]);
|
||||||
|
|
||||||
@@ -113,17 +90,6 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
|||||||
[setSearchParams],
|
[setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCloseDownload = useCallback(
|
|
||||||
() =>
|
|
||||||
setSearchParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
next.delete('download');
|
|
||||||
next.delete('downloadRoot');
|
|
||||||
return next;
|
|
||||||
}),
|
|
||||||
[setSearchParams],
|
|
||||||
);
|
|
||||||
|
|
||||||
const components = useMemo(
|
const components = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
'files-cliamp': {
|
'files-cliamp': {
|
||||||
@@ -153,17 +119,12 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
|||||||
component: ChatEphemeralBody,
|
component: ChatEphemeralBody,
|
||||||
onClose: onCloseChat,
|
onClose: onCloseChat,
|
||||||
},
|
},
|
||||||
'files-download': {
|
|
||||||
header: VideoDownloadPanelHeader,
|
|
||||||
component: VideoDownloadPanel,
|
|
||||||
onClose: onCloseDownload,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay, onCloseDownload],
|
[onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!viewPath && !chatContext && !playPath && !downloadPath) return null;
|
if (!viewPath && !chatContext && !playPath) return null;
|
||||||
const onClose = downloadPath ? onCloseDownload : playPath ? onClosePlay : onCloseViewer;
|
const onClose = playPath ? onClosePlay : onCloseViewer;
|
||||||
return { layout, components, defaultBaseSize: 40, onClose };
|
return { layout, components, defaultBaseSize: 40, onClose };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user