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