feat(files): add file browser with drive-style UI

google drive-inspired file browser with grid/list views,
folder navigation, context menus, and file management
dialogs. sidebar transitions to file-specific nav when
on /dashboard/files routes. mock data for now, real R2
backend later.
This commit is contained in:
Nicholai Vogel 2026-01-23 21:06:49 -07:00
parent aa6230c9d4
commit 598047635d
24 changed files with 2634 additions and 2 deletions

View File

@ -0,0 +1,17 @@
"use client"
import { Suspense } from "react"
import { useParams } from "next/navigation"
import { FileBrowser } from "@/components/files/file-browser"
export default function FilesPathPage() {
const params = useParams()
const path = (params.path as string[]) ?? []
const decodedPath = path.map(decodeURIComponent)
return (
<Suspense>
<FileBrowser path={decodedPath} />
</Suspense>
)
}

View File

@ -0,0 +1,17 @@
"use client"
import { Toaster } from "sonner"
import { FilesProvider } from "@/hooks/use-files"
export default function FilesLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<FilesProvider>
{children}
<Toaster position="bottom-right" />
</FilesProvider>
)
}

View File

@ -0,0 +1,12 @@
"use client"
import { Suspense } from "react"
import { FileBrowser } from "@/components/files/file-browser"
export default function FilesPage() {
return (
<Suspense>
<FileBrowser path={[]} />
</Suspense>
)
}

View File

@ -4,15 +4,18 @@ import * as React from "react"
import {
IconCalendarStats,
IconDashboard,
IconFiles,
IconFolder,
IconHelp,
IconInnerShadowTop,
IconSearch,
IconSettings,
} from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import { NavMain } from "@/components/nav-main"
import { NavSecondary } from "@/components/nav-secondary"
import { NavFiles } from "@/components/nav-files"
import { NavUser } from "@/components/nav-user"
import {
Sidebar,
@ -46,6 +49,11 @@ const data = {
url: "/dashboard/projects/demo-project-1/schedule",
icon: IconCalendarStats,
},
{
title: "Files",
url: "/dashboard/files",
icon: IconFiles,
},
],
navSecondary: [
{
@ -67,6 +75,9 @@ const data = {
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname()
const isFilesMode = pathname?.startsWith("/dashboard/files")
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
@ -85,8 +96,29 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
<div className="relative flex-1 overflow-hidden">
<div
className={`absolute inset-0 flex flex-col transition-all duration-200 ${
isFilesMode
? "-translate-x-full opacity-0"
: "translate-x-0 opacity-100"
}`}
>
<NavMain items={data.navMain} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</div>
<div
className={`absolute inset-0 flex flex-col transition-all duration-200 ${
isFilesMode
? "translate-x-0 opacity-100"
: "translate-x-full opacity-0"
}`}
>
<React.Suspense>
<NavFiles />
</React.Suspense>
</div>
</div>
</SidebarContent>
<SidebarFooter>
<NavUser user={data.user} />

View File

@ -0,0 +1,51 @@
"use client"
import Link from "next/link"
import { IconChevronRight } from "@tabler/icons-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export function FileBreadcrumb({ path }: { path: string[] }) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
{path.length === 0 ? (
<BreadcrumbPage>My Files</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href="/dashboard/files">My Files</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{path.map((segment, i) => {
const isLast = i === path.length - 1
const href = `/dashboard/files/${path.slice(0, i + 1).join("/")}`
return (
<span key={segment} className="contents">
<BreadcrumbSeparator>
<IconChevronRight size={14} />
</BreadcrumbSeparator>
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>{segment}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={href}>{segment}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
)
})}
</BreadcrumbList>
</Breadcrumb>
)
}

View File

@ -0,0 +1,145 @@
"use client"
import { useState, useCallback } from "react"
import { useSearchParams } from "next/navigation"
import { toast } from "sonner"
import type { FileItem } from "@/lib/files-data"
import { useFiles, type FileView } from "@/hooks/use-files"
import { useFileSelection } from "@/hooks/use-file-selection"
import { FileBreadcrumb } from "./file-breadcrumb"
import { FileToolbar, type NewFileType } from "./file-toolbar"
import { FileGrid } from "./file-grid"
import { FileList } from "./file-list"
import { FileUploadDialog } from "./file-upload-dialog"
import { FileNewFolderDialog } from "./file-new-folder-dialog"
import { FileRenameDialog } from "./file-rename-dialog"
import { FileMoveDialog } from "./file-move-dialog"
import { FileDropZone } from "./file-drop-zone"
import { ScrollArea } from "@/components/ui/scroll-area"
export function FileBrowser({ path }: { path: string[] }) {
const searchParams = useSearchParams()
const viewParam = searchParams.get("view") as FileView | null
const currentView = viewParam ?? "my-files"
const { state, dispatch, getFilesForView } = useFiles()
const files = getFilesForView(currentView, path)
const [uploadOpen, setUploadOpen] = useState(false)
const [newFolderOpen, setNewFolderOpen] = useState(false)
const [renameFile, setRenameFile] = useState<FileItem | null>(null)
const [moveFile, setMoveFile] = useState<FileItem | null>(null)
const { handleClick } = useFileSelection(files, state.selectedIds, {
select: (ids) => dispatch({ type: "SET_SELECTED", payload: ids }),
})
const handleBackgroundClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
dispatch({ type: "CLEAR_SELECTION" })
}
},
[dispatch]
)
const parentId = (() => {
if (path.length === 0) return null
const folder = state.files.find(
(f) =>
f.type === "folder" &&
f.name === path[path.length - 1] &&
JSON.stringify(f.path) === JSON.stringify(path.slice(0, -1))
)
return folder?.id ?? null
})()
const handleNew = useCallback(
(type: NewFileType) => {
if (type === "folder") {
setNewFolderOpen(true)
return
}
const names: Record<string, string> = {
document: "Untitled Document",
spreadsheet: "Untitled Spreadsheet",
presentation: "Untitled Presentation",
}
const fileType = type === "presentation" ? "document" : type
dispatch({
type: "CREATE_FILE",
payload: {
name: names[type],
fileType: fileType as FileItem["type"],
parentId,
path,
},
})
toast.success(`${names[type]} created`)
},
[dispatch, parentId, path]
)
const viewTitle: Record<FileView, string> = {
"my-files": "",
shared: "Shared with me",
recent: "Recent",
starred: "Starred",
trash: "Trash",
}
return (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
{currentView !== "my-files" && (
<h1 className="text-lg font-semibold">{viewTitle[currentView]}</h1>
)}
{currentView === "my-files" && <FileBreadcrumb path={path} />}
<FileToolbar
onNew={handleNew}
onUpload={() => setUploadOpen(true)}
/>
<FileDropZone onDrop={() => setUploadOpen(true)}>
<ScrollArea className="flex-1" onClick={handleBackgroundClick}>
{state.viewMode === "grid" ? (
<FileGrid
files={files}
selectedIds={state.selectedIds}
onItemClick={handleClick}
onRename={setRenameFile}
onMove={setMoveFile}
/>
) : (
<FileList
files={files}
selectedIds={state.selectedIds}
onItemClick={handleClick}
onRename={setRenameFile}
onMove={setMoveFile}
/>
)}
</ScrollArea>
</FileDropZone>
<FileUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} />
<FileNewFolderDialog
open={newFolderOpen}
onOpenChange={setNewFolderOpen}
currentPath={path}
parentId={parentId}
/>
<FileRenameDialog
open={!!renameFile}
onOpenChange={(open) => !open && setRenameFile(null)}
file={renameFile}
/>
<FileMoveDialog
open={!!moveFile}
onOpenChange={(open) => !open && setMoveFile(null)}
file={moveFile}
/>
</div>
)
}

View File

@ -0,0 +1,109 @@
"use client"
import {
IconDownload,
IconEdit,
IconFolderSymlink,
IconShare,
IconStar,
IconStarFilled,
IconTrash,
IconTrashOff,
} from "@tabler/icons-react"
import type { FileItem } from "@/lib/files-data"
import { useFiles } from "@/hooks/use-files"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { toast } from "sonner"
export function FileContextMenu({
file,
children,
onRename,
onMove,
}: {
file: FileItem
children: React.ReactNode
onRename: (file: FileItem) => void
onMove: (file: FileItem) => void
}) {
const { dispatch } = useFiles()
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{file.type === "folder" && (
<ContextMenuItem onClick={() => toast.info("Opening folder...")}>
<IconFolderSymlink size={16} className="mr-2" />
Open
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => toast.info("Share dialog coming soon")}>
<IconShare size={16} className="mr-2" />
Share
</ContextMenuItem>
{!file.trashed && (
<>
<ContextMenuItem onClick={() => toast.success("Download started")}>
<IconDownload size={16} className="mr-2" />
Download
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onRename(file)}>
<IconEdit size={16} className="mr-2" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => onMove(file)}>
<IconFolderSymlink size={16} className="mr-2" />
Move to
</ContextMenuItem>
<ContextMenuItem
onClick={() => dispatch({ type: "STAR_FILE", payload: file.id })}
>
{file.starred ? (
<>
<IconStarFilled size={16} className="mr-2 text-amber-400" />
Unstar
</>
) : (
<>
<IconStar size={16} className="mr-2" />
Star
</>
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive"
onClick={() => {
dispatch({ type: "TRASH_FILE", payload: file.id })
toast.success(`"${file.name}" moved to trash`)
}}
>
<IconTrash size={16} className="mr-2" />
Delete
</ContextMenuItem>
</>
)}
{file.trashed && (
<ContextMenuItem
onClick={() => {
dispatch({ type: "RESTORE_FILE", payload: file.id })
toast.success(`"${file.name}" restored`)
}}
>
<IconTrashOff size={16} className="mr-2" />
Restore
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
}

View File

@ -0,0 +1,81 @@
"use client"
import { useState, useCallback } from "react"
import { IconCloudUpload } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
export function FileDropZone({
children,
onDrop,
}: {
children: React.ReactNode
onDrop: () => void
}) {
const [dragging, setDragging] = useState(false)
const [dragCounter, setDragCounter] = useState(0)
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setDragCounter((c) => c + 1)
if (e.dataTransfer.types.includes("Files")) {
setDragging(true)
}
},
[]
)
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setDragCounter((c) => {
const next = c - 1
if (next <= 0) setDragging(false)
return Math.max(0, next)
})
},
[]
)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
setDragCounter(0)
if (e.dataTransfer.files.length > 0) {
onDrop()
}
},
[onDrop]
)
return (
<div
className="relative flex-1"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{children}
<div
className={cn(
"pointer-events-none absolute inset-0 z-50 flex items-center justify-center",
"rounded-lg border-2 border-dashed transition-all duration-200",
dragging
? "border-primary bg-primary/5 opacity-100"
: "border-transparent opacity-0"
)}
>
<div className="flex flex-col items-center gap-2 text-primary">
<IconCloudUpload size={48} strokeWidth={1.5} />
<p className="text-sm font-medium">Drop files to upload</p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,74 @@
"use client"
import type { FileItem as FileItemType } from "@/lib/files-data"
import { FolderCard, FileCard } from "./file-item"
import { FileContextMenu } from "./file-context-menu"
import { IconFile } from "@tabler/icons-react"
export function FileGrid({
files,
selectedIds,
onItemClick,
onRename,
onMove,
}: {
files: FileItemType[]
selectedIds: Set<string>
onItemClick: (id: string, e: React.MouseEvent) => void
onRename: (file: FileItemType) => void
onMove: (file: FileItemType) => void
}) {
if (files.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<IconFile size={48} strokeWidth={1} className="mb-3 opacity-40" />
<p className="text-sm font-medium">No files here</p>
<p className="text-xs mt-1">Upload files or create a folder to get started</p>
</div>
)
}
const folders = files.filter((f) => f.type === "folder")
const regularFiles = files.filter((f) => f.type !== "folder")
return (
<div className="space-y-6">
{folders.length > 0 && (
<section>
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
Folders
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
{folders.map((file) => (
<FileContextMenu key={file.id} file={file} onRename={onRename} onMove={onMove}>
<FolderCard
file={file}
selected={selectedIds.has(file.id)}
onClick={(e) => onItemClick(file.id, e)}
/>
</FileContextMenu>
))}
</div>
</section>
)}
{regularFiles.length > 0 && (
<section>
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
Files
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{regularFiles.map((file) => (
<FileContextMenu key={file.id} file={file} onRename={onRename} onMove={onMove}>
<FileCard
file={file}
selected={selectedIds.has(file.id)}
onClick={(e) => onItemClick(file.id, e)}
/>
</FileContextMenu>
))}
</div>
</section>
)}
</div>
)
}

View File

@ -0,0 +1,40 @@
import {
IconFolder,
IconFileText,
IconTable,
IconPhoto,
IconVideo,
IconFileTypePdf,
IconCode,
IconFileZip,
IconMusic,
IconFile,
} from "@tabler/icons-react"
import type { FileType } from "@/lib/files-data"
import { cn } from "@/lib/utils"
const iconMap: Record<FileType, { icon: typeof IconFile; color: string }> = {
folder: { icon: IconFolder, color: "text-amber-500" },
document: { icon: IconFileText, color: "text-blue-500" },
spreadsheet: { icon: IconTable, color: "text-green-600" },
image: { icon: IconPhoto, color: "text-purple-500" },
video: { icon: IconVideo, color: "text-red-500" },
pdf: { icon: IconFileTypePdf, color: "text-red-600" },
code: { icon: IconCode, color: "text-emerald-500" },
archive: { icon: IconFileZip, color: "text-orange-500" },
audio: { icon: IconMusic, color: "text-pink-500" },
unknown: { icon: IconFile, color: "text-muted-foreground" },
}
export function FileIcon({
type,
className,
size = 20,
}: {
type: FileType
className?: string
size?: number
}) {
const { icon: Icon, color } = iconMap[type]
return <Icon size={size} className={cn(color, className)} />
}

View File

@ -0,0 +1,143 @@
"use client"
import { forwardRef } from "react"
import { IconStar, IconStarFilled, IconUsers, IconDots } from "@tabler/icons-react"
import { useRouter } from "next/navigation"
import type { FileItem as FileItemType } from "@/lib/files-data"
import { formatRelativeDate } from "@/lib/file-utils"
import { FileIcon } from "./file-icon"
import { useFiles } from "@/hooks/use-files"
import { cn } from "@/lib/utils"
const fileTypeColors: Record<string, string> = {
document: "bg-blue-50 dark:bg-blue-950/30",
spreadsheet: "bg-green-50 dark:bg-green-950/30",
image: "bg-purple-50 dark:bg-purple-950/30",
video: "bg-red-50 dark:bg-red-950/30",
pdf: "bg-red-50 dark:bg-red-950/30",
code: "bg-emerald-50 dark:bg-emerald-950/30",
archive: "bg-orange-50 dark:bg-orange-950/30",
audio: "bg-pink-50 dark:bg-pink-950/30",
unknown: "bg-muted",
}
export const FolderCard = forwardRef<
HTMLDivElement,
{
file: FileItemType
selected: boolean
onClick: (e: React.MouseEvent) => void
}
>(function FolderCard({ file, selected, onClick, ...props }, ref) {
const router = useRouter()
const { dispatch } = useFiles()
const handleDoubleClick = () => {
const folderPath = [...file.path, file.name].join("/")
router.push(`/dashboard/files/${folderPath}`)
}
return (
<div
ref={ref}
className={cn(
"group flex items-center gap-3 rounded-xl border bg-card px-4 py-3 cursor-pointer",
"hover:shadow-sm hover:border-border/80 transition-all",
selected && "border-primary ring-2 ring-primary/20"
)}
onClick={onClick}
onDoubleClick={handleDoubleClick}
{...props}
>
<FileIcon type="folder" size={22} />
<span className="text-sm font-medium truncate flex-1">{file.name}</span>
{file.shared && (
<IconUsers size={14} className="text-muted-foreground shrink-0" />
)}
<button
className={cn(
"shrink-0 opacity-0 group-hover:opacity-100 transition-opacity",
file.starred && "opacity-100"
)}
onClick={(e) => {
e.stopPropagation()
dispatch({ type: "STAR_FILE", payload: file.id })
}}
>
{file.starred ? (
<IconStarFilled size={14} className="text-amber-400" />
) : (
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" />
)}
</button>
</div>
)
})
export const FileCard = forwardRef<
HTMLDivElement,
{
file: FileItemType
selected: boolean
onClick: (e: React.MouseEvent) => void
}
>(function FileCard({ file, selected, onClick, ...props }, ref) {
const { dispatch } = useFiles()
return (
<div
ref={ref}
className={cn(
"group relative flex flex-col rounded-xl border bg-card overflow-hidden cursor-pointer",
"hover:shadow-sm hover:border-border/80 transition-all",
selected && "border-primary ring-2 ring-primary/20"
)}
onClick={onClick}
{...props}
>
<div
className={cn(
"flex items-center justify-center h-32",
fileTypeColors[file.type] ?? fileTypeColors.unknown
)}
>
<FileIcon type={file.type} size={48} className="opacity-70" />
</div>
<div className="flex items-center gap-2.5 px-3 py-2.5 border-t">
<FileIcon type={file.type} size={16} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">
{formatRelativeDate(file.modifiedAt)}
{file.shared && " · Shared"}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
className={cn(
"opacity-0 group-hover:opacity-100 transition-opacity",
file.starred && "opacity-100"
)}
onClick={(e) => {
e.stopPropagation()
dispatch({ type: "STAR_FILE", payload: file.id })
}}
>
{file.starred ? (
<IconStarFilled size={14} className="text-amber-400" />
) : (
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" />
)}
</button>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<IconDots size={16} />
</button>
</div>
</div>
</div>
)
})

View File

@ -0,0 +1,62 @@
"use client"
import type { FileItem as FileItemType } from "@/lib/files-data"
import { FileRow } from "./file-row"
import { FileContextMenu } from "./file-context-menu"
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { IconFile } from "@tabler/icons-react"
export function FileList({
files,
selectedIds,
onItemClick,
onRename,
onMove,
}: {
files: FileItemType[]
selectedIds: Set<string>
onItemClick: (id: string, e: React.MouseEvent) => void
onRename: (file: FileItemType) => void
onMove: (file: FileItemType) => void
}) {
if (files.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<IconFile size={48} strokeWidth={1} className="mb-3 opacity-40" />
<p className="text-sm font-medium">No files here</p>
<p className="text-xs mt-1">Upload files or create a folder to get started</p>
</div>
)
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Modified</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Size</TableHead>
<TableHead className="w-8" />
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<FileContextMenu key={file.id} file={file} onRename={onRename} onMove={onMove}>
<FileRow
file={file}
selected={selectedIds.has(file.id)}
onClick={(e) => onItemClick(file.id, e)}
/>
</FileContextMenu>
))}
</TableBody>
</Table>
)
}

View File

@ -0,0 +1,100 @@
"use client"
import { useState } from "react"
import { IconFolder, IconFolderSymlink } from "@tabler/icons-react"
import type { FileItem } from "@/lib/files-data"
import { useFiles } from "@/hooks/use-files"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export function FileMoveDialog({
open,
onOpenChange,
file,
}: {
open: boolean
onOpenChange: (open: boolean) => void
file: FileItem | null
}) {
const { dispatch, getFolders } = useFiles()
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
const folders = getFolders().filter((f) => f.id !== file?.id)
const handleMove = () => {
if (!file) return
const targetFolder = folders.find((f) => f.id === selectedFolderId)
const targetPath = targetFolder
? [...targetFolder.path, targetFolder.name]
: []
dispatch({
type: "MOVE_FILE",
payload: {
id: file.id,
targetFolderId: selectedFolderId,
targetPath,
},
})
toast.success(
`Moved "${file.name}" to ${targetFolder?.name ?? "My Files"}`
)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconFolderSymlink size={18} />
Move to
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-64 rounded-md border p-2">
<button
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
selectedFolderId === null && "bg-accent"
)}
onClick={() => setSelectedFolderId(null)}
>
<IconFolder size={16} className="text-amber-500" />
My Files (root)
</button>
{folders.map((folder) => (
<button
key={folder.id}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
selectedFolderId === folder.id && "bg-accent"
)}
style={{ paddingLeft: `${(folder.path.length + 1) * 12 + 12}px` }}
onClick={() => setSelectedFolderId(folder.id)}
>
<IconFolder size={16} className="text-amber-500" />
{folder.name}
</button>
))}
</ScrollArea>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleMove}>Move here</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,74 @@
"use client"
import { useState } from "react"
import { IconFolderPlus } from "@tabler/icons-react"
import { useFiles } from "@/hooks/use-files"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { toast } from "sonner"
export function FileNewFolderDialog({
open,
onOpenChange,
currentPath,
parentId,
}: {
open: boolean
onOpenChange: (open: boolean) => void
currentPath: string[]
parentId: string | null
}) {
const [name, setName] = useState("")
const { dispatch } = useFiles()
const handleCreate = () => {
const trimmed = name.trim()
if (!trimmed) return
dispatch({
type: "CREATE_FOLDER",
payload: { name: trimmed, parentId, path: currentPath },
})
toast.success(`Folder "${trimmed}" created`)
setName("")
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconFolderPlus size={18} />
New folder
</DialogTitle>
</DialogHeader>
<div className="py-2">
<Input
placeholder="Folder name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!name.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,76 @@
"use client"
import { useState, useEffect } from "react"
import { IconEdit } from "@tabler/icons-react"
import type { FileItem } from "@/lib/files-data"
import { useFiles } from "@/hooks/use-files"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { toast } from "sonner"
export function FileRenameDialog({
open,
onOpenChange,
file,
}: {
open: boolean
onOpenChange: (open: boolean) => void
file: FileItem | null
}) {
const [name, setName] = useState("")
const { dispatch } = useFiles()
useEffect(() => {
if (file) setName(file.name)
}, [file])
const handleRename = () => {
if (!file) return
const trimmed = name.trim()
if (!trimmed || trimmed === file.name) return
dispatch({ type: "RENAME_FILE", payload: { id: file.id, name: trimmed } })
toast.success(`Renamed to "${trimmed}"`)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconEdit size={18} />
Rename
</DialogTitle>
</DialogHeader>
<div className="py-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleRename()}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleRename}
disabled={!name.trim() || name.trim() === file?.name}
>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,79 @@
"use client"
import { forwardRef } from "react"
import { useRouter } from "next/navigation"
import { IconStar, IconStarFilled, IconUsers } from "@tabler/icons-react"
import type { FileItem } from "@/lib/files-data"
import { formatFileSize, formatRelativeDate } from "@/lib/file-utils"
import { FileIcon } from "./file-icon"
import { useFiles } from "@/hooks/use-files"
import { TableCell, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
export const FileRow = forwardRef<
HTMLTableRowElement,
{
file: FileItem
selected: boolean
onClick: (e: React.MouseEvent) => void
}
>(function FileRow({ file, selected, onClick, ...props }, ref) {
const router = useRouter()
const { dispatch } = useFiles()
const handleDoubleClick = () => {
if (file.type === "folder") {
const folderPath = [...file.path, file.name].join("/")
router.push(`/dashboard/files/${folderPath}`)
}
}
return (
<TableRow
ref={ref}
className={cn(
"cursor-pointer group",
selected && "bg-primary/5"
)}
onClick={onClick}
onDoubleClick={handleDoubleClick}
{...props}
>
<TableCell className="w-[40%]">
<div className="flex items-center gap-2.5 min-w-0">
<FileIcon type={file.type} size={18} />
<span className="truncate text-sm font-medium">{file.name}</span>
{file.shared && <IconUsers size={13} className="text-muted-foreground shrink-0" />}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatRelativeDate(file.modifiedAt)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{file.owner.name}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{file.type === "folder" ? "—" : formatFileSize(file.size)}
</TableCell>
<TableCell className="w-8">
<button
className={cn(
"opacity-0 group-hover:opacity-100 transition-opacity",
file.starred && "opacity-100"
)}
onClick={(e) => {
e.stopPropagation()
dispatch({ type: "STAR_FILE", payload: file.id })
}}
>
{file.starred ? (
<IconStarFilled size={14} className="text-amber-400" />
) : (
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" />
)}
</button>
</TableCell>
</TableRow>
)
})

View File

@ -0,0 +1,157 @@
"use client"
import { useState } from "react"
import {
IconLayoutGrid,
IconList,
IconPlus,
IconUpload,
IconSearch,
IconSortAscending,
IconSortDescending,
IconFolder,
IconFileText,
IconTable,
IconPresentation,
} from "@tabler/icons-react"
import { useFiles, type SortField, type ViewMode } from "@/hooks/use-files"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export type NewFileType = "folder" | "document" | "spreadsheet" | "presentation"
export function FileToolbar({
onNew,
onUpload,
}: {
onNew: (type: NewFileType) => void
onUpload: () => void
}) {
const { state, dispatch } = useFiles()
const [searchFocused, setSearchFocused] = useState(false)
const sortLabels: Record<SortField, string> = {
name: "Name",
modified: "Modified",
size: "Size",
type: "Type",
}
const handleSort = (field: SortField) => {
const direction =
state.sortBy === field && state.sortDirection === "asc" ? "desc" : "asc"
dispatch({ type: "SET_SORT", payload: { field, direction } })
}
return (
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<IconPlus size={16} />
<span className="hidden sm:inline">New</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onNew("folder")}>
<IconFolder size={16} />
Folder
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onNew("document")}>
<IconFileText size={16} />
Document
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onNew("spreadsheet")}>
<IconTable size={16} />
Spreadsheet
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onNew("presentation")}>
<IconPresentation size={16} />
Presentation
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUpload}>
<IconUpload size={16} />
File upload
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex-1" />
<div
className={`relative transition-all duration-200 ${
searchFocused ? "w-64" : "w-44"
}`}
>
<IconSearch
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search files..."
value={state.searchQuery}
onChange={(e) =>
dispatch({ type: "SET_SEARCH", payload: e.target.value })
}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
className="h-8 pl-8 text-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost">
{state.sortDirection === "asc" ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
)}
<span className="hidden sm:inline">{sortLabels[state.sortBy]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(Object.keys(sortLabels) as SortField[]).map((field) => (
<DropdownMenuItem key={field} onClick={() => handleSort(field)}>
{sortLabels[field]}
{state.sortBy === field && (
<span className="ml-auto text-xs text-muted-foreground">
{state.sortDirection === "asc" ? "↑" : "↓"}
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ToggleGroup
type="single"
value={state.viewMode}
onValueChange={(v) => {
if (v) dispatch({ type: "SET_VIEW_MODE", payload: v as ViewMode })
}}
size="sm"
>
<ToggleGroupItem value="grid" aria-label="Grid view">
<IconLayoutGrid size={16} />
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List view">
<IconList size={16} />
</ToggleGroupItem>
</ToggleGroup>
</div>
)
}

View File

@ -0,0 +1,76 @@
"use client"
import { useState, useEffect } from "react"
import { IconUpload } from "@tabler/icons-react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Progress } from "@/components/ui/progress"
import { toast } from "sonner"
export function FileUploadDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [progress, setProgress] = useState(0)
const [uploading, setUploading] = useState(false)
useEffect(() => {
if (!open) {
setProgress(0)
setUploading(false)
return
}
setUploading(true)
const interval = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
clearInterval(interval)
setTimeout(() => {
onOpenChange(false)
toast.success("File uploaded successfully")
}, 300)
return 100
}
return prev + Math.random() * 15
})
}, 200)
return () => clearInterval(interval)
}, [open, onOpenChange])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconUpload size={18} />
Uploading file
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">example-file.pdf</span>
<span className="text-muted-foreground">
{Math.min(100, Math.round(progress))}%
</span>
</div>
<Progress value={Math.min(100, progress)} className="h-2" />
{uploading && progress < 100 && (
<p className="text-xs text-muted-foreground">
Uploading to cloud storage...
</p>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,22 @@
"use client"
import { Progress } from "@/components/ui/progress"
import { formatFileSize } from "@/lib/file-utils"
import type { StorageUsage } from "@/lib/files-data"
export function StorageIndicator({ usage }: { usage: StorageUsage }) {
const percent = Math.round((usage.used / usage.total) * 100)
return (
<div className="px-3 py-2">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1.5">
<span>Storage</span>
<span>{percent}% used</span>
</div>
<Progress value={percent} className="h-1.5" />
<p className="text-xs text-muted-foreground mt-1.5">
{formatFileSize(usage.used)} of {formatFileSize(usage.total)}
</p>
</div>
)
}

94
src/components/nav-files.tsx Executable file
View File

@ -0,0 +1,94 @@
"use client"
import {
IconArrowLeft,
IconFiles,
IconUsers,
IconClock,
IconStar,
IconTrash,
} from "@tabler/icons-react"
import Link from "next/link"
import { usePathname, useSearchParams } from "next/navigation"
import { mockStorageUsage } from "@/lib/files-data"
import { StorageIndicator } from "@/components/files/storage-indicator"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
} from "@/components/ui/sidebar"
import { cn } from "@/lib/utils"
type FileView = "my-files" | "shared" | "recent" | "starred" | "trash"
const fileNavItems: { title: string; view: FileView; icon: typeof IconFiles }[] = [
{ title: "My Files", view: "my-files", icon: IconFiles },
{ title: "Shared with me", view: "shared", icon: IconUsers },
{ title: "Recent", view: "recent", icon: IconClock },
{ title: "Starred", view: "starred", icon: IconStar },
{ title: "Trash", view: "trash", icon: IconTrash },
]
export function NavFiles() {
const pathname = usePathname()
const searchParams = useSearchParams()
const activeView = searchParams.get("view") ?? "my-files"
return (
<>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Back to Dashboard">
<Link href="/dashboard">
<IconArrowLeft />
<span>Back</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{fileNavItems.map((item) => (
<SidebarMenuItem key={item.view}>
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(
activeView === item.view &&
pathname?.startsWith("/dashboard/files") &&
"bg-sidebar-accent text-sidebar-accent-foreground"
)}
>
<Link
href={
item.view === "my-files"
? "/dashboard/files"
: `/dashboard/files?view=${item.view}`
}
>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<div className="mt-auto">
<SidebarSeparator />
<StorageIndicator usage={mockStorageUsage} />
</div>
</>
)
}

56
src/hooks/use-file-selection.ts Executable file
View File

@ -0,0 +1,56 @@
"use client"
import { useCallback, useRef } from "react"
import type { FileItem } from "@/lib/files-data"
type SelectionAction = {
select: (ids: Set<string>) => void
}
export function useFileSelection(
files: FileItem[],
selectedIds: Set<string>,
actions: SelectionAction
) {
const lastClickedRef = useRef<string | null>(null)
const handleClick = useCallback(
(id: string, event: React.MouseEvent) => {
if (event.metaKey || event.ctrlKey) {
const next = new Set(selectedIds)
if (next.has(id)) next.delete(id)
else next.add(id)
actions.select(next)
lastClickedRef.current = id
return
}
if (event.shiftKey && lastClickedRef.current) {
const lastIdx = files.findIndex((f) => f.id === lastClickedRef.current)
const currIdx = files.findIndex((f) => f.id === id)
if (lastIdx !== -1 && currIdx !== -1) {
const start = Math.min(lastIdx, currIdx)
const end = Math.max(lastIdx, currIdx)
const range = files.slice(start, end + 1).map((f) => f.id)
actions.select(new Set([...selectedIds, ...range]))
return
}
}
actions.select(new Set([id]))
lastClickedRef.current = id
},
[files, selectedIds, actions]
)
const selectAll = useCallback(() => {
actions.select(new Set(files.map((f) => f.id)))
}, [files, actions])
const clearSelection = useCallback(() => {
actions.select(new Set())
lastClickedRef.current = null
}, [actions])
return { handleClick, selectAll, clearSelection }
}

291
src/hooks/use-files.tsx Executable file
View File

@ -0,0 +1,291 @@
"use client"
import {
createContext,
useContext,
useReducer,
useCallback,
type ReactNode,
} from "react"
import { mockFiles, mockStorageUsage, type FileItem } from "@/lib/files-data"
export type FileView =
| "my-files"
| "shared"
| "recent"
| "starred"
| "trash"
export type SortField = "name" | "modified" | "size" | "type"
export type SortDirection = "asc" | "desc"
export type ViewMode = "grid" | "list"
type FilesState = {
viewMode: ViewMode
currentView: FileView
selectedIds: Set<string>
sortBy: SortField
sortDirection: SortDirection
searchQuery: string
files: FileItem[]
}
type FilesAction =
| { type: "SET_VIEW_MODE"; payload: ViewMode }
| { type: "SET_CURRENT_VIEW"; payload: FileView }
| { type: "SET_SELECTED"; payload: Set<string> }
| { type: "TOGGLE_SELECTED"; payload: string }
| { type: "CLEAR_SELECTION" }
| { type: "SET_SORT"; payload: { field: SortField; direction: SortDirection } }
| { type: "SET_SEARCH"; payload: string }
| { type: "STAR_FILE"; payload: string }
| { type: "TRASH_FILE"; payload: string }
| { type: "RESTORE_FILE"; payload: string }
| { type: "RENAME_FILE"; payload: { id: string; name: string } }
| { type: "CREATE_FOLDER"; payload: { name: string; parentId: string | null; path: string[] } }
| { type: "CREATE_FILE"; payload: { name: string; fileType: FileItem["type"]; parentId: string | null; path: string[] } }
| { type: "MOVE_FILE"; payload: { id: string; targetFolderId: string | null; targetPath: string[] } }
function filesReducer(state: FilesState, action: FilesAction): FilesState {
switch (action.type) {
case "SET_VIEW_MODE":
return { ...state, viewMode: action.payload }
case "SET_CURRENT_VIEW":
return { ...state, currentView: action.payload, selectedIds: new Set() }
case "SET_SELECTED":
return { ...state, selectedIds: action.payload }
case "TOGGLE_SELECTED": {
const next = new Set(state.selectedIds)
if (next.has(action.payload)) next.delete(action.payload)
else next.add(action.payload)
return { ...state, selectedIds: next }
}
case "CLEAR_SELECTION":
return { ...state, selectedIds: new Set() }
case "SET_SORT":
return {
...state,
sortBy: action.payload.field,
sortDirection: action.payload.direction,
}
case "SET_SEARCH":
return { ...state, searchQuery: action.payload, selectedIds: new Set() }
case "STAR_FILE":
return {
...state,
files: state.files.map((f) =>
f.id === action.payload ? { ...f, starred: !f.starred } : f
),
}
case "TRASH_FILE":
return {
...state,
files: state.files.map((f) =>
f.id === action.payload ? { ...f, trashed: true } : f
),
selectedIds: new Set(),
}
case "RESTORE_FILE":
return {
...state,
files: state.files.map((f) =>
f.id === action.payload ? { ...f, trashed: false } : f
),
}
case "RENAME_FILE":
return {
...state,
files: state.files.map((f) =>
f.id === action.payload.id
? { ...f, name: action.payload.name, modifiedAt: new Date().toISOString() }
: f
),
}
case "CREATE_FOLDER": {
const newFolder: FileItem = {
id: `folder-${Date.now()}`,
name: action.payload.name,
type: "folder",
size: 0,
path: action.payload.path,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
owner: { name: "Martine Vogel" },
starred: false,
shared: false,
trashed: false,
parentId: action.payload.parentId,
}
return { ...state, files: [...state.files, newFolder] }
}
case "CREATE_FILE": {
const newFile: FileItem = {
id: `file-${Date.now()}`,
name: action.payload.name,
type: action.payload.fileType,
size: 0,
path: action.payload.path,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
owner: { name: "Martine Vogel" },
starred: false,
shared: false,
trashed: false,
parentId: action.payload.parentId,
}
return { ...state, files: [...state.files, newFile] }
}
case "MOVE_FILE":
return {
...state,
files: state.files.map((f) =>
f.id === action.payload.id
? {
...f,
parentId: action.payload.targetFolderId,
path: action.payload.targetPath,
modifiedAt: new Date().toISOString(),
}
: f
),
}
default:
return state
}
}
const initialState: FilesState = {
viewMode: "grid",
currentView: "my-files",
selectedIds: new Set(),
sortBy: "name",
sortDirection: "asc",
searchQuery: "",
files: mockFiles,
}
type FilesContextValue = {
state: FilesState
dispatch: React.Dispatch<FilesAction>
getFilesForPath: (path: string[]) => FileItem[]
getFilesForView: (view: FileView, path: string[]) => FileItem[]
storageUsage: typeof mockStorageUsage
getFolders: () => FileItem[]
}
const FilesContext = createContext<FilesContextValue | null>(null)
export function FilesProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(filesReducer, initialState)
const getFilesForPath = useCallback(
(path: string[]) => {
return state.files.filter((f) => {
if (f.trashed) return false
if (path.length === 0) return f.parentId === null
const parentFolder = state.files.find(
(folder) =>
folder.type === "folder" &&
folder.name === path[path.length - 1] &&
JSON.stringify(folder.path) === JSON.stringify(path.slice(0, -1))
)
return parentFolder && f.parentId === parentFolder.id
})
},
[state.files]
)
const getFilesForView = useCallback(
(view: FileView, path: string[]) => {
let files: FileItem[]
switch (view) {
case "my-files":
files = getFilesForPath(path)
break
case "shared":
files = state.files.filter((f) => !f.trashed && f.shared)
break
case "recent": {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - 30)
files = state.files
.filter((f) => !f.trashed && new Date(f.modifiedAt) > cutoff)
.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
break
}
case "starred":
files = state.files.filter((f) => !f.trashed && f.starred)
break
case "trash":
files = state.files.filter((f) => f.trashed)
break
default:
files = []
}
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase()
files = files.filter((f) => f.name.toLowerCase().includes(q))
}
return sortFiles(files, state.sortBy, state.sortDirection)
},
[state.files, state.searchQuery, state.sortBy, state.sortDirection, getFilesForPath]
)
const getFolders = useCallback(() => {
return state.files.filter((f) => f.type === "folder" && !f.trashed)
}, [state.files])
return (
<FilesContext.Provider
value={{
state,
dispatch,
getFilesForPath,
getFilesForView,
storageUsage: mockStorageUsage,
getFolders,
}}
>
{children}
</FilesContext.Provider>
)
}
export function useFiles() {
const ctx = useContext(FilesContext)
if (!ctx) throw new Error("useFiles must be used within FilesProvider")
return ctx
}
function sortFiles(
files: FileItem[],
sortBy: SortField,
direction: SortDirection
): FileItem[] {
const sorted = [...files].sort((a, b) => {
// folders always first
if (a.type === "folder" && b.type !== "folder") return -1
if (a.type !== "folder" && b.type === "folder") return 1
let cmp = 0
switch (sortBy) {
case "name":
cmp = a.name.localeCompare(b.name)
break
case "modified":
cmp = new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()
break
case "size":
cmp = a.size - b.size
break
case "type":
cmp = a.type.localeCompare(b.type)
break
}
return direction === "asc" ? cmp : -cmp
})
return sorted
}

104
src/lib/file-utils.ts Executable file
View File

@ -0,0 +1,104 @@
import type { FileType } from "./files-data"
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "—"
const units = ["B", "KB", "MB", "GB", "TB"]
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const size = bytes / Math.pow(1024, i)
return `${size >= 10 ? Math.round(size) : size.toFixed(1)} ${units[i]}`
}
export function getFileTypeFromExtension(filename: string): FileType {
const ext = filename.split(".").pop()?.toLowerCase()
if (!ext) return "unknown"
const typeMap: Record<string, FileType> = {
// documents
doc: "document",
docx: "document",
txt: "document",
rtf: "document",
odt: "document",
pptx: "document",
ppt: "document",
// spreadsheets
xls: "spreadsheet",
xlsx: "spreadsheet",
csv: "spreadsheet",
ods: "spreadsheet",
// images
png: "image",
jpg: "image",
jpeg: "image",
gif: "image",
svg: "image",
webp: "image",
fig: "image",
// video
mp4: "video",
mov: "video",
avi: "video",
mkv: "video",
webm: "video",
// audio
mp3: "audio",
wav: "audio",
ogg: "audio",
flac: "audio",
// pdf
pdf: "pdf",
// code
js: "code",
ts: "code",
tsx: "code",
jsx: "code",
py: "code",
go: "code",
rs: "code",
md: "code",
json: "code",
yaml: "code",
yml: "code",
toml: "code",
html: "code",
css: "code",
// archive
zip: "archive",
tar: "archive",
gz: "archive",
"7z": "archive",
rar: "archive",
}
return typeMap[ext] ?? "unknown"
}
export function formatRelativeDate(isoDate: string): string {
const date = new Date(isoDate)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60_000)
const diffHours = Math.floor(diffMs / 3_600_000)
const diffDays = Math.floor(diffMs / 86_400_000)
if (diffMins < 1) return "Just now"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear()
? "numeric"
: undefined,
})
}
export function formatDate(isoDate: string): string {
return new Date(isoDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}

720
src/lib/files-data.ts Executable file
View File

@ -0,0 +1,720 @@
export type FileType =
| "folder"
| "document"
| "spreadsheet"
| "image"
| "video"
| "pdf"
| "code"
| "archive"
| "audio"
| "unknown"
export type SharedRole = "viewer" | "editor"
export type SharedUser = {
name: string
avatar?: string
role: SharedRole
}
export type FileOwner = {
name: string
avatar?: string
}
export type FileItem = {
id: string
name: string
type: FileType
mimeType?: string
size: number
path: string[]
createdAt: string
modifiedAt: string
owner: FileOwner
starred: boolean
shared: boolean
sharedWith?: SharedUser[]
trashed: boolean
parentId: string | null
}
export type StorageUsage = {
used: number
total: number
}
const owner = { name: "Martine Vogel", avatar: "/avatars/martine.jpg" }
const teamMember1 = { name: "Alex Chen", avatar: "/avatars/alex.jpg" }
const teamMember2 = { name: "Jordan Park" }
const teamMember3 = { name: "Sam Rivera", avatar: "/avatars/sam.jpg" }
export const mockFiles: FileItem[] = [
// root folders
{
id: "folder-documents",
name: "Documents",
type: "folder",
size: 0,
path: [],
createdAt: "2025-09-15T10:00:00Z",
modifiedAt: "2026-01-20T14:30:00Z",
owner,
starred: true,
shared: false,
trashed: false,
parentId: null,
},
{
id: "folder-images",
name: "Images",
type: "folder",
size: 0,
path: [],
createdAt: "2025-10-01T09:00:00Z",
modifiedAt: "2026-01-18T11:00:00Z",
owner,
starred: false,
shared: true,
sharedWith: [
{ ...teamMember1, role: "editor" },
{ ...teamMember2, role: "viewer" },
],
trashed: false,
parentId: null,
},
{
id: "folder-projects",
name: "Projects",
type: "folder",
size: 0,
path: [],
createdAt: "2025-08-20T08:00:00Z",
modifiedAt: "2026-01-22T16:45:00Z",
owner,
starred: true,
shared: true,
sharedWith: [
{ ...teamMember1, role: "editor" },
{ ...teamMember3, role: "editor" },
],
trashed: false,
parentId: null,
},
{
id: "folder-archive",
name: "Archive",
type: "folder",
size: 0,
path: [],
createdAt: "2025-06-10T12:00:00Z",
modifiedAt: "2025-12-05T09:30:00Z",
owner,
starred: false,
shared: false,
trashed: false,
parentId: null,
},
{
id: "folder-shared-assets",
name: "Shared Assets",
type: "folder",
size: 0,
path: [],
createdAt: "2025-11-01T10:00:00Z",
modifiedAt: "2026-01-15T13:20:00Z",
owner: teamMember1,
starred: false,
shared: true,
sharedWith: [
{ ...owner, role: "editor" },
{ ...teamMember2, role: "viewer" },
],
trashed: false,
parentId: null,
},
// Documents folder contents
{
id: "doc-proposal",
name: "Project Proposal.docx",
type: "document",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size: 245_000,
path: ["Documents"],
createdAt: "2025-11-10T14:00:00Z",
modifiedAt: "2026-01-19T10:15:00Z",
owner,
starred: true,
shared: true,
sharedWith: [{ ...teamMember1, role: "editor" }],
trashed: false,
parentId: "folder-documents",
},
{
id: "doc-meeting-notes",
name: "Meeting Notes - Jan 2026.docx",
type: "document",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size: 128_500,
path: ["Documents"],
createdAt: "2026-01-05T09:00:00Z",
modifiedAt: "2026-01-22T11:30:00Z",
owner,
starred: false,
shared: false,
trashed: false,
parentId: "folder-documents",
},
{
id: "doc-budget",
name: "Budget 2026.xlsx",
type: "spreadsheet",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
size: 890_000,
path: ["Documents"],
createdAt: "2025-12-01T08:00:00Z",
modifiedAt: "2026-01-21T15:00:00Z",
owner,
starred: true,
shared: true,
sharedWith: [
{ ...teamMember1, role: "editor" },
{ ...teamMember3, role: "viewer" },
],
trashed: false,
parentId: "folder-documents",
},
{
id: "doc-contract",
name: "Vendor Contract.pdf",
type: "pdf",
mimeType: "application/pdf",
size: 1_540_000,
path: ["Documents"],
createdAt: "2025-10-20T11:00:00Z",
modifiedAt: "2025-10-20T11:00:00Z",
owner,
starred: false,
shared: false,
trashed: false,
parentId: "folder-documents",
},
{
id: "doc-timeline",
name: "Project Timeline.xlsx",
type: "spreadsheet",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
size: 456_000,
path: ["Documents"],
createdAt: "2025-11-28T13:00:00Z",
modifiedAt: "2026-01-18T09:45:00Z",
owner,
starred: false,
shared: true,
sharedWith: [{ ...teamMember2, role: "viewer" }],
trashed: false,
parentId: "folder-documents",
},
{
id: "folder-documents-reports",
name: "Reports",
type: "folder",
size: 0,
path: ["Documents"],
createdAt: "2025-10-05T10:00:00Z",
modifiedAt: "2026-01-15T14:00:00Z",
owner,
starred: false,
shared: false,
trashed: false,
parentId: "folder-documents",
},
// Documents/Reports subfolder
{
id: "doc-q4-report",
name: "Q4 2025 Report.pdf",
type: "pdf",
mimeType: "application/pdf",
size: 2_100_000,
path: ["Documents", "Reports"],
createdAt: "2026-01-02T10:00:00Z",
modifiedAt: "2026-01-10T16:00:00Z",
owner,
starred: false,
shared: true,
sharedWith: [
{ ...teamMember1, role: "viewer" },
{ ...teamMember3, role: "viewer" },
],
trashed: false,
parentId: "folder-documents-reports",
},
{
id: "doc-analytics",
name: "Site Analytics.xlsx",
type: "spreadsheet",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
size: 670_000,
path: ["Documents", "Reports"],
createdAt: "2025-12-20T14:00:00Z",
modifiedAt: "2026-01-15T14:00:00Z",
owner: teamMember1,
starred: false,
shared: true,
sharedWith: [{ ...owner, role: "editor" }],
trashed: false,
parentId: "folder-documents-reports",
},
// Images folder contents
{
id: "img-hero",
name: "hero-banner.png",
type: "image",
mimeType: "image/png",
size: 3_200_000,
path: ["Images"],
createdAt: "2025-12-10T10:00:00Z",
modifiedAt: "2025-12-10T10:00:00Z",
owner,
starred: false,
shared: true,
sharedWith: [{ ...teamMember2, role: "viewer" }],
trashed: false,
parentId: "folder-images",
},
{
id: "img-logo",
name: "compass-logo.svg",
type: "image",
mimeType: "image/svg+xml",
size: 12_400,
path: ["Images"],
createdAt: "2025-09-01T08:00:00Z",
modifiedAt: "2025-11-15T16:30:00Z",
owner,
starred: true,
shared: true,
sharedWith: [
{ ...teamMember1, role: "editor" },
{ ...teamMember2, role: "viewer" },
],
trashed: false,
parentId: "folder-images",
},
{
id: "img-mockup",
name: "dashboard-mockup.fig",
type: "image",
mimeType: "application/x-figma",
size: 8_900_000,
path: ["Images"],
createdAt: "2025-11-20T14:00:00Z",
modifiedAt: "2026-01-10T11:00:00Z",
owner: teamMember1,
starred: false,
shared: true,
sharedWith: [{ ...owner, role: "editor" }],
trashed: false,
parentId: "folder-images",
},
{
id: "img-photo1",
name: "team-photo.jpg",
type: "image",
mimeType: "image/jpeg",
size: 4_500_000,
path: ["Images"],
createdAt: "2025-12-20T15:00:00Z",
modifiedAt: "2025-12-20T15:00:00Z",
owner: teamMember3,
starred: false,
shared: true,
sharedWith: [
{ ...owner, role: "viewer" },
{ ...teamMember1, role: "viewer" },
],
trashed: false,
parentId: "folder-images",
},
{
id: "img-screenshot",
name: "app-screenshot.png",
type: "image",
mimeType: "image/png",
size: 1_800_000,
path: ["Images"],
createdAt: "2026-01-08T09:00:00Z",
modifiedAt: "2026-01-08T09:00:00Z",
owner,
starred: false,
shared: false,
trashed: false,
parentId: "folder-images",
},
// Projects folder contents
{
id: "folder-projects-compass",
name: "Compass",
type: "folder",
size: 0,
path: ["Projects"],
createdAt: "2025-08-20T08:00:00Z",
modifiedAt: "2026-01-22T16:45:00Z",
owner,
starred: true,
shared: true,
sharedWith: [
{ ...teamMember1, role: "editor" },
{ ...teamMember3, role: "editor" },
],
trashed: false,
parentId: "folder-projects",
},
{
id: "folder-projects-website",
name: "Website Redesign",
type: "folder",
size: 0,
path: ["Projects"],
createdAt: "2025-10-15T09:00:00Z",
modifiedAt: "2026-01-20T10:00:00Z",
owner,
starred: false,
shared: true,
sharedWith: [{ ...teamMember1, role: "editor" }],
trashed: false,
parentId: "folder-projects",
},
{
id: "proj-spec",
name: "Technical Spec.md",
type: "code",
mimeType: "text/markdown",
size: 34_000,
path: ["Projects"],
createdAt: "2025-09-10T10:00:00Z",
modifiedAt: "2026-01-15T08:00:00Z",
owner,
starred: false,
shared: true,
sharedWith: [{ ...teamMember1, role: "editor" }],
trashed: false,
parentId: "folder-projects",
},
// Projects/Compass subfolder
{
id: "compass-readme",
name: "README.md",
type: "code",
mimeType: "text/markdown",
size: 8_200,
path: ["Projects", "Compass"],
createdAt: "2025-08-20T08:30:00Z",
modifiedAt: "2026-01-22T16:45:00Z",
owner,
starred: false,
shared: true,
sharedWith: [{ ...teamMember1, role: "editor" }],
trashed: false,
parentId: "folder-projects-compass",
},
{
id: "compass-design",
name: "Design System.fig",
type: "image",
mimeType: "application/x-figma",
size: 15_600_000,
path: ["Projects", "Compass"],
createdAt: "2025-09-05T14:00:00Z",
modifiedAt: "2026-01-18T11:30:00Z",
owner: teamMember1,
starred: true,
shared: true,
sharedWith: [
{ ...owner, role: "editor" },
{ ...teamMember3, role: "viewer" },
],
trashed: false,
parentId: "folder-projects-compass",
},
{
id: "compass-api-doc",
name: "API Documentation.pdf",
type: "pdf",
mimeType: "application/pdf",
size: 980_000,
path: ["Projects", "Compass"],
createdAt: "2025-11-01T10:00:00Z",
modifiedAt: "2026-01-12T14:00:00Z",
owner,
starred: false,
shared: true,
sharedWith: [{ ...teamMember3, role: "viewer" }],
trashed: false,
parentId: "folder-projects-compass",
},
// Projects/Website Redesign subfolder
{
id: "web-wireframes",
name: "Wireframes.fig",
type: "image",
mimeType: "application/x-figma",
size: 12_300_000,
path: ["Projects", "Website Redesign"],
createdAt: "2025-10-20T11:00:00Z",
modifiedAt: "2026-01-20T10:00:00Z",
owner: teamMember1,
starred: false,
shared: true,
sharedWith: [{ ...owner, role: "editor" }],
trashed: false,
parentId: "folder-projects-website",
},
{
id: "web-copy",
name: "Website Copy.docx",
type: "document",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size: 67_000,
path: ["Projects", "Website Redesign"],
createdAt: "2025-11-15T09:00:00Z",
modifiedAt: "2026-01-16T13:00:00Z",
owner,
starred: false,
shared: true,
sharedWith: [{ ...teamMember1, role: "editor" }],
trashed: false,
parentId: "folder-projects-website",
},
// Shared Assets folder (owned by team member)
{
id: "shared-brand-guide",
name: "Brand Guidelines.pdf",
type: "pdf",
mimeType: "application/pdf",
size: 5_400_000,
path: ["Shared Assets"],
createdAt: "2025-11-01T10:00:00Z",
modifiedAt: "2025-11-01T10:00:00Z",
owner: teamMember1,
starred: false,
shared: true,
sharedWith: [
{ ...owner, role: "editor" },
{ ...teamMember2, role: "viewer" },
],
trashed: false,
parentId: "folder-shared-assets",
},
{
id: "shared-icons",
name: "Icon Set.zip",
type: "archive",
mimeType: "application/zip",
size: 2_300_000,
path: ["Shared Assets"],
createdAt: "2025-11-05T14:00:00Z",
modifiedAt: "2025-11-05T14:00:00Z",
owner: teamMember1,
starred: false,
shared: true,
sharedWith: [{ ...owner, role: "viewer" }],
trashed: false,
parentId: "folder-shared-assets",
},
{
id: "shared-fonts",
name: "Custom Fonts.zip",
type: "archive",
mimeType: "application/zip",
size: 1_100_000,
path: ["Shared Assets"],
createdAt: "2025-11-10T09:00:00Z",
modifiedAt: "2025-11-10T09:00:00Z",
owner: teamMember1,
starred: false,
shared: true,
sharedWith: [{ ...owner, role: "editor" }],
trashed: false,
parentId: "folder-shared-assets",
},
// Root-level files
{
id: "root-video",
name: "Product Demo.mp4",
type: "video",
mimeType: "video/mp4",
size: 45_000_000,
path: [],
createdAt: "2025-12-15T16:00:00Z",
modifiedAt: "2025-12-15T16:00:00Z",
owner,
starred: false,
shared: true,
sharedWith: [
{ ...teamMember1, role: "viewer" },
{ ...teamMember2, role: "viewer" },
{ ...teamMember3, role: "viewer" },
],
trashed: false,
parentId: null,
},
{
id: "root-audio",
name: "Podcast Recording.mp3",
type: "audio",
mimeType: "audio/mpeg",
size: 18_500_000,
path: [],
createdAt: "2026-01-05T11:00:00Z",
modifiedAt: "2026-01-05T11:00:00Z",
owner: teamMember3,
starred: false,
shared: true,
sharedWith: [{ ...owner, role: "viewer" }],
trashed: false,
parentId: null,
},
{
id: "root-presentation",
name: "Q1 Kickoff.pptx",
type: "document",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
size: 6_700_000,
path: [],
createdAt: "2026-01-02T08:00:00Z",
modifiedAt: "2026-01-20T17:00:00Z",
owner,
starred: true,
shared: true,
sharedWith: [
{ ...teamMember1, role: "editor" },
{ ...teamMember3, role: "editor" },
],
trashed: false,
parentId: null,
},
{
id: "root-config",
name: "deploy-config.yaml",
type: "code",
mimeType: "text/yaml",
size: 2_400,
path: [],
createdAt: "2025-11-20T10:00:00Z",
modifiedAt: "2026-01-10T09:00:00Z",
owner,
starred: false,
shared: false,
trashed: false,
parentId: null,
},
// Trashed files
{
id: "trash-old-draft",
name: "Old Draft.docx",
type: "document",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size: 156_000,
path: ["Documents"],
createdAt: "2025-08-10T10:00:00Z",
modifiedAt: "2025-12-28T14:00:00Z",
owner,
starred: false,
shared: false,
trashed: true,
parentId: "folder-documents",
},
{
id: "trash-screenshot",
name: "old-screenshot.png",
type: "image",
mimeType: "image/png",
size: 2_400_000,
path: ["Images"],
createdAt: "2025-07-15T09:00:00Z",
modifiedAt: "2025-12-20T11:00:00Z",
owner,
starred: false,
shared: false,
trashed: true,
parentId: "folder-images",
},
{
id: "trash-backup",
name: "backup-nov.zip",
type: "archive",
mimeType: "application/zip",
size: 34_000_000,
path: [],
createdAt: "2025-11-30T22:00:00Z",
modifiedAt: "2026-01-05T08:00:00Z",
owner,
starred: false,
shared: false,
trashed: true,
parentId: null,
},
// Archive folder contents
{
id: "archive-old-project",
name: "2024 Campaign.zip",
type: "archive",
mimeType: "application/zip",
size: 89_000_000,
path: ["Archive"],
createdAt: "2025-01-15T10:00:00Z",
modifiedAt: "2025-06-10T12:00:00Z",
owner,
starred: false,
shared: false,
trashed: false,
parentId: "folder-archive",
},
{
id: "archive-legacy-docs",
name: "Legacy Documentation.pdf",
type: "pdf",
mimeType: "application/pdf",
size: 3_200_000,
path: ["Archive"],
createdAt: "2025-03-20T14:00:00Z",
modifiedAt: "2025-03-20T14:00:00Z",
owner,
starred: false,
shared: false,
trashed: false,
parentId: "folder-archive",
},
{
id: "archive-old-video",
name: "Training Video 2024.mp4",
type: "video",
mimeType: "video/mp4",
size: 120_000_000,
path: ["Archive"],
createdAt: "2024-11-10T16:00:00Z",
modifiedAt: "2024-11-10T16:00:00Z",
owner,
starred: false,
shared: false,
trashed: false,
parentId: "folder-archive",
},
]
export const mockStorageUsage: StorageUsage = {
used: 412_000_000,
total: 5_000_000_000,
}