Spaces:
Build error
Build error
| 'use client'; | |
| import * as React from 'react'; | |
| import { Search, Send, Download, X, List, LayoutGrid, CheckSquare, XSquare } from 'lucide-react'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Logo } from '@/components/logo'; | |
| import { FolderTree } from '@/components/folder-tree'; | |
| import { FileList } from '@/components/file-list'; | |
| import { findNodeByPath, getAllFiles, getNodesByIds } from '@/lib/utils'; | |
| import type { File as FileType, Folder, FileSystemNode } from '@/app/api/files/route'; | |
| import { Card, CardContent } from './ui/card'; | |
| import { MobileSheet } from './mobile-sheet'; | |
| import { Button } from './ui/button'; | |
| import Link from 'next/link'; | |
| import { ThemeToggle } from './theme-toggle'; | |
| import { AnimatePresence, motion } from 'framer-motion'; | |
| import { useToast } from '@/hooks/use-toast'; | |
| import JSZip from 'jszip'; | |
| import { GridView } from './grid-view'; | |
| import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group'; | |
| import { useLocalStorage } from '@/hooks/use-local-storage'; | |
| import { cn } from '@/lib/utils'; | |
| interface FileBrowserProps { | |
| initialData: Folder; | |
| isPublicShare?: boolean; | |
| } | |
| export function FileBrowser({ initialData, isPublicShare = false }: FileBrowserProps) { | |
| const [fileSystemData] = React.useState(initialData); | |
| const [currentPath, setCurrentPath] = React.useState('/'); | |
| const [searchTerm, setSearchTerm] = React.useState(''); | |
| const [selectedIds, setSelectedIds] = React.useState<string[]>([]); | |
| const [view, setView] = useLocalStorage<'list' | 'grid'>('file-browser-view', 'grid'); | |
| const [isSelectionMode, setIsSelectionMode] = React.useState(false); | |
| const { toast, dismiss } = useToast(); | |
| React.useEffect(() => { | |
| if (!isSelectionMode) { | |
| setSelectedIds([]); | |
| } | |
| }, [isSelectionMode]); | |
| const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => { | |
| setSearchTerm(event.target.value); | |
| setSelectedIds([]); // Clear selection on new search | |
| }; | |
| const currentFolder = React.useMemo(() => { | |
| if (isPublicShare) return fileSystemData; | |
| return findNodeByPath(fileSystemData, currentPath); | |
| }, [fileSystemData, currentPath, isPublicShare]); | |
| const { filesToShow, foldersToShow, allVisibleItems } = React.useMemo(() => { | |
| if (searchTerm) { | |
| const allFiles = getAllFiles(fileSystemData); | |
| const filteredFiles = allFiles.filter((file) => | |
| file.name.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ); | |
| return { | |
| filesToShow: filteredFiles, | |
| foldersToShow: [], | |
| allVisibleItems: filteredFiles, | |
| }; | |
| } | |
| if (currentFolder && currentFolder.type === 'folder') { | |
| const files = currentFolder.children.filter( | |
| (child): child is FileType => child.type === 'file' | |
| ); | |
| const folders = currentFolder.children.filter( | |
| (child): child is Folder => child.type === 'folder' | |
| ); | |
| return { | |
| filesToShow: files, | |
| foldersToShow: folders, | |
| allVisibleItems: [...folders, ...files] | |
| }; | |
| } | |
| return { filesToShow: [], foldersToShow: [], allVisibleItems: [] }; | |
| }, [fileSystemData, currentPath, searchTerm, currentFolder]); | |
| const breadcrumbs = currentPath.split('/').filter(Boolean); | |
| const handleSelectAll = (isChecked: boolean) => { | |
| if (isChecked) { | |
| setSelectedIds(allVisibleItems.map(item => item.id)); | |
| } else { | |
| setSelectedIds([]); | |
| } | |
| }; | |
| const handleSelectItem = (id: string, isChecked: boolean) => { | |
| setSelectedIds(prev => | |
| isChecked ? [...prev, id] : prev.filter(selectedId => selectedId !== id) | |
| ); | |
| }; | |
| const handleBatchDownload = async () => { | |
| const { saveAs } = await import('file-saver'); | |
| const { id: toastId } = toast({ | |
| title: 'Preparing Download', | |
| description: 'Zipping your files... Please wait.', | |
| }); | |
| try { | |
| const selectedNodes = getNodesByIds(fileSystemData, selectedIds); | |
| const filesToZip: FileType[] = []; | |
| selectedNodes.forEach(node => { | |
| if (node.type === 'file') { | |
| filesToZip.push(node); | |
| } else if (node.type === 'folder') { | |
| filesToZip.push(...getAllFiles(node)); | |
| } | |
| }); | |
| if (filesToZip.length === 0) { | |
| toast({ | |
| variant: 'destructive', | |
| title: 'No Files Selected', | |
| description: 'Please select files to download.', | |
| }); | |
| dismiss(toastId); | |
| return; | |
| } | |
| const zip = new JSZip(); | |
| await Promise.all( | |
| filesToZip.map(async (file) => { | |
| const response = await fetch(file.path); | |
| const blob = await response.blob(); | |
| // Use the relative path within the zip file | |
| const zipPath = file.path.startsWith('/') ? file.path.substring(1) : file.path; | |
| zip.file(zipPath, blob); | |
| }) | |
| ); | |
| const zipBlob = await zip.generateAsync({ type: 'blob' }); | |
| saveAs(zipBlob, 'medico-docs.zip'); | |
| toast({ | |
| title: 'Download Ready', | |
| description: 'Your ZIP file has been downloaded.', | |
| }); | |
| } catch (error) { | |
| console.error('Batch download failed:', error); | |
| toast({ | |
| variant: 'destructive', | |
| title: 'Download Failed', | |
| description: 'There was an error creating the ZIP file.', | |
| }); | |
| } finally { | |
| dismiss(toastId); | |
| setSelectedIds([]); | |
| setIsSelectionMode(false); | |
| } | |
| }; | |
| const numSelected = selectedIds.length; | |
| return ( | |
| <div className="grid md:grid-cols-[280px_1fr] h-screen w-full bg-background font-body text-foreground"> | |
| <aside className={cn("hidden md:flex flex-col border-r bg-card", isPublicShare && "hidden")}> | |
| <div className="p-4 border-b"> | |
| <Logo /> | |
| </div> | |
| <div className="flex-1 overflow-auto py-2"> | |
| <FolderTree | |
| rootFolder={fileSystemData} | |
| currentPath={currentPath} | |
| onSelectFolder={setCurrentPath} | |
| /> | |
| </div> | |
| </aside> | |
| <div className="flex flex-col"> | |
| <header className="flex h-16 items-center gap-4 border-b bg-card px-6 shrink-0"> | |
| <div className="md:hidden"> | |
| {isPublicShare ? <Logo /> : <MobileSheet rootFolder={fileSystemData} currentPath={currentPath} onSelectFolder={setCurrentPath} />} | |
| </div> | |
| {!isPublicShare && <div className="hidden md:block w-[215px]"/>} | |
| <div className="relative flex-1"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search documents by name..." | |
| className="pl-10 h-10 bg-card" | |
| value={searchTerm} | |
| onChange={handleSearchChange} | |
| /> | |
| </div> | |
| <ThemeToggle /> | |
| {isSelectionMode ? ( | |
| <Button variant="outline" onClick={() => setIsSelectionMode(false)}> | |
| <XSquare className="mr-2 h-4 w-4" /> | |
| Cancel | |
| </Button> | |
| ) : ( | |
| <Button variant="outline" onClick={() => setIsSelectionMode(true)}> | |
| <CheckSquare className="mr-2 h-4 w-4" /> | |
| Select | |
| </Button> | |
| )} | |
| {!isPublicShare && ( | |
| <Link href="https://t.me/ztx" target="_blank"> | |
| <Button> | |
| <Send className="mr-2 h-4 w-4" /> | |
| Contact Me | |
| </Button> | |
| </Link> | |
| )} | |
| </header> | |
| <main className="flex-1 overflow-auto p-6 space-y-4"> | |
| <div className="flex items-center justify-between gap-4"> | |
| <div className="flex items-center gap-2 text-sm text-muted-foreground"> | |
| {isPublicShare || breadcrumbs.length === 0 ? ( | |
| <span>{currentFolder?.name || 'Files'}</span> | |
| ) : ( | |
| <React.Fragment> | |
| <span className="cursor-pointer hover:underline" onClick={() => setCurrentPath('/')}>Files</span> | |
| {breadcrumbs.map((crumb, index) => { | |
| const path = '/' + breadcrumbs.slice(0, index + 1).join('/'); | |
| return ( | |
| <React.Fragment key={index}> | |
| <span>/</span> | |
| <span className="cursor-pointer hover:underline" onClick={() => setCurrentPath(path)}>{crumb}</span> | |
| </React.Fragment> | |
| ) | |
| })} | |
| </React.Fragment> | |
| )} | |
| </div> | |
| <ToggleGroup type="single" value={view} onValueChange={(value) => { if (value) setView(value as 'list' | 'grid')}} size="sm"> | |
| <ToggleGroupItem value="list" aria-label="List view"> | |
| <List className="h-4 w-4" /> | |
| </ToggleGroupItem> | |
| <ToggleGroupItem value="grid" aria-label="Grid view"> | |
| <LayoutGrid className="h-4 w-4" /> | |
| </ToggleGroupItem> | |
| </ToggleGroup> | |
| </div> | |
| <h1 className="text-2xl font-bold"> | |
| {searchTerm ? `Search Results` : currentFolder?.name} | |
| </h1> | |
| <Card className="shadow-sm"> | |
| <CardContent className="p-0"> | |
| {view === 'list' ? ( | |
| <FileList | |
| files={filesToShow} | |
| folders={foldersToShow} | |
| searchTerm={searchTerm} | |
| onSelectFolder={setCurrentPath} | |
| selectedIds={selectedIds} | |
| onSelectAll={handleSelectAll} | |
| onSelectItem={handleSelectItem} | |
| allItemCount={allVisibleItems.length} | |
| isSelectionActive={isSelectionMode} | |
| isPublicShare={isPublicShare} | |
| /> | |
| ) : ( | |
| <GridView | |
| files={filesToShow} | |
| folders={foldersToShow} | |
| searchTerm={searchTerm} | |
| onSelectFolder={setCurrentPath} | |
| selectedIds={selectedIds} | |
| onSelectItem={handleSelectItem} | |
| isSelectionActive={isSelectionMode} | |
| isPublicShare={isPublicShare} | |
| /> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </main> | |
| <AnimatePresence> | |
| {numSelected > 0 && ( | |
| <motion.div | |
| initial={{ y: 100, opacity: 0 }} | |
| animate={{ y: 0, opacity: 1 }} | |
| exit={{ y: 100, opacity: 0 }} | |
| transition={{ type: 'spring', stiffness: 300, damping: 30 }} | |
| className="fixed bottom-6 left-1/2 -translate-x-1/2 w-auto bg-primary/90 text-primary-foreground backdrop-blur-md rounded-lg shadow-2xl z-50 overflow-hidden" | |
| > | |
| <div className="flex items-center gap-4 px-4 py-2"> | |
| <div className="flex items-center gap-2"> | |
| <Button variant="ghost" size="icon" className="hover:bg-primary-foreground/10" onClick={() => setSelectedIds([])}> | |
| <X className="h-5 w-5" /> | |
| </Button> | |
| <span className="font-medium text-sm whitespace-nowrap">{numSelected} item{numSelected > 1 ? 's' : ''} selected</span> | |
| </div> | |
| <div className="h-6 w-px bg-primary-foreground/20" /> | |
| <div className="flex items-center gap-2"> | |
| <Button variant="ghost" className="hover:bg-primary-foreground/10" onClick={handleBatchDownload}> | |
| <Download className="mr-2 h-4 w-4"/> | |
| Download | |
| </Button> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| ); | |
| } | |