From 0ab82964b902645ea8251df7e91863c3b21008c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 28 Jul 2026 14:33:51 +0000 Subject: [PATCH] file browser: video downloader as an ephemeral side panel (was a modal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the video-download UI from a modal into the same ephemeral side-panel slot the file viewer uses (double-click a video), per request. It's driven by the `download` search param (the target folder) + `downloadRoot`, exactly like the existing view/play/chat ephemeral panels. - layouts: singleDownloadLayout (files-download panel). - VideoDownloadPanel (new, apps/FileBrowser): self-contained — reads the target folder/root from the params, does its own useFilesAPI, prefetch + per-entry / download-all flow (unchanged from the dialog), and bumps the shared `files:refresh-signal` so the browser re-lists when a file lands. Header shows a "Saving to " hint. - useFileViewerPanels: register files-download (param → layout → panel + close), add download/downloadRoot to the on-refresh cleanup keys. - useFileBrowserApp: replace the showVideoDownload modal state with openVideoDownload(), which sets the download param for the current folder+root. - Toolbar + FileViewContainer trigger openVideoDownload(); drop the modal mount and delete VideoDownloadDialog.tsx. Co-Authored-By: Claude Opus 4.8 --- .../FileBrowserApp/FileBrowserApp.tsx | 7 +- .../components/FileViewContainer.tsx | 22 +- .../components/Toolbar/Toolbar.tsx | 12 +- .../components/VideoDownloadDialog.tsx | 314 ------------------ .../FileBrowserApp/useFileBrowserApp.ts | 11 +- .../apps/FileBrowser/VideoDownloadPanel.tsx | 293 ++++++++++++++++ .../src/hooks/useFileViewerPanels/layouts.ts | 6 + .../useFileViewerPanels.tsx | 67 +++- 8 files changed, 383 insertions(+), 349 deletions(-) delete mode 100644 src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx index f22bf1d5..2786a67c 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx @@ -3,7 +3,6 @@ import { Toolbar } from './components/Toolbar'; import { UploadProgress } from './components/UploadProgress'; import { FileViewContainer } from './components/FileViewContainer'; import { TaskRunnerDialog } from './components/TaskRunnerDialog'; -import { VideoDownloadDialog } from './components/VideoDownloadDialog'; import { DictateDialog } from './components/DictateDialog'; import { useFileBrowserApp } from './useFileBrowserApp'; @@ -26,15 +25,11 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, initialPath, defa return (
- + -
); }; - diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx index ec965e77..dc2bd46d 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx @@ -1,5 +1,17 @@ import { useRef } from 'react'; -import { Loader2, Folder, ClipboardPaste, FolderPlus, FolderUp, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download, Mic } from 'lucide-react'; +import { + Loader2, + Folder, + ClipboardPaste, + FolderPlus, + FolderUp, + LayoutGrid, + Upload, + ClipboardCopy, + MessageSquare, + Download, + Mic, +} from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import type { UseFileBrowserAppType } from '../useFileBrowserApp'; @@ -28,7 +40,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps handleChatHere, handleCreateDir, handleCreateDashboardHere, - setShowVideoDownload, + openVideoDownload, setShowDictate, handleUpload, } = fileBrowserManager; @@ -92,9 +104,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps })} ) : searchResults ? ( -
- No results found -
+
No results found
) : null} ) : ( @@ -145,7 +155,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps Create Dashboard here - setShowVideoDownload(true)} className="cursor-pointer"> + openVideoDownload()} className="cursor-pointer"> Download video diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/Toolbar.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/Toolbar.tsx index f48f5101..26ba6736 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/Toolbar.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/Toolbar.tsx @@ -21,7 +21,7 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => { setShowHidden, viewMode, setViewMode, - setShowVideoDownload, + openVideoDownload, setShowDictate, hiddenForced, } = fileBrowserManager; @@ -76,7 +76,7 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => { ) : ( <> diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx deleted file mode 100644 index 5539661d..00000000 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx +++ /dev/null @@ -1,314 +0,0 @@ -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([]); - 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) => - 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 ( - !open && handleClose()}> - - - Download video - - {phase === 'input' - ? 'Paste a video or playlist URL — it fetches details before downloading.' - : 'Review and download.'} - - - - {phase === 'input' ? ( -
{ - ev.preventDefault(); - void fetchMeta(); - }} - className="flex flex-col gap-4" - > - 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" - /> - -
- - -
-
- ) : ( -
-
- - - {audioOnly ? 'Audio only' : 'Video'} - {isPlaylist ? ` · ${entries.length} items` : ''} - -
- - {isPlaylist && ( -
- 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" - /> - -
- )} - -
- {entries.map((e, i) => ( -
-
- {e.status === 'loading' ? ( - - ) : e.status === 'error' ? ( - - ) : e.thumbnail && !audioOnly ? ( - - ) : ( - - )} -
- -
- {e.status === 'loading' ? ( -
-
-
-
- ) : e.status === 'error' ? ( - <> -

Could not fetch

-

{e.error || e.url}

- - ) : ( - <> -

- {e.title || e.url} -

-

- {[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')} -

- {e.dl === 'error' &&

{e.dlError}

} - - )} -
- - {e.status === 'ready' && ( -
- {e.dl === 'done' ? ( - - Saved - - ) : e.dl === 'downloading' || e.dl === 'saving' ? ( - - {dlLabel(e)} - - ) : ( - - )} -
- )} -
- ))} -
-
- )} - -
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 67d0ff50..2d90d749 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -54,7 +54,6 @@ export const useFileBrowserApp = ( entry: DirEntry; selectedNames?: string[]; } | null>(null); - const [showVideoDownload, setShowVideoDownload] = useState(false); const [showDictate, setShowDictate] = useState(false); const dragCounter = useRef(0); const { getMatchingTasks, getMatchingTaskGroups } = useTasks(); @@ -477,6 +476,9 @@ 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 paths = selected.size > 0 ? selectedPaths() : []; if (paths.length === 0) return; @@ -665,11 +667,8 @@ export const useFileBrowserApp = ( setRunningTask, getMatchingTasks, getMatchingTaskGroups, - // Files API (for self-contained dialogs like the video downloader) - files, - // Video download - showVideoDownload, - setShowVideoDownload, + // Video download (opens an ephemeral side panel) + openVideoDownload, // Dictate showDictate, setShowDictate, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx new file mode 100644 index 00000000..b91661e9 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx @@ -0,0 +1,293 @@ +import { useRef, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Download, Loader2, ChevronLeft, AlertCircle, Check, Music, Folder } from 'lucide-react'; +import { useFilesAPI, type VideoInfo } from '../../hooks/useFilesAPI'; + +// Ephemeral side-panel version of the video downloader (replaces the old modal). It opens on the +// `download` search param — the target folder — with `downloadRoot` the file-browser root. Self-contained: +// it prefetches metadata (ReClip via the platform proxy), downloads entries as background jobs, and bumps +// the shared `files:refresh-signal` so the browser re-lists once a file lands. Mirrors ReClip's own UI. + +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)); +const sanitizeFolder = (name: string) => name.replace(/[/\\]/g, '').replace(/^\.+/, '').trim(); + +export const VideoDownloadPanelHeader = () => ( + <> + + Download video + +); + +export const VideoDownloadPanel = () => { + const [searchParams] = useSearchParams(); + const basePath = searchParams.get('download') ?? '/'; + const root = searchParams.get('downloadRoot') ?? 'home'; + const files = useFilesAPI(root); + const [, setRefreshSignal] = usePanelChannel('files:refresh-signal', 0); + + 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([]); + const [downloadingAll, setDownloadingAll] = useState(false); + const entriesRef = useRef(entries); + entriesRef.current = entries; + + const isPlaylist = entries.length > 1; + const readyCount = entries.filter((e) => e.status === 'ready').length; + const folderLabel = basePath === '/' ? 'Home' : basePath.split('/').pop() || 'Home'; + + const patch = (i: number, p: Partial) => + setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...p } : e))); + + 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 runs yt-dlp per video); 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 basePath; + return basePath === '/' ? `/${sub}` : `${basePath}/${sub}`; + }; + + 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 }); + setRefreshSignal((n) => n + 1); + return; + } + } + } catch { + patch(i, { dl: 'error', dlError: 'Could not start the download' }); + } + }; + + 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 ( +
+
+ + Saving to {folderLabel} +
+ + {phase === 'input' ? ( +
{ + ev.preventDefault(); + void fetchMeta(); + }} + className="flex flex-col gap-3" + > + 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" + /> + + +
+ ) : ( +
+
+ + + {audioOnly ? 'Audio only' : 'Video'} + {isPlaylist ? ` · ${entries.length} items` : ''} + +
+ + {isPlaylist && ( +
+ 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" + /> + +
+ )} + +
+ {entries.map((e, i) => ( +
+
+ {e.status === 'loading' ? ( + + ) : e.status === 'error' ? ( + + ) : e.thumbnail && !audioOnly ? ( + + ) : ( + + )} +
+ +
+ {e.status === 'loading' ? ( +
+
+
+
+ ) : e.status === 'error' ? ( + <> +

Could not fetch

+

{e.error || e.url}

+ + ) : ( + <> +

+ {e.title || e.url} +

+

+ {[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')} +

+ {e.dl === 'error' &&

{e.dlError}

} + + )} +
+ + {e.status === 'ready' && ( +
+ {e.dl === 'done' ? ( + + Saved + + ) : e.dl === 'downloading' || e.dl === 'saving' ? ( + + {dlLabel(e)} + + ) : ( + + )} +
+ )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts index ed10067e..85527f2c 100644 --- a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts +++ b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts @@ -28,6 +28,12 @@ export const singleChatLayout: LayoutNode = { appType: null, }; +export const singleDownloadLayout: LayoutNode = { + type: 'panel', + id: 'files-download', + appType: null, +}; + export const viewerWithEphemeralSplitLayout: LayoutNode = { type: 'group', id: 'files-viewer-group', diff --git a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx index d79b640c..09cba6f0 100644 --- a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx +++ b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx @@ -2,11 +2,31 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useSearchParams } from 'react-router'; import type { EphemeralPanels } from '../../components/Workspace'; import { FileViewerHeader, FileViewerBody } from '../../apps/FileViewer'; -import { singleViewerLayout, singleCliampLayout, viewerWithEphemeralLayout, viewerWithEphemeralSplitLayout, singleChatLayout } from './layouts'; +import { + singleViewerLayout, + singleCliampLayout, + viewerWithEphemeralLayout, + viewerWithEphemeralSplitLayout, + singleChatLayout, + singleDownloadLayout, +} from './layouts'; import { ViewerProvider, EphemeralProvider, Ephemeral2Provider, ChatEphemeralBody } from './Providers'; import { CliampPanelHeader, CliampPanelBody } from '../../apps/FileBrowser/CliampPanel'; +import { VideoDownloadPanel, VideoDownloadPanelHeader } from '../../apps/FileBrowser/VideoDownloadPanel'; -const EPHEMERAL_KEYS = ['view', 'ephemeral', 'ephemeralRoot', 'ephemeral2', 'ephemeral2Root', 'ephemeral2Auto', 'chatContext', 'chatType', 'play']; +const EPHEMERAL_KEYS = [ + 'view', + 'ephemeral', + 'ephemeralRoot', + 'ephemeral2', + 'ephemeral2Root', + 'ephemeral2Auto', + 'chatContext', + 'chatType', + 'play', + 'download', + 'downloadRoot', +]; export const useFileViewerPanels = (): EphemeralPanels | null => { const [searchParams, setSearchParams] = useSearchParams(); @@ -30,16 +50,19 @@ export const useFileViewerPanels = (): EphemeralPanels | null => { const ephemeral2Path = searchParams.get('ephemeral2'); const chatContext = searchParams.get('chatContext'); const playPath = searchParams.get('play'); + const downloadPath = searchParams.get('download'); - const layout = playPath - ? singleCliampLayout - : chatContext - ? singleChatLayout - : viewPath && ephemeralPath && ephemeral2Path - ? viewerWithEphemeralSplitLayout - : viewPath && ephemeralPath - ? viewerWithEphemeralLayout - : singleViewerLayout; + const layout = downloadPath + ? singleDownloadLayout + : playPath + ? singleCliampLayout + : chatContext + ? singleChatLayout + : viewPath && ephemeralPath && ephemeral2Path + ? viewerWithEphemeralSplitLayout + : viewPath && ephemeralPath + ? viewerWithEphemeralLayout + : singleViewerLayout; const onCloseViewer = useCallback(() => setSearchParams({}), [setSearchParams]); @@ -90,6 +113,17 @@ export const useFileViewerPanels = (): EphemeralPanels | null => { [setSearchParams], ); + const onCloseDownload = useCallback( + () => + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.delete('download'); + next.delete('downloadRoot'); + return next; + }), + [setSearchParams], + ); + const components = useMemo( () => ({ 'files-cliamp': { @@ -119,12 +153,17 @@ export const useFileViewerPanels = (): EphemeralPanels | null => { component: ChatEphemeralBody, onClose: onCloseChat, }, + 'files-download': { + header: VideoDownloadPanelHeader, + component: VideoDownloadPanel, + onClose: onCloseDownload, + }, }), - [onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay], + [onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay, onCloseDownload], ); - if (!viewPath && !chatContext && !playPath) return null; - const onClose = playPath ? onClosePlay : onCloseViewer; + if (!viewPath && !chatContext && !playPath && !downloadPath) return null; + const onClose = downloadPath ? onCloseDownload : playPath ? onClosePlay : onCloseViewer; return { layout, components, defaultBaseSize: 40, onClose }; };