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:
parent
aa6230c9d4
commit
598047635d
17
src/app/dashboard/files/[...path]/page.tsx
Executable file
17
src/app/dashboard/files/[...path]/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/app/dashboard/files/layout.tsx
Executable file
17
src/app/dashboard/files/layout.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/app/dashboard/files/page.tsx
Executable file
12
src/app/dashboard/files/page.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -4,15 +4,18 @@ import * as React from "react"
|
|||||||
import {
|
import {
|
||||||
IconCalendarStats,
|
IconCalendarStats,
|
||||||
IconDashboard,
|
IconDashboard,
|
||||||
|
IconFiles,
|
||||||
IconFolder,
|
IconFolder,
|
||||||
IconHelp,
|
IconHelp,
|
||||||
IconInnerShadowTop,
|
IconInnerShadowTop,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
import { NavMain } from "@/components/nav-main"
|
import { NavMain } from "@/components/nav-main"
|
||||||
import { NavSecondary } from "@/components/nav-secondary"
|
import { NavSecondary } from "@/components/nav-secondary"
|
||||||
|
import { NavFiles } from "@/components/nav-files"
|
||||||
import { NavUser } from "@/components/nav-user"
|
import { NavUser } from "@/components/nav-user"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@ -46,6 +49,11 @@ const data = {
|
|||||||
url: "/dashboard/projects/demo-project-1/schedule",
|
url: "/dashboard/projects/demo-project-1/schedule",
|
||||||
icon: IconCalendarStats,
|
icon: IconCalendarStats,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Files",
|
||||||
|
url: "/dashboard/files",
|
||||||
|
icon: IconFiles,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
navSecondary: [
|
navSecondary: [
|
||||||
{
|
{
|
||||||
@ -67,6 +75,9 @@ const data = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const isFilesMode = pathname?.startsWith("/dashboard/files")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="offcanvas" {...props}>
|
<Sidebar collapsible="offcanvas" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
@ -85,8 +96,29 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<div className="relative flex-1 overflow-hidden">
|
||||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
<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>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser user={data.user} />
|
<NavUser user={data.user} />
|
||||||
|
|||||||
51
src/components/files/file-breadcrumb.tsx
Executable file
51
src/components/files/file-breadcrumb.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
src/components/files/file-browser.tsx
Executable file
145
src/components/files/file-browser.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
src/components/files/file-context-menu.tsx
Executable file
109
src/components/files/file-context-menu.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/components/files/file-drop-zone.tsx
Executable file
81
src/components/files/file-drop-zone.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/components/files/file-grid.tsx
Executable file
74
src/components/files/file-grid.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/files/file-icon.tsx
Executable file
40
src/components/files/file-icon.tsx
Executable 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)} />
|
||||||
|
}
|
||||||
143
src/components/files/file-item.tsx
Executable file
143
src/components/files/file-item.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
62
src/components/files/file-list.tsx
Executable file
62
src/components/files/file-list.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
src/components/files/file-move-dialog.tsx
Executable file
100
src/components/files/file-move-dialog.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/components/files/file-new-folder-dialog.tsx
Executable file
74
src/components/files/file-new-folder-dialog.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/components/files/file-rename-dialog.tsx
Executable file
76
src/components/files/file-rename-dialog.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
src/components/files/file-row.tsx
Executable file
79
src/components/files/file-row.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
157
src/components/files/file-toolbar.tsx
Executable file
157
src/components/files/file-toolbar.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/components/files/file-upload-dialog.tsx
Executable file
76
src/components/files/file-upload-dialog.tsx
Executable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/components/files/storage-indicator.tsx
Executable file
22
src/components/files/storage-indicator.tsx
Executable 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
94
src/components/nav-files.tsx
Executable 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
56
src/hooks/use-file-selection.ts
Executable 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
291
src/hooks/use-files.tsx
Executable 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
104
src/lib/file-utils.ts
Executable 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
720
src/lib/files-data.ts
Executable 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,
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user