diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx b/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx index d547fb45..c18d1b85 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx @@ -1,10 +1,15 @@ import { useRef, useCallback, useMemo, useState, useEffect } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; +import { ChevronUp, ChevronDown } from 'lucide-react'; import type { DirEntry, TaskSummary } from 'apps/FileBrowser'; +import { getFileType } from 'apps/FileViewer'; import { FileItem } from './FileItem'; type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null; +type SortField = 'name' | 'size' | 'type' | 'date'; +type SortDirection = 'asc' | 'desc'; + type FileGridProps = { entries: DirEntry[]; viewMode: 'grid' | 'list'; @@ -21,6 +26,11 @@ type FileGridProps = { onCopy: () => void; renamingName: string | null; onRenamingChange: (name: string | null) => void; + onReadAloud: (entry: DirEntry) => void; + onOcr: (entry: DirEntry) => void; + onTranscribe: (entry: DirEntry) => void; + onExtractAudio: (entry: DirEntry) => void; + onExtract: (entry: DirEntry) => void; getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; onCreateWorkspace: (entry: DirEntry) => void; @@ -30,6 +40,7 @@ type FileGridProps = { const LIST_ROW_HEIGHT = 42; const GRID_ROW_HEIGHT = 130; const GRID_GAP = 12; +const HEADER_HEIGHT = 36; const useColumnCount = (scrollRef: React.RefObject) => { const [cols, setCols] = useState(4); @@ -71,21 +82,55 @@ export const FileGrid = ({ onCopy, renamingName, onRenamingChange, + onReadAloud, + onOcr, + onTranscribe, + onExtractAudio, + onExtract, getMatchingTasks, onRunTask, onCreateWorkspace, scrollRef, }: FileGridProps) => { const lastClickedIdx = useRef(-1); + const [sortField, setSortField] = useState('name'); + const [sortDirection, setSortDirection] = useState('asc'); - const sorted = useMemo( - () => - [...entries].sort((a, b) => { - if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; - return a.name.localeCompare(b.name); - }), - [entries], - ); + const sorted = useMemo(() => { + const compare = (a: DirEntry, b: DirEntry): number => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + let result: number; + switch (sortField) { + case 'name': + result = a.name.localeCompare(b.name); + break; + case 'size': + result = a.size - b.size; + break; + case 'type': { + const typeA = a.type === 'directory' ? 'directory' : getFileType(a.name); + const typeB = b.type === 'directory' ? 'directory' : getFileType(b.name); + result = typeA.localeCompare(typeB); + if (result === 0) result = a.name.localeCompare(b.name); + break; + } + case 'date': + result = a.modifiedAt - b.modifiedAt; + break; + } + return sortDirection === 'asc' ? result : -result; + }; + return [...entries].sort(compare); + }, [entries, sortField, sortDirection]); + + const toggleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDirection('asc'); + } + }; const handleSelect = useCallback( (entry: DirEntry, ev: React.MouseEvent) => { @@ -128,6 +173,7 @@ export const FileGrid = ({ getScrollElement: () => scrollRef.current, estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP), overscan: 10, + scrollMargin: HEADER_HEIGHT, }); if (entries.length === 0) { @@ -153,44 +199,117 @@ export const FileGrid = ({ onCopy={onCopy} forceRename={renamingName === entry.name} onRenamingChange={onRenamingChange} + onReadAloud={onReadAloud} + onOcr={onOcr} + onTranscribe={onTranscribe} + onExtractAudio={onExtractAudio} + onExtract={onExtract} matchingTasks={getMatchingTasks(entry.name, entry.type)} onRunTask={onRunTask} onCreateWorkspace={onCreateWorkspace} /> ); + const sortIcon = (field: SortField) => + sortField === field ? ( + sortDirection === 'asc' ? ( + + ) : ( + + ) + ) : null; + + const sortBtnClass = (field: SortField) => + `flex items-center gap-1 text-xs font-medium cursor-pointer transition-colors ${ + sortField === field ? 'text-duck-teal' : 'text-duck-dark/50 hover:text-duck-dark/80' + }`; + return ( -
- {virtualizer.getVirtualItems().map((virtualRow) => { - if (viewMode === 'list') { - const entry = sorted[virtualRow.index]!; + <> + {viewMode === 'list' ? ( +
+ + + + + + + +
+ ) : ( +
+ Sort: + {(['name', 'size', 'type', 'date'] as SortField[]).map((field) => ( + + ))} +
+ )} +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + if (viewMode === 'list') { + const entry = sorted[virtualRow.index]!; + return ( +
+ {renderItem(entry)} +
+ ); + } + + const startIdx = virtualRow.index * cols; + const rowEntries = sorted.slice(startIdx, startIdx + cols); return (
- {renderItem(entry)} + {rowEntries.map(renderItem)}
); - } - - const startIdx = virtualRow.index * cols; - const rowEntries = sorted.slice(startIdx, startIdx + cols); - return ( -
- {rowEntries.map(renderItem)} -
- ); - })} -
+ })} +
+ ); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileItem.tsx b/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileItem.tsx index 3d3abfee..5ae41692 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileItem.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileItem.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid } from 'lucide-react'; +import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive } from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { DropdownMenu, @@ -23,6 +23,7 @@ import { } from '@/components/ui/context-menu'; import { cardStyle } from '@/components/Card'; import type { DirEntry, TaskSummary } from 'apps/FileBrowser'; +import { getFileType } from 'apps/FileViewer'; export type FileItemProps = { entry: DirEntry; @@ -41,6 +42,11 @@ export type FileItemProps = { onCopy: () => void; forceRename: boolean; onRenamingChange: (name: string | null) => void; + onReadAloud: (entry: DirEntry) => void; + onOcr: (entry: DirEntry) => void; + onTranscribe: (entry: DirEntry) => void; + onExtractAudio: (entry: DirEntry) => void; + onExtract: (entry: DirEntry) => void; matchingTasks: TaskSummary[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; onCreateWorkspace: (entry: DirEntry) => void; @@ -67,6 +73,11 @@ type MenuItemsProps = { onStartRename: () => void; onChat: (e: DirEntry) => void; onDownload: (e: DirEntry) => void; + onReadAloud: (e: DirEntry) => void; + onOcr: (e: DirEntry) => void; + onTranscribe: (e: DirEntry) => void; + onExtractAudio: (e: DirEntry) => void; + onExtract: (e: DirEntry) => void; onCut: () => void; onCopy: () => void; matchingTasks: TaskSummary[]; @@ -81,12 +92,25 @@ const DropdownMenuItems = ({ onStartRename, onChat, onDownload, + onReadAloud, + onOcr, + onTranscribe, + onExtractAudio, + onExtract, onCut, onCopy, matchingTasks, onRunTask, onCreateWorkspace, -}: MenuItemsProps) => ( +}: MenuItemsProps) => { + const fileType = entry.type === 'file' ? getFileType(entry.name) : null; + const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text'; + const showOcr = fileType === 'image'; + const showTranscribe = fileType === 'audio'; + const showExtractAudio = fileType === 'video'; + const showExtract = fileType === 'archive'; + + return ( <> onChat(entry)} className="cursor-pointer"> @@ -96,6 +120,36 @@ const DropdownMenuItems = ({ Download + {showReadAloud && ( + onReadAloud(entry)} className="cursor-pointer"> + + Read Aloud + + )} + {showOcr && ( + onOcr(entry)} className="cursor-pointer"> + + Extract Text (OCR) + + )} + {showTranscribe && ( + onTranscribe(entry)} className="cursor-pointer"> + + Transcribe + + )} + {showExtractAudio && ( + onExtractAudio(entry)} className="cursor-pointer"> + + Extract Audio + + )} + {showExtract && ( + onExtract(entry)} className="cursor-pointer"> + + Extract + + )} {matchingTasks.length > 0 && ( @@ -141,7 +195,8 @@ const DropdownMenuItems = ({ Delete -); + ); +}; const ContextMenuItems = ({ entry, @@ -150,12 +205,25 @@ const ContextMenuItems = ({ onStartRename, onChat, onDownload, + onReadAloud, + onOcr, + onTranscribe, + onExtractAudio, + onExtract, onCut, onCopy, matchingTasks, onRunTask, onCreateWorkspace, -}: MenuItemsProps) => ( +}: MenuItemsProps) => { + const fileType = entry.type === 'file' ? getFileType(entry.name) : null; + const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text'; + const showOcr = fileType === 'image'; + const showTranscribe = fileType === 'audio'; + const showExtractAudio = fileType === 'video'; + const showExtract = fileType === 'archive'; + + return ( <> onChat(entry)} className="cursor-pointer"> @@ -165,6 +233,36 @@ const ContextMenuItems = ({ Download + {showReadAloud && ( + onReadAloud(entry)} className="cursor-pointer"> + + Read Aloud + + )} + {showOcr && ( + onOcr(entry)} className="cursor-pointer"> + + Extract Text (OCR) + + )} + {showTranscribe && ( + onTranscribe(entry)} className="cursor-pointer"> + + Transcribe + + )} + {showExtractAudio && ( + onExtractAudio(entry)} className="cursor-pointer"> + + Extract Audio + + )} + {showExtract && ( + onExtract(entry)} className="cursor-pointer"> + + Extract + + )} {matchingTasks.length > 0 && ( @@ -210,7 +308,8 @@ const ContextMenuItems = ({ Delete -); + ); +}; const EllipsisMenu = (props: MenuItemsProps) => (
ev.stopPropagation()}> @@ -320,6 +419,11 @@ export const FileItem = ({ onCopy, forceRename, onRenamingChange, + onReadAloud, + onOcr, + onTranscribe, + onExtractAudio, + onExtract, matchingTasks, onRunTask, onCreateWorkspace, @@ -395,6 +499,11 @@ export const FileItem = ({ onStartRename: () => setRenaming(true), onChat, onDownload, + onReadAloud, + onOcr, + onTranscribe, + onExtractAudio, + onExtract, onCut, onCopy, matchingTasks, @@ -430,6 +539,9 @@ export const FileItem = ({ {entry.name} )}
+ + {isDir ? 'Folder' : getFileType(entry.name).replace(/^\w/, (c) => c.toUpperCase())} + {isDir ? '--' : formatSize(entry.size)} diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx b/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx index 45293c81..d28c536e 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx @@ -5,7 +5,6 @@ import { Loader2, LayoutGrid, List, - ClipboardPaste, FolderPlus, Search, @@ -18,15 +17,16 @@ import { Code, Eye, EyeOff, + Upload, } from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import { useFiles, type DirEntry, useTasks, type TaskSummary, Breadcrumb, Toolbar } from 'apps/FileBrowser'; +import { useClient } from 'hooks/useClient'; import { useUserState } from '@/state/useUserState'; import { useAuth } from 'hooks/useAuth'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { FileGrid } from './FileGrid'; -import { FileViewer } from './FileViewer'; import { TaskRunnerModal } from './TaskRunnerModal'; type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null; @@ -39,6 +39,7 @@ type FilesProps = { export const Files = ({ basePath = '/' }: FilesProps) => { const { user } = useAuth(); + const client = useClient(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const [homeRoot, setHomeRoot] = useUserState('files/homeRoot', 'home'); @@ -58,13 +59,14 @@ export const Files = ({ basePath = '/' }: FilesProps) => { const [showCloneInput, setShowCloneInput] = useState(false); const [cloneUrl, setCloneUrl] = useState(''); const [cloning, setCloning] = useState(false); + const [dragging, setDragging] = useState(false); const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry } | null>(null); + const dragCounter = useRef(0); const { getMatchingTasks } = useTasks(); const searchTimerRef = useRef | null>(null); const searchInputRef = useRef(null); const fileScrollRef = useRef(null); const viewPath = searchParams.get('view'); - const viewerFileName = viewPath ? viewPath.split('/').pop()! : ''; const files = useFiles(homeRoot); const filesRef = useRef(files); filesRef.current = files; @@ -174,7 +176,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => { } }; - const handleUpload = async (fileList: FileList) => { + const handleUpload = async (fileList: FileList | File[]) => { const count = fileList.length; setUploadProgress(0); try { @@ -286,6 +288,67 @@ export const Files = ({ basePath = '/' }: FilesProps) => { navigate(`/workspaces/new?${params}`); }; + const handleReadAloud = async (entry: DirEntry) => { + const filePath = entryPath(entry.name); + const toastId = toast.loading('Generating speech audio...'); + try { + const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path: filePath, root: homeRoot }); + toast.dismiss(toastId); + setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot }); + } catch { + toast.error('Failed to generate speech audio', { id: toastId }); + } + }; + + const handleOcr = async (entry: DirEntry) => { + const filePath = entryPath(entry.name); + const toastId = toast.loading('Extracting text from image...'); + try { + const { ocrPath, ocrRoot } = await client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path: filePath, root: homeRoot }); + toast.dismiss(toastId); + setSearchParams({ view: filePath, ephemeral: ocrPath, ephemeralRoot: ocrRoot }); + } catch { + toast.error('Failed to extract text from image', { id: toastId }); + } + }; + + const handleTranscribe = async (entry: DirEntry) => { + const filePath = entryPath(entry.name); + const toastId = toast.loading('Transcribing audio...'); + try { + const { transcriptionPath, transcriptionRoot } = await client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path: filePath, root: homeRoot }); + toast.dismiss(toastId); + setSearchParams({ view: filePath, ephemeral: transcriptionPath, ephemeralRoot: transcriptionRoot }); + } catch { + toast.error('Failed to transcribe audio', { id: toastId }); + } + }; + + const handleExtractAudio = async (entry: DirEntry) => { + const filePath = entryPath(entry.name); + const toastId = toast.loading('Extracting audio from video...'); + try { + const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path: filePath, root: homeRoot }); + toast.dismiss(toastId); + setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot }); + } catch { + toast.error('Failed to extract audio from video', { id: toastId }); + } + }; + + const handleExtract = async (entry: DirEntry) => { + const filePath = entryPath(entry.name); + const toastId = toast.loading('Extracting archive...'); + try { + const { extractedPath } = await client.post<{ extractedPath: string }>('/file-browser/extract', { path: filePath, root: homeRoot }); + const folderName = extractedPath.split('/').pop() ?? extractedPath; + toast.success(`Extracted to "${folderName}"`, { id: toastId }); + await refresh(); + } catch { + toast.error('Failed to extract archive', { id: toastId }); + } + }; + const handleGitClone = async () => { const url = cloneUrl.trim(); if (!url) return; @@ -318,21 +381,86 @@ export const Files = ({ basePath = '/' }: FilesProps) => { }; const handlePaste = async () => { - if (!clipboard) return; + // Internal file clipboard takes priority + if (clipboard) { + try { + if (clipboard.mode === 'copy') { + await files.copy(clipboard.paths, currentPath); + } else { + await files.move(clipboard.paths, currentPath); + setClipboard(null); + } + await refresh(); + toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`); + } catch { + toast.error('Failed to paste'); + } + return; + } + + // Try system clipboard for images/files try { - if (clipboard.mode === 'copy') { - await files.copy(clipboard.paths, currentPath); - } else { - await files.move(clipboard.paths, currentPath); - setClipboard(null); + const clipboardItems = await navigator.clipboard.read(); + const imageFiles: File[] = []; + for (const item of clipboardItems) { + for (const type of item.types) { + if (type.startsWith('image/')) { + const blob = await item.getType(type); + const ext = type.split('/')[1] ?? 'png'; + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + imageFiles.push(new File([blob], `clipboard-${ts}.${ext}`, { type })); + } + } + } + if (imageFiles.length > 0) { + const dt = new DataTransfer(); + imageFiles.forEach((f) => dt.items.add(f)); + await handleUpload(dt.files); } - await refresh(); - toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`); } catch { - toast.error('Failed to paste'); + // Clipboard API not available or permission denied — silently ignore } }; + // Clipboard image paste + useEffect(() => { + const handler = (ev: ClipboardEvent) => { + const tag = (ev.target as HTMLElement).tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + + const items = ev.clipboardData?.items; + if (!items) return; + + const imageFiles: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) imageFiles.push(file); + } + } + if (imageFiles.length === 0) return; + + ev.preventDefault(); + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + + const dt = new DataTransfer(); + imageFiles.forEach((file, i) => { + const ext = file.type.split('/')[1] ?? 'png'; + const name = imageFiles.length === 1 ? `clipboard-${ts}.${ext}` : `clipboard-${ts}-${i + 1}.${ext}`; + dt.items.add(new File([file], name, { type: file.type })); + }); + handleUpload(dt.files); + }; + + window.addEventListener('paste', handler); + return () => window.removeEventListener('paste', handler); + }, [currentPath, homeRoot]); + // Keyboard shortcuts useEffect(() => { const handler = (ev: KeyboardEvent) => { @@ -378,8 +506,10 @@ export const Files = ({ basePath = '/' }: FilesProps) => { } if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v') { - ev.preventDefault(); - handlePaste(); + if (clipboard) { + ev.preventDefault(); + handlePaste(); + } return; } @@ -409,6 +539,91 @@ export const Files = ({ basePath = '/' }: FilesProps) => { return () => window.removeEventListener('keydown', handler); }, [visibleEntries, selected, clipboard, currentPath, viewPath]); + const handleDragEnter = (ev: React.DragEvent) => { + ev.preventDefault(); + dragCounter.current++; + if (ev.dataTransfer.types.includes('Files')) { + setDragging(true); + } + }; + + const handleDragLeave = (ev: React.DragEvent) => { + ev.preventDefault(); + dragCounter.current--; + if (dragCounter.current === 0) { + setDragging(false); + } + }; + + const handleDragOver = (ev: React.DragEvent) => { + ev.preventDefault(); + }; + + const handleDrop = async (ev: React.DragEvent) => { + ev.preventDefault(); + dragCounter.current = 0; + setDragging(false); + + const items = ev.dataTransfer.items; + if (!items || items.length === 0) return; + + // Collect FileSystemEntry objects (supports folders) + const entries: FileSystemEntry[] = []; + for (let i = 0; i < items.length; i++) { + const entry = items[i]!.webkitGetAsEntry?.(); + if (entry) entries.push(entry); + } + + // If no entries (browser doesn't support webkitGetAsEntry), fall back to files + if (entries.length === 0) { + if (ev.dataTransfer.files.length > 0) { + handleUpload(ev.dataTransfer.files); + } + return; + } + + // Recursively read all files from entries + const allFiles: File[] = []; + + const readEntry = (entry: FileSystemEntry, path: string): Promise => { + if (entry.isFile) { + return new Promise((resolve) => { + (entry as FileSystemFileEntry).file((file) => { + allFiles.push(new File([file], path + file.name, { type: file.type, lastModified: file.lastModified })); + resolve(); + }); + }); + } + if (entry.isDirectory) { + const reader = (entry as FileSystemDirectoryEntry).createReader(); + return new Promise((resolve) => { + const readBatch = () => { + reader.readEntries(async (batch) => { + if (batch.length === 0) { + resolve(); + return; + } + for (const child of batch) { + await readEntry(child, path + entry.name + '/'); + } + readBatch(); // readEntries may not return all at once + }); + }; + readBatch(); + }); + } + return Promise.resolve(); + }; + + for (const entry of entries) { + await readEntry(entry, ''); + } + + if (allFiles.length > 0) { + handleUpload(allFiles); + } + }; + const handleBackgroundClick = (ev: React.MouseEvent) => { if ((ev.target as HTMLElement).closest('[data-file-item]')) return; setSelected(new Set()); @@ -583,7 +798,21 @@ export const Files = ({ basePath = '/' }: FilesProps) => { )} {/* File content */} -
+
+ {dragging && ( +
+
+ + Drop files to upload +
+
+ )} {searchQuery.trim() ? (
{searching ? ( @@ -647,6 +876,11 @@ export const Files = ({ basePath = '/' }: FilesProps) => { onCopy={handleCopy} renamingName={renamingName} onRenamingChange={setRenamingName} + onReadAloud={handleReadAloud} + onOcr={handleOcr} + onTranscribe={handleTranscribe} + onExtractAudio={handleExtractAudio} + onExtract={handleExtract} getMatchingTasks={getMatchingTasks} onRunTask={handleRunTask} onCreateWorkspace={handleCreateWorkspace} @@ -656,7 +890,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
- + Paste @@ -680,16 +914,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
- { - if (!open) setSearchParams({}); - }} - filePath={viewPath ?? ''} - fileName={viewerFileName} - root={homeRoot} - /> - {runningTask && ( { + const [searchParams, setSearchParams] = useSearchParams(); + const [homeRoot] = useUserState('files/homeRoot', 'home'); + const viewPath = searchParams.get('view'); + const fileName = viewPath ? viewPath.split('/').pop()! : ''; + + const handleOpenFile = useCallback( + (filePath: string, root: string) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set('ephemeral', filePath); + next.set('ephemeralRoot', root); + return next; + }); + }, + [setSearchParams], + ); + + if (!viewPath) return null; + + return ( + + {children} + + ); +}; + +const EphemeralFileViewerProvider = ({ children }: { children: ReactNode }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const ephemeralPath = searchParams.get('ephemeral'); + const ephemeralRoot = searchParams.get('ephemeralRoot') ?? 'home'; + const fileName = ephemeralPath ? ephemeralPath.split('/').pop()! : ''; + + const handleOpenFile = useCallback( + (filePath: string, root: string) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set('ephemeral2', filePath); + next.set('ephemeral2Root', root); + next.set('ephemeral2Auto', '1'); + return next; + }); + }, + [setSearchParams], + ); + + if (!ephemeralPath) return null; + + return ( + + {children} + + ); +}; + +const Ephemeral2FileViewerProvider = ({ children }: { children: ReactNode }) => { + const [searchParams] = useSearchParams(); + const path = searchParams.get('ephemeral2'); + const root = searchParams.get('ephemeral2Root') ?? 'home'; + const autoPlay = searchParams.get('ephemeral2Auto') === '1'; + const fileName = path ? path.split('/').pop()! : ''; + + if (!path) return null; + + return ( + + {children} + + ); +}; + export const FilesPage = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const viewPath = searchParams.get('view'); + const ephemeralPath = searchParams.get('ephemeral'); + const ephemeral2Path = searchParams.get('ephemeral2'); + + const layout = useMemo( + () => + viewPath && ephemeralPath && ephemeral2Path + ? viewerWithEphemeralSplitLayout + : viewPath && ephemeralPath + ? viewerWithEphemeralLayout + : viewPath + ? viewerLayout + : baseLayout, + [viewPath, ephemeralPath, ephemeral2Path], + ); + + const closeEphemeral = useCallback(() => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.delete('ephemeral'); + next.delete('ephemeralRoot'); + next.delete('ephemeral2'); + next.delete('ephemeral2Root'); + next.delete('ephemeral2Auto'); + return next; + }); + }, [setSearchParams]); + + const closeEphemeral2 = useCallback(() => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.delete('ephemeral2'); + next.delete('ephemeral2Root'); + next.delete('ephemeral2Auto'); + return next; + }); + }, [setSearchParams]); + const panelComponents: PanelComponents = useMemo( () => ({ 'files-main': FilesInner, + 'files-viewer': { + provider: FilesFileViewerProvider, + header: FileViewerHeader, + component: FileViewerBody, + onClose: () => setSearchParams({}), + }, + 'files-ephemeral': { + provider: EphemeralFileViewerProvider, + header: FileViewerHeader, + component: FileViewerBody, + onClose: closeEphemeral, + }, + 'files-ephemeral2': { + provider: Ephemeral2FileViewerProvider, + header: FileViewerHeader, + component: FileViewerBody, + onClose: closeEphemeral2, + }, }), - [], + [setSearchParams, closeEphemeral, closeEphemeral2], ); return ( diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx index fcf0a6cf..730b7600 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; -import { Box, Circle, Server, Wrench } from 'lucide-react'; +import { Circle, Server, Wrench } from 'lucide-react'; import { useGlobal } from 'hooks/useGlobal'; import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; import { useResources, getResourceCategory, type Resource } from '@/state/useResources'; type ResourceItemProps = { @@ -24,46 +25,61 @@ const ResourceItem = ({ resource: r, isActive, onSelect }: ResourceItemProps) =>
{r.name}
{r.subtitle}
- + ); export const ResourceSidebar = () => { const { resources, isLoading } = useResources(); const [selectedId, setSelectedId] = useGlobal('RESOURCE_SELECTED', null); + const [showCatalog, setShowCatalog] = useGlobal('RESOURCE_CATALOG', false); const [search, setSearch] = useState(''); - const query = search.toLowerCase(); - const filtered = resources?.filter( - (r: Resource) => - r.name.toLowerCase().includes(query) || - r.subtitle.toLowerCase().includes(query) || - r.description.toLowerCase().includes(query), - ); + const installed = resources?.filter((r: Resource) => r.installed) ?? []; - const apiBased = filtered?.filter((r: Resource) => getResourceCategory(r) === 'api-based') ?? []; - const localCli = filtered?.filter((r: Resource) => getResourceCategory(r) === 'local-cli') ?? []; + const query = search.toLowerCase(); + const filtered = query + ? installed.filter( + (r: Resource) => r.name.toLowerCase().includes(query) || r.subtitle.toLowerCase().includes(query), + ) + : installed; + + const apiBased = filtered.filter((r: Resource) => getResourceCategory(r) === 'api-based'); + const localCli = filtered.filter((r: Resource) => getResourceCategory(r) === 'local-cli'); + + const handleCatalog = () => { + setSelectedId(null); + setShowCatalog(true); + }; + + const handleSelect = (id: string) => { + setShowCatalog(false); + setSelectedId(id); + }; return ( -
-
-
- - Resources -
-
-
+
+
+

Resources

+ setSearch(ev.target.value)} - className="h-8 text-xs" + className="h-7 text-xs" />
{isLoading &&

Loading...

} + {!isLoading && filtered.length === 0 && ( +

{search ? 'No matches' : 'No active resources'}

+ )} {apiBased.length > 0 && ( <>
@@ -74,8 +90,8 @@ export const ResourceSidebar = () => { setSelectedId(r.id)} + isActive={!showCatalog && selectedId === r.id} + onSelect={() => handleSelect(r.id)} /> ))} @@ -90,8 +106,8 @@ export const ResourceSidebar = () => { setSelectedId(r.id)} + isActive={!showCatalog && selectedId === r.id} + onSelect={() => handleSelect(r.id)} /> ))} diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx index edee15f9..dc53b556 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx @@ -1,39 +1,22 @@ import { useState } from 'react'; -import { Copy, Check, Server, Wrench, Loader2 } from 'lucide-react'; +import { Server, Wrench, Loader2 } from 'lucide-react'; import { useGlobal } from 'hooks/useGlobal'; +import { usePanelChannel } from 'hooks/usePanelChannel'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { CommandBlock } from '@/components/CommandBlock'; import { useResources, getResourceCategory, type Resource, type PingResult } from '@/state/useResources'; - -const CopyCommand = ({ command }: { command: string }) => { - const [copied, setCopied] = useState(false); - - const copy = () => { - navigator.clipboard.writeText(command); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }; - - return ( -
- {command} - -
- ); -}; - -const CommandRow = ({ label, command }: { label: string; command: string }) => ( -
- {label}: - -
-); +import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel'; type ConnectionSectionProps = { resource: Resource; @@ -147,7 +130,12 @@ const ConnectionSection = ({ resource }: ConnectionSectionProps) => { ); }; -const LocalAvailabilitySection = ({ resource }: { resource: Resource }) => ( +type LocalAvailabilitySectionProps = { + resource: Resource; + onRun: (command: string) => void; +}; + +const LocalAvailabilitySection = ({ resource, onRun }: LocalAvailabilitySectionProps) => (

Local Availability

@@ -159,60 +147,203 @@ const LocalAvailabilitySection = ({ resource }: { resource: Resource }) => ( Not installed )}
-
- {resource.installCommand && } - {resource.uninstallCommand && } - {resource.manageCommand && } - {resource.verifyCommand && } - {resource.updateCommand && } +
+ {resource.installCommand && } + {resource.uninstallCommand && ( + + )} + {resource.manageCommand && } + {resource.verifyCommand && } + {resource.updateCommand && }
); -export const Resources = () => { - const { resources } = useResources(); - const [selectedId] = useGlobal('RESOURCE_SELECTED', null); +type CatalogCardProps = { + resource: Resource; + onSelect: (id: string) => void; +}; - const resource = resources?.find((r: Resource) => r.id === selectedId); - - if (!resource) { - return ( -
-

Select a resource to view details

+const CatalogCard = ({ resource: r, onSelect }: CatalogCardProps) => { + const category = getResourceCategory(r); + return ( + + ); +}; - const category = getResourceCategory(resource); +const ResourceCatalog = () => { + const { resources, isLoading } = useResources(); + const [, setSelectedId] = useGlobal('RESOURCE_SELECTED', null); + const [, setShowCatalog] = useGlobal('RESOURCE_CATALOG', false); + const [search, setSearch] = useState(''); + + const query = search.toLowerCase(); + const filtered = + resources?.filter( + (r: Resource) => + !r.installed && + (r.name.toLowerCase().includes(query) || + r.subtitle.toLowerCase().includes(query) || + r.description.toLowerCase().includes(query)), + ) ?? []; + + const apiBased = filtered.filter((r: Resource) => getResourceCategory(r) === 'api-based'); + const localCli = filtered.filter((r: Resource) => getResourceCategory(r) === 'local-cli'); + + const handleSelect = (id: string) => { + setShowCatalog(false); + setSelectedId(id); + }; return (
-
- {resource.port ? ( - - ) : ( - - )} -

{resource.name}

- {resource.subtitle} -
- -
- {resource.type} - - {category === 'api-based' ? 'API Based' : 'Local CLI'} - - {resource.port && ( - :{resource.port} - )} -
- -

{resource.description}

- - {category === 'api-based' && } - +

Resource Catalog

+

All available resources. Select one to configure.

+ setSearch(ev.target.value)} + className="h-8 text-xs mb-4 max-w-xs" + /> + {isLoading &&

Loading...

} + {apiBased.length > 0 && ( +
+
+ + API Based +
+
+ {apiBased.map((r: Resource) => ( + + ))} +
+
+ )} + {localCli.length > 0 && ( +
+
+ + Local CLI +
+
+ {localCli.map((r: Resource) => ( + + ))} +
+
+ )}
); }; + +const ResourceDetail = ({ resource }: { resource: Resource }) => { + const category = getResourceCategory(resource); + const [, setRunCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null); + const [confirmCommand, setConfirmCommand] = useState(null); + + const handleRun = (command: string) => { + const isSudo = command.trimStart().startsWith('sudo'); + if (isSudo) { + setConfirmCommand(command); + } else { + setRunCommand({ command }); + } + }; + + return ( + <> +
+
+ {resource.port ? ( + + ) : ( + + )} +

{resource.name}

+ {resource.subtitle} +
+ +
+ {resource.type} + + {category === 'api-based' ? 'API Based' : 'Local CLI'} + + {resource.port && ( + :{resource.port} + )} +
+ +

{resource.description}

+ + {category === 'api-based' && } + +
+ + !open && setConfirmCommand(null)}> + + + Run with elevated privileges + +
+ You are about to run a command with elevated privileges (sudo) +
+ + in the host machine. +
+ ARE YOU SURE? +
+
+
+
+ {confirmCommand} + + Cancel + { + if (confirmCommand) setRunCommand({ command: confirmCommand }); + setConfirmCommand(null); + }} + > + Run + + +
+
+ + ); +}; + +export const Resources = () => { + const { resources } = useResources(); + const [selectedId] = useGlobal('RESOURCE_SELECTED', null); + const [showCatalog] = useGlobal('RESOURCE_CATALOG', false); + + const resource = selectedId ? resources?.find((r: Resource) => r.id === selectedId) : null; + + if (showCatalog || !resource) return ; + return ; +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx index 45a47cdd..6a8f2913 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx @@ -1,11 +1,24 @@ -import { useMemo } from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { X } from 'lucide-react'; +import { toast } from 'sonner'; +import { useQueryClient } from '@tanstack/react-query'; import type { LayoutNode, PanelComponents } from '@/components/Workspace'; import { WorkspaceLayout } from '@/components/Workspace'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useClient } from 'hooks/useClient'; +import { TerminalView } from 'apps/Terminal'; +import { FileViewerView } from 'apps/FileViewer'; import { appRegistry } from '../../Workspaces/app-registry'; import { Resources } from './Resources'; import { ResourceSidebar } from './ResourceSidebar'; +import { + RUN_COMMAND_CHANNEL, + ERROR_LOG_CHANNEL, + type RunCommandState, + type ErrorLogState, +} from './run-command-channel'; -const layout: LayoutNode = { +const baseLayout: LayoutNode = { type: 'group', id: 'resources-root', direction: 'horizontal', @@ -15,11 +28,147 @@ const layout: LayoutNode = { ], }; +const splitLayout: LayoutNode = { + type: 'group', + id: 'resources-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 }, + { + node: { + type: 'group', + id: 'resources-right-group', + direction: 'vertical', + children: [ + { node: { type: 'panel', id: 'resources-right', appType: null }, size: 50 }, + { node: { type: 'panel', id: 'resources-terminal', appType: null }, size: 50 }, + ], + }, + size: 80, + }, + ], +}; + +const errorLayout: LayoutNode = { + type: 'group', + id: 'resources-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 }, + { + node: { + type: 'group', + id: 'resources-right-group', + direction: 'vertical', + children: [ + { node: { type: 'panel', id: 'resources-right', appType: null }, size: 50 }, + { node: { type: 'panel', id: 'resources-error-log', appType: null }, size: 50 }, + ], + }, + size: 80, + }, + ], +}; + +const ResourceTerminalPanel = () => { + const queryClient = useQueryClient(); + const client = useClient(); + const [state, setState] = usePanelChannel(RUN_COMMAND_CHANNEL, null); + const [, setErrorLog] = usePanelChannel(ERROR_LOG_CHANNEL, null); + const [session, setSession] = useState<{ id: string; command: string } | null>(null); + + useEffect(() => { + if (state && (!session || session.command !== state.command)) { + setSession({ id: `res-cmd-${Date.now()}`, command: state.command }); + } else if (!state) { + setSession(null); + } + }, [state]); + + const close = () => setState(null); + + const onCommandDone = (exitCode: number, output: string) => { + queryClient.invalidateQueries({ queryKey: ['RESOURCES'] }); + if (exitCode === 0) { + toast.success('Command completed successfully'); + setTimeout(() => setState(null), 2000); + } else { + const command = session?.command ?? 'unknown'; + const md = [ + `# Command Failed (exit code ${exitCode})`, + '', + '```', + command, + '```', + '', + '## Output', + '', + '```', + output, + '```', + ].join('\n'); + + client.post('/server-settings/resources/error-log', { command, output, exitCode }).catch(() => {}); + + setState(null); + setErrorLog({ content: md, fileName: 'error.md' }); + } + }; + + if (!state || !session) return null; + + return ( +
+
+ Run Command + +
+ +
+ ); +}; + +const ErrorLogPanel = () => { + const [errorLog, setErrorLog] = usePanelChannel(ERROR_LOG_CHANNEL, null); + + if (!errorLog) return null; + + return ( + setErrorLog(null)} + /> + ); +}; + export const ResourceSettings = () => { + const [runCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null); + const [errorLog] = usePanelChannel(ERROR_LOG_CHANNEL, null); + + const layout = useMemo( + () => (errorLog ? errorLayout : runCommand ? splitLayout : baseLayout), + [errorLog, runCommand], + ); + const panelComponents: PanelComponents = useMemo( () => ({ 'resources-left': ResourceSidebar, 'resources-right': Resources, + 'resources-terminal': ResourceTerminalPanel, + 'resources-error-log': ErrorLogPanel, }), [], ); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/run-command-channel.ts b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/run-command-channel.ts new file mode 100644 index 00000000..4aedd472 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/run-command-channel.ts @@ -0,0 +1,12 @@ +export type RunCommandState = { + command: string; +} | null; + +export const RUN_COMMAND_CHANNEL = 'resource-settings:run-command'; + +export type ErrorLogState = { + content: string; + fileName: string; +} | null; + +export const ERROR_LOG_CHANNEL = 'resource-settings:error-log'; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx index 5b9fc8bc..846e05a5 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx @@ -1,11 +1,14 @@ import { useState, useEffect, useRef } from 'react'; -import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye } from 'lucide-react'; import { useAuth } from 'hooks/useAuth'; import type { AppRegistry } from '@/components/Workspace'; import { useWorkspace } from '@/components/Workspace'; import { CodeEditorView } from 'apps/CodeEditor'; import { TerminalView } from 'apps/Terminal'; import { useWorkspacesState } from '@/state/useWorkspacesState'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer'; import { useClaude } from '../Chat/useClaude'; import { useOpenCode } from '../Chat/useOpenCode'; import { usePiMono } from '../Chat/usePiMono'; @@ -127,6 +130,32 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => { return ; }; +type FileViewerChannelState = { + filePath: string; + fileName: string; + root?: string; +} | null; + +const FILE_VIEWER_CHANNEL = 'file-viewer'; + +const FileViewerWorkspaceProvider = ({ panelId, children }: { panelId: string; children: ReactNode }) => { + const [state] = usePanelChannel(`${FILE_VIEWER_CHANNEL}:${panelId}`, null); + + if (!state) { + return ( +
+ No file selected +
+ ); + } + + return ( + + {children} + + ); +}; + export const appRegistry: AppRegistry = { 'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget }, 'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper }, @@ -136,6 +165,7 @@ export const appRegistry: AppRegistry = { 'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper }, 'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper }, 'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => }, + 'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider }, 'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => , fixedHeight: 180 }, 'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true }, ...widgetRegistry, diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 0cd761f8..be4dacf6 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -1,16 +1,23 @@ import { createRouter } from '@@/create-router'; -import { resolve, dirname } from 'node:path'; +import { resolve, dirname, join, parse as parsePath } from 'node:path'; import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; -import { getHomeDir } from '@@/data-path'; +import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path'; import * as errors from '@@/custom-errors'; +import { readConfig } from '@@/api/server-settings/resources'; export const router = createRouter(); type UserCtx = { email: string; role: string | null }; +function getUserDataDir(email: string): string { + return join(DATA_PATH, email); +} + function getRootDir(user: UserCtx, root?: string): string { if (!root || root === 'home') return getHomeDir(user.email); + if (root === 'user-data') return getUserDataDir(user.email); if (user.role !== 'Super Admin') throw errors.FORBIDDEN('Only Super Admin can access this root'); if (root === '~') return homedir(); if (root === 'officer.dev') return resolve(process.cwd(), '..'); @@ -244,6 +251,298 @@ router.get('/transcode', async (ctx) => { }); }); +// Text-to-speech with caching +router.post('/tts', async (ctx) => { + const user = ctx.get('user'); + const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; + if (!filePath) throw errors.BAD_REQUEST('path is required'); + + const rootDir = getRootDir(user, root); + const absPath = resolveUserPath(rootDir, filePath); + const s = await stat(absPath); + if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory'); + + const userDataDir = getUserDataDir(user.email); + const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); + const cacheRel = dir ? `tts/${dir}/${name}.mp3` : `tts/${name}.mp3`; + const cacheAbs = resolve(userDataDir, cacheRel); + + if (existsSync(cacheAbs)) { + return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' }); + } + + const config = await readConfig(); + const kokoroUrl = config.kokoro?.url; + if (!kokoroUrl) throw errors.BAD_REQUEST('Kokoro TTS not configured'); + + const content = await readFile(absPath, 'utf-8'); + const res = await fetch(`${kokoroUrl}/v1/audio/speech`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'kokoro', input: content, voice: 'af_heart', response_format: 'mp3' }), + }); + if (!res.ok) throw errors.BAD_REQUEST('TTS request failed'); + + await mkdir(dirname(cacheAbs), { recursive: true }); + const buffer = await res.arrayBuffer(); + await Bun.write(cacheAbs, buffer); + + return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' }); +}); + +// OCR image via vision model with caching +router.post('/ocr', async (ctx) => { + const user = ctx.get('user'); + const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; + if (!filePath) throw errors.BAD_REQUEST('path is required'); + + const rootDir = getRootDir(user, root); + const absPath = resolveUserPath(rootDir, filePath); + const s = await stat(absPath); + if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot OCR a directory'); + + const userDataDir = getUserDataDir(user.email); + const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); + const cacheRel = dir ? `ocr/${dir}/${name}.md` : `ocr/${name}.md`; + const cacheAbs = resolve(userDataDir, cacheRel); + + if (existsSync(cacheAbs)) { + const text = await readFile(cacheAbs, 'utf-8'); + return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: true }); + } + + const config = await readConfig(); + const llamaUrl = config.llama?.url; + if (!llamaUrl) throw errors.BAD_REQUEST('llama.cpp not configured'); + + const imageBytes = await Bun.file(absPath).arrayBuffer(); + const base64 = Buffer.from(imageBytes).toString('base64'); + const ext = absPath.split('.').pop()?.toLowerCase() ?? 'png'; + const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : `image/${ext}`; + + const res = await fetch(`${llamaUrl}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'Qwen2.5-VL-7B-Instruct-q4_k_m.gguf', + messages: [ + { + role: 'user', + content: [ + { type: 'image_url', image_url: { url: `data:${mime};base64,${base64}` } }, + { type: 'text', text: 'Extract all text from this image. Return only the extracted text, nothing else.' }, + ], + }, + ], + }), + }); + if (!res.ok) throw errors.BAD_REQUEST('OCR request failed'); + + const json = (await res.json()) as { choices: { message: { content: string } }[] }; + const text = json.choices[0]?.message?.content ?? ''; + + await mkdir(dirname(cacheAbs), { recursive: true }); + await Bun.write(cacheAbs, text); + + return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: false }); +}); + +// Extract audio from video via ffmpeg with caching +router.post('/extract-audio', async (ctx) => { + const user = ctx.get('user'); + const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; + if (!filePath) throw errors.BAD_REQUEST('path is required'); + + const rootDir = getRootDir(user, root); + const absPath = resolveUserPath(rootDir, filePath); + const s = await stat(absPath); + if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract audio from a directory'); + + const userDataDir = getUserDataDir(user.email); + const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); + const cacheRel = dir ? `audio/${dir}/${name}.mp3` : `audio/${name}.mp3`; + const cacheAbs = resolve(userDataDir, cacheRel); + + if (existsSync(cacheAbs)) { + return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data', cached: true }); + } + + await mkdir(dirname(cacheAbs), { recursive: true }); + + const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-vn', '-codec:a', 'libmp3lame', '-q:a', '2', '-y', cacheAbs], { + stdout: 'ignore', + stderr: 'pipe', + }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw errors.BAD_REQUEST(stderr.trim() || 'Audio extraction failed'); + } + + return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data', cached: false }); +}); + +// Extract archive (zip, tar, 7z, rar) into a sibling folder +router.post('/extract', async (ctx) => { + const user = ctx.get('user'); + const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; + if (!filePath) throw errors.BAD_REQUEST('path is required'); + + const rootDir = getRootDir(user, root); + const absPath = resolveUserPath(rootDir, filePath); + const s = await stat(absPath); + if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract a directory'); + + const fileName = absPath.split('/').pop()!; + const lower = fileName.toLowerCase(); + + // Determine archive type and build command + type ArchiveType = 'tar' | 'zip' | '7z' | 'rar'; + let archiveType: ArchiveType; + if ( + lower.endsWith('.tar') || + lower.endsWith('.tar.gz') || + lower.endsWith('.tgz') || + lower.endsWith('.tar.bz2') || + lower.endsWith('.tbz2') || + lower.endsWith('.tar.xz') || + lower.endsWith('.txz') || + lower.endsWith('.tar.zst') || + lower.endsWith('.gz') || + lower.endsWith('.bz2') || + lower.endsWith('.xz') || + lower.endsWith('.zst') + ) { + archiveType = 'tar'; + } else if (lower.endsWith('.zip')) { + archiveType = 'zip'; + } else if (lower.endsWith('.7z')) { + archiveType = '7z'; + } else if (lower.endsWith('.rar')) { + archiveType = 'rar'; + } else { + throw errors.BAD_REQUEST('Unsupported archive format'); + } + + // Compute destination folder name (strip archive extension) + const stripArchiveExt = (name: string): string => { + const l = name.toLowerCase(); + for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) { + if (l.endsWith(compound)) return name.slice(0, -compound.length); + } + const dotIdx = name.lastIndexOf('.'); + return dotIdx > 0 ? name.slice(0, dotIdx) : name; + }; + + const baseName = stripArchiveExt(fileName); + const destPath = resolve(dirname(absPath), baseName); + const finalDest = await resolveCollision(destPath); + await mkdir(finalDest, { recursive: true }); + + let cmd: string[]; + switch (archiveType) { + case 'tar': + cmd = ['tar', 'xf', absPath, '-C', finalDest]; + break; + case 'zip': + cmd = ['unzip', '-q', absPath, '-d', finalDest]; + break; + case '7z': + cmd = ['7z', 'x', absPath, `-o${finalDest}`, '-y']; + break; + case 'rar': + cmd = ['unrar', 'x', '-o+', absPath, `${finalDest}/`]; + break; + } + + const proc = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + // Clean up the empty directory on failure + await rm(finalDest, { recursive: true, force: true }).catch(() => {}); + throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed'); + } + + const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, ''); + return ctx.json({ extractedPath }); +}); + +// Transcribe audio via Whisper with caching +// Workflow: detect language → check user's spoken languages → translate if needed → transcribe +router.post('/transcribe', async (ctx) => { + const user = ctx.get('user'); + const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; + if (!filePath) throw errors.BAD_REQUEST('path is required'); + + const rootDir = getRootDir(user, root); + const absPath = resolveUserPath(rootDir, filePath); + const s = await stat(absPath); + if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcribe a directory'); + + const userDataDir = getUserDataDir(user.email); + const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); + const cacheRel = dir ? `transcriptions/${dir}/${name}.md` : `transcriptions/${name}.md`; + const cacheAbs = resolve(userDataDir, cacheRel); + + if (existsSync(cacheAbs)) { + return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: true }); + } + + const config = await readConfig(); + const whisperUrl = config.whisper?.url; + if (!whisperUrl) throw errors.BAD_REQUEST('Whisper not configured'); + + const audioFile = Bun.file(absPath); + + // Step 1: Detect language + const detectForm = new FormData(); + detectForm.append('file', audioFile); + detectForm.append('temperature', '0.0'); + detectForm.append('response_format', 'verbose_json'); + detectForm.append('detect_language', 'true'); + + const detectRes = await fetch(`${whisperUrl}/inference`, { method: 'POST', body: detectForm }); + if (!detectRes.ok) throw errors.BAD_REQUEST('Language detection failed'); + + const detectJson = (await detectRes.json()) as { language?: string }; + const detectedLang = detectJson.language ?? 'en'; + + // Step 2: Check user's spoken languages to decide if translation is needed + let shouldTranslate = false; + const settingsFile = Bun.file(getUserSettingsFile(user.email)); + if (await settingsFile.exists()) { + const settings = (await settingsFile.json()) as { languages?: { spoken?: string[] } }; + const spokenLanguages = settings.languages?.spoken ?? []; + if (spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLang)) { + shouldTranslate = true; + } + } + + // Step 3: Full transcription + const transcribeForm = new FormData(); + transcribeForm.append('file', audioFile); + transcribeForm.append('temperature', '0.0'); + transcribeForm.append('temperature_inc', '0.2'); + transcribeForm.append('response_format', 'text'); + transcribeForm.append('language', detectedLang); + if (shouldTranslate) { + transcribeForm.append('translate', 'true'); + } + + const res = await fetch(`${whisperUrl}/inference`, { method: 'POST', body: transcribeForm }); + if (!res.ok) throw errors.BAD_REQUEST('Transcription request failed'); + + const text = (await res.text()).trim(); + + await mkdir(dirname(cacheAbs), { recursive: true }); + await Bun.write(cacheAbs, text); + + return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: false }); +}); + // Search files by name router.get('/search', async (ctx) => { const user = ctx.get('user'); diff --git a/src/servers/api/server-settings/resources.ts b/src/servers/api/server-settings/resources.ts index 54781e17..f1a05c18 100644 --- a/src/servers/api/server-settings/resources.ts +++ b/src/servers/api/server-settings/resources.ts @@ -2,7 +2,7 @@ import { readdir, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { createRouter } from '../../create-router'; -import { getResourcesDir } from '../../data-path'; +import { DATA_PATH, getResourcesDir } from '../../data-path'; type ResourceCredentials = { apiKey?: string; @@ -10,7 +10,7 @@ type ResourceCredentials = { password?: string; }; -type ResourceConnectionConfig = { +export type ResourceConnectionConfig = { url: string; credentials?: ResourceCredentials; }; @@ -37,6 +37,10 @@ type Resource = { const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1'); +const resolveCommand = (command: string): string => { + return command.replace(/\$DATA_PATH/g, DATA_PATH); +}; + function parseResourceFile( filename: string, content: string, @@ -70,11 +74,11 @@ function parseResourceFile( port, path: rawPath ? stripBackticks(rawPath) : null, description: field('Description') ?? '', - installCommand: field('Install') ? stripBackticks(field('Install')!) : null, - uninstallCommand: field('Uninstall') ? stripBackticks(field('Uninstall')!) : null, - manageCommand: field('Manage') ? stripBackticks(field('Manage')!) : null, - verifyCommand: field('Verify') ? stripBackticks(field('Verify')!) : null, - updateCommand: field('Update') ? stripBackticks(field('Update')!) : null, + installCommand: field('Install') ? resolveCommand(stripBackticks(field('Install')!)) : null, + uninstallCommand: field('Uninstall') ? resolveCommand(stripBackticks(field('Uninstall')!)) : null, + manageCommand: field('Manage') ? resolveCommand(stripBackticks(field('Manage')!)) : null, + verifyCommand: field('Verify') ? resolveCommand(stripBackticks(field('Verify')!)) : null, + updateCommand: field('Update') ? resolveCommand(stripBackticks(field('Update')!)) : null, }; } @@ -82,7 +86,7 @@ const CONFIG_FILENAME = 'resources-config.json'; const getConfigPath = () => join(getResourcesDir(), CONFIG_FILENAME); -async function readConfig(): Promise { +export async function readConfig(): Promise { const path = getConfigPath(); if (!existsSync(path)) return {}; const text = await Bun.file(path).text(); @@ -265,6 +269,27 @@ resourcesRouter.post('/:id/ping', async (ctx) => { } }); +resourcesRouter.post('/error-log', async (ctx) => { + const body = await ctx.req.json<{ command: string; output: string; exitCode: number }>(); + const timestamp = Date.now(); + const filePath = `/tmp/officer-error-${timestamp}.md`; + const md = [ + `# Command Failed (exit code ${body.exitCode})`, + '', + '```', + body.command, + '```', + '', + '## Output', + '', + '```', + body.output, + '```', + ].join('\n'); + await Bun.write(filePath, md); + return ctx.json({ filePath }); +}); + resourcesRouter.get('/:id', async (ctx) => { const resources = await loadResources(); const resource = resources.find((r) => r.id === ctx.req.param('id')); diff --git a/src/workspaces/apps/FileViewer/FileViewerContext.tsx b/src/workspaces/apps/FileViewer/FileViewerContext.tsx new file mode 100644 index 00000000..6066b87f --- /dev/null +++ b/src/workspaces/apps/FileViewer/FileViewerContext.tsx @@ -0,0 +1,358 @@ +import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'; +import type { ReactNode } from 'react'; +import { + Download, + Loader2, + Music, + Film, + Image, + FileType2, + Volume2, + ArrowUp, + ScanText, + FileText, + AudioLines, + FolderArchive, +} from 'lucide-react'; +import { useFiles } from 'apps/FileBrowser'; +import { useClient } from 'hooks/useClient'; +import { toast } from 'sonner'; +import { getIcon } from 'material-file-icons'; +import { getFileType, getLang, getRawUrl, getTranscodeUrl, needsTranscode, getArchiveBaseName } from './file-types'; +import type { FileType } from './file-types'; +import { + PdfRenderer, + ImageRenderer, + VideoRenderer, + AudioRenderer, + CodeRenderer, + MarkdownRenderer, + TextRenderer, + ScrollToTopButton, +} from './FileViewerView'; + +// ── Context ── + +type FileViewerContextValue = { + filePath: string; + fileName: string; + root: string; + fileType: FileType; + content: string | null; + loading: boolean; + error: string | null; + directContent: boolean; + ttsLoading: boolean; + ocrLoading: boolean; + transcribeLoading: boolean; + extractAudioLoading: boolean; + extractLoading: boolean; + autoPlay: boolean; + handleReadAloud: () => void; + handleOcr: () => void; + handleTranscribe: () => void; + handleExtractAudio: () => void; + handleExtract: () => void; + handleDownload: () => void; +}; + +const FileViewerContext = createContext(null); + +const useFileViewer = () => { + const ctx = useContext(FileViewerContext); + if (!ctx) throw new Error('useFileViewer must be used within FileViewerProvider'); + return ctx; +}; + +// ── Provider ── + +type FileViewerProviderProps = { + filePath: string; + fileName: string; + root?: string; + content?: string; + onOpenFile?: (filePath: string, root: string) => void; + autoPlay?: boolean; + children: ReactNode; +}; + +export const FileViewerProvider = ({ filePath, fileName, root = 'home', content: directContent, onOpenFile, autoPlay = false, children }: FileViewerProviderProps) => { + const [content, setContent] = useState(directContent ?? null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [ttsLoading, setTtsLoading] = useState(false); + const [ocrLoading, setOcrLoading] = useState(false); + const [transcribeLoading, setTranscribeLoading] = useState(false); + const [extractAudioLoading, setExtractAudioLoading] = useState(false); + const [extractLoading, setExtractLoading] = useState(false); + const client = useClient(); + const files = useFiles(root); + const fileType = getFileType(fileName); + + useEffect(() => { + if (directContent !== undefined) { + setContent(directContent); + setLoading(false); + return; + } + + if (fileType === 'audio' || fileType === 'video' || fileType === 'image' || fileType === 'pdf' || fileType === 'archive') { + setLoading(false); + return; + } + + setLoading(true); + setError(null); + files + .readFile(filePath) + .then((res) => setContent(res.content)) + .catch(() => setError('Failed to read file')) + .finally(() => setLoading(false)); + }, [filePath, directContent]); + + const handleDownload = useCallback(() => { + const url = getRawUrl(filePath, root); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + }, [filePath, root, fileName]); + + const handleReadAloud = useCallback(async () => { + setTtsLoading(true); + try { + const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path: filePath, root }); + onOpenFile?.(audioPath, audioRoot); + } catch { + toast.error('Failed to generate speech audio'); + } finally { + setTtsLoading(false); + } + }, [filePath, root, client, onOpenFile]); + + const handleOcr = useCallback(async () => { + setOcrLoading(true); + try { + const { ocrPath, ocrRoot } = await client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path: filePath, root }); + onOpenFile?.(ocrPath, ocrRoot); + } catch { + toast.error('Failed to extract text from image'); + } finally { + setOcrLoading(false); + } + }, [filePath, root, client, onOpenFile]); + + const handleTranscribe = useCallback(async () => { + setTranscribeLoading(true); + try { + const { transcriptionPath, transcriptionRoot } = await client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path: filePath, root }); + onOpenFile?.(transcriptionPath, transcriptionRoot); + } catch { + toast.error('Failed to transcribe audio'); + } finally { + setTranscribeLoading(false); + } + }, [filePath, root, client, onOpenFile]); + + const handleExtractAudio = useCallback(async () => { + setExtractAudioLoading(true); + try { + const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path: filePath, root }); + onOpenFile?.(audioPath, audioRoot); + } catch { + toast.error('Failed to extract audio from video'); + } finally { + setExtractAudioLoading(false); + } + }, [filePath, root, client, onOpenFile]); + + const handleExtract = useCallback(async () => { + setExtractLoading(true); + try { + const { extractedPath } = await client.post<{ extractedPath: string }>('/file-browser/extract', { path: filePath, root }); + const folderName = extractedPath.split('/').pop() ?? extractedPath; + toast.success(`Extracted to "${folderName}"`); + } catch { + toast.error('Failed to extract archive'); + } finally { + setExtractLoading(false); + } + }, [filePath, root, client]); + + const value: FileViewerContextValue = { + filePath, + fileName, + root, + fileType, + content, + loading, + error, + directContent: directContent !== undefined, + ttsLoading, + ocrLoading, + transcribeLoading, + extractAudioLoading, + extractLoading, + autoPlay, + handleReadAloud, + handleOcr, + handleTranscribe, + handleExtractAudio, + handleExtract, + handleDownload, + }; + + return {children}; +}; + +// ── Header (content fragments only — no container div) ── + +export const FileViewerHeader = () => { + const { fileName, fileType, content, directContent, ttsLoading, ocrLoading, transcribeLoading, extractAudioLoading, extractLoading, handleReadAloud, handleOcr, handleTranscribe, handleExtractAudio, handleExtract, handleDownload } = + useFileViewer(); + + const textContent = content; + const showDownload = !directContent; + + const headerIcon = + fileType === 'audio' ? ( + + ) : fileType === 'video' ? ( + + ) : fileType === 'image' ? ( + + ) : fileType === 'pdf' ? ( + + ) : fileType === 'archive' ? ( + + ) : ( + + ); + + return ( + <> + {headerIcon} + {fileName} + + {fileType === 'code' ? getLang(fileName) : fileType} + + {(fileType === 'markdown' || fileType === 'code' || fileType === 'text') && textContent && ( + + )} + {fileType === 'image' && ( + + )} + {fileType === 'audio' && ( + + )} + {fileType === 'video' && ( + + )} + {fileType === 'archive' && ( + + )} + {showDownload && ( + + )} + + ); +}; + +// ── Body (renderers) ── + +export const FileViewerBody = () => { + const { filePath, fileName, root, fileType, content, loading, error, autoPlay } = useFileViewer(); + const scrollRef = useRef(null); + + const videoSrc = + fileType === 'video' + ? needsTranscode(fileName) + ? getTranscodeUrl(filePath, root) + : getRawUrl(filePath, root) + : ''; + + return ( +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : fileType === 'pdf' ? ( + + ) : fileType === 'archive' ? ( +
+ + Archive file + {getArchiveBaseName(fileName)} +
+ ) : fileType === 'image' ? ( + + ) : fileType === 'video' ? ( + + ) : fileType === 'audio' ? ( + + ) : content !== null ? ( + fileType === 'code' ? ( +
+ + +
+ ) : ( +
+ {fileType === 'markdown' ? ( + + ) : ( + + )} + +
+ ) + ) : null} +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileViewer.tsx b/src/workspaces/apps/FileViewer/FileViewerView.tsx similarity index 60% rename from src/apps/officer-web/Screens/Dashboard/Files/Screen/FileViewer.tsx rename to src/workspaces/apps/FileViewer/FileViewerView.tsx index e69fca64..19696620 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileViewer.tsx +++ b/src/workspaces/apps/FileViewer/FileViewerView.tsx @@ -1,15 +1,10 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { X, - Download, Loader2, Music, - Film, - Image, - FileType2, Maximize2, Minimize2, - Square, Play, Pause, Volume2, @@ -23,172 +18,8 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSlug from 'rehype-slug'; -import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog'; -import * as DialogPrimitive from '@radix-ui/react-dialog'; -import { cardStyle } from '@/components/Card'; -import { useFiles } from 'apps/FileBrowser'; -import { getHeaders } from 'hooks/useClient'; -import { config } from 'config'; -import { toast } from 'sonner'; -import { getIcon } from 'material-file-icons'; - -type FileViewerProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - filePath: string; - fileName: string; - root?: string; -}; - -type FileType = 'markdown' | 'audio' | 'video' | 'image' | 'pdf' | 'code' | 'text'; - -const AUDIO_EXTS = ['mp3', 'wav', 'flac', 'ogg', 'oga', 'opus', 'aac', 'm4a', 'wma']; -const NATIVE_VIDEO_EXTS = ['mp4', 'm4v', 'webm', 'ogv', 'mov']; -const TRANSCODE_VIDEO_EXTS = [ - 'mkv', - 'avi', - 'wmv', - 'flv', - 'ts', - 'mts', - 'm2ts', - '3gp', - '3g2', - 'vob', - 'divx', - 'asf', - 'f4v', - 'rm', - 'rmvb', -]; -const ALL_VIDEO_EXTS = [...NATIVE_VIDEO_EXTS, ...TRANSCODE_VIDEO_EXTS]; -const IMAGE_EXTS = [ - 'jpg', - 'jpeg', - 'png', - 'gif', - 'webp', - 'svg', - 'bmp', - 'ico', - 'tiff', - 'tif', - 'avif', - 'heic', - 'heif', - 'jfif', - 'apng', -]; - -// Map file extension to shiki language identifier -const EXT_TO_LANG: Record = { - js: 'javascript', - mjs: 'javascript', - cjs: 'javascript', - jsx: 'jsx', - ts: 'typescript', - mts: 'typescript', - cts: 'typescript', - tsx: 'tsx', - json: 'json', - jsonc: 'jsonc', - html: 'html', - htm: 'html', - css: 'css', - scss: 'scss', - sass: 'sass', - less: 'less', - py: 'python', - rb: 'ruby', - rs: 'rust', - go: 'go', - java: 'java', - kt: 'kotlin', - kts: 'kotlin', - swift: 'swift', - c: 'c', - h: 'c', - cpp: 'cpp', - cc: 'cpp', - cxx: 'cpp', - hpp: 'cpp', - cs: 'csharp', - php: 'php', - sh: 'bash', - bash: 'bash', - zsh: 'bash', - sql: 'sql', - yaml: 'yaml', - yml: 'yaml', - toml: 'toml', - xml: 'xml', - xsl: 'xml', - lua: 'lua', - r: 'r', - dart: 'dart', - vue: 'vue', - svelte: 'svelte', - graphql: 'graphql', - gql: 'graphql', - dockerfile: 'dockerfile', - makefile: 'makefile', - zig: 'zig', - elixir: 'elixir', - ex: 'elixir', - exs: 'elixir', -}; - -function getFileType(name: string): FileType { - const ext = name.split('.').pop()?.toLowerCase() ?? ''; - const baseName = name.toLowerCase(); - if (['md', 'mdx', 'markdown'].includes(ext)) return 'markdown'; - if (ext === 'pdf') return 'pdf'; - if (EXT_TO_LANG[ext]) return 'code'; - if (AUDIO_EXTS.includes(ext)) return 'audio'; - if (ALL_VIDEO_EXTS.includes(ext)) return 'video'; - if (IMAGE_EXTS.includes(ext)) return 'image'; - if (baseName === 'dockerfile' || baseName === 'makefile') return 'code'; - return 'text'; -} - -function getLang(name: string): string { - const ext = name.split('.').pop()?.toLowerCase() ?? ''; - const baseName = name.toLowerCase(); - if (baseName === 'dockerfile') return 'dockerfile'; - if (baseName === 'makefile') return 'makefile'; - return EXT_TO_LANG[ext] ?? 'text'; -} - -function getExt(name: string): string { - return name.split('.').pop()?.toLowerCase() ?? ''; -} - -function needsTranscode(name: string): boolean { - return TRANSCODE_VIDEO_EXTS.includes(getExt(name)); -} - -function getRawUrl(filePath: string, root?: string): string { - const headers = getHeaders(); - const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; - const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; - return `${config.API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`; -} - -function getTranscodeUrl(filePath: string, root?: string, t = '0'): string { - const headers = getHeaders(); - const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; - const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; - return `${config.API_URL}/file-browser/transcode?path=${encodeURIComponent(filePath)}&t=${encodeURIComponent(t)}&token=${encodeURIComponent(token)}${rootParam}`; -} - -function formatTime(s: number): string { - if (!isFinite(s) || isNaN(s)) return '0:00'; - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = Math.floor(s % 60); - if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; - return `${m}:${sec.toString().padStart(2, '0')}`; -} +import { getExt, formatTime } from './file-types'; +import { FileViewerProvider, FileViewerHeader, FileViewerBody } from './FileViewerContext'; // ── Shared seek bar hook ── function useSeekBar(mediaRef: React.RefObject, duration: number) { @@ -318,7 +149,7 @@ function HighlightedCodeBlock({ code, lang }: { code: string; lang: string }) { } // ── Scroll to top button ── -function ScrollToTopButton({ scrollContainer }: { scrollContainer: React.RefObject }) { +export function ScrollToTopButton({ scrollContainer }: { scrollContainer: React.RefObject }) { const [visible, setVisible] = useState(false); useEffect(() => { @@ -343,7 +174,7 @@ function ScrollToTopButton({ scrollContainer }: { scrollContainer: React.RefObje } // ── Markdown renderer ── -const MarkdownRenderer = ({ +export const MarkdownRenderer = ({ content, scrollContainer, }: { @@ -400,12 +231,12 @@ const MarkdownRenderer = ({ }; // ── Text renderer ── -const TextRenderer = ({ content }: { content: string }) => ( +export const TextRenderer = ({ content }: { content: string }) => (
{content}
); // ── Code renderer with syntax highlighting ── -const CodeRenderer = ({ content, lang }: { content: string; lang: string }) => { +export const CodeRenderer = ({ content, lang }: { content: string; lang: string }) => { const [html, setHtml] = useState(null); useEffect(() => { @@ -436,7 +267,7 @@ const CodeRenderer = ({ content, lang }: { content: string; lang: string }) => { }; // ── Audio renderer ── -const AudioRenderer = ({ src, fileName }: { src: string; fileName: string }) => { +export const AudioRenderer = ({ src, fileName, autoPlay = false }: { src: string; fileName: string; autoPlay?: boolean }) => { const audioRef = useRef(null); const [playing, setPlaying] = useState(false); const [currentTime, setCurrent] = useState(0); @@ -455,6 +286,10 @@ const AudioRenderer = ({ src, fileName }: { src: string; fileName: string }) => const onLoaded = () => { setDuration(a.duration); setLoaded(true); + if (autoPlay) { + a.play(); + setPlaying(true); + } }; const onTime = () => setCurrent(a.currentTime); const onEnded = () => setPlaying(false); @@ -479,8 +314,8 @@ const AudioRenderer = ({ src, fileName }: { src: string; fileName: string }) => setPlaying(!playing); }, [playing]); - const changeVolume = useCallback((e: React.ChangeEvent) => { - const v = parseFloat(e.target.value); + const changeVolume = useCallback((ev: React.ChangeEvent) => { + const v = parseFloat(ev.target.value); setVolume(v); setMuted(v === 0); if (audioRef.current) audioRef.current.volume = v; @@ -570,7 +405,7 @@ const AudioRenderer = ({ src, fileName }: { src: string; fileName: string }) => }; // ── Video renderer ── -const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) => { +export const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) => { const videoRef = useRef(null); const containerRef = useRef(null); const hideTimer = useRef>(null); @@ -613,14 +448,12 @@ const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) => }; }, []); - // Fullscreen change detection useEffect(() => { const onChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onChange); return () => document.removeEventListener('fullscreenchange', onChange); }, []); - // Auto-hide controls const resetHideTimer = useCallback(() => { setShowControls(true); if (hideTimer.current) clearTimeout(hideTimer.current); @@ -636,8 +469,8 @@ const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) => else v.pause(); }, []); - const changeVolume = useCallback((e: React.ChangeEvent) => { - const val = parseFloat(e.target.value); + const changeVolume = useCallback((ev: React.ChangeEvent) => { + const val = parseFloat(ev.target.value); setVolume(val); setMuted(val === 0); if (videoRef.current) videoRef.current.volume = val; @@ -683,7 +516,6 @@ const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) => >