docs / src /components /file-browser.tsx
Zerotracex-Stuff
First model version
a5871f0
'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>
);
}