feat: add trash page

This commit is contained in:
2025-10-03 23:23:05 +00:00
parent 0e686a1f85
commit c2d9010508
13 changed files with 410 additions and 268 deletions

View File

@@ -13,7 +13,6 @@ import type {
FilterApi,
FunctionReference,
} from "convex/server";
import type * as admin from "../admin.js";
import type * as files from "../files.js";
import type * as filesystem from "../filesystem.js";
import type * as functions from "../functions.js";
@@ -33,7 +32,6 @@ import type * as users from "../users.js";
* ```
*/
declare const fullApi: ApiFromModules<{
admin: typeof admin;
files: typeof files;
filesystem: typeof filesystem;
functions: typeof functions;

View File

@@ -50,15 +50,6 @@ export const fetchDirectory = authenticatedQuery({
},
})
export const fetchDirectoryContent = authenticatedQuery({
args: {
directoryId: v.optional(v.id("directories")),
},
handler: async (ctx, { directoryId }): Promise<FileSystemItem[]> => {
return await Directories.fetchContent(ctx, { directoryId })
},
})
export const createDirectory = authenticatedMutation({
args: {
name: v.string(),

View File

@@ -1,16 +1,19 @@
import { v } from "convex/values"
import { authenticatedMutation } from "./functions"
import { authenticatedMutation, authenticatedQuery } from "./functions"
import * as Directories from "./model/directories"
import * as Err from "./model/error"
import * as Files from "./model/files"
import type { DirectoryHandle, FileHandle } from "./model/filesystem"
import type {
DirectoryHandle,
FileHandle,
FileSystemItem,
} from "./model/filesystem"
import {
type FileSystemHandle,
FileType,
VDirectoryHandle,
VFileSystemHandle,
} from "./model/filesystem"
export const moveItems = authenticatedMutation({
args: {
targetDirectory: VDirectoryHandle,
@@ -100,3 +103,16 @@ export const moveToTrash = authenticatedMutation({
}
},
})
export const fetchDirectoryContent = authenticatedQuery({
args: {
directoryId: v.optional(v.id("directories")),
trashed: v.boolean(),
},
handler: async (
ctx,
{ directoryId, trashed },
): Promise<FileSystemItem[]> => {
return await Directories.fetchContent(ctx, { directoryId, trashed })
},
})

View File

@@ -75,7 +75,10 @@ export async function fetch(
export async function fetchContent(
ctx: AuthenticatedQueryCtx,
{ directoryId }: { directoryId?: Id<"directories"> } = {},
{
directoryId,
trashed,
}: { directoryId?: Id<"directories">; trashed: boolean },
): Promise<FileSystemItem[]> {
let dirId: Id<"directories"> | undefined
if (directoryId) {
@@ -85,21 +88,33 @@ export async function fetchContent(
const [files, directories] = await Promise.all([
ctx.db
.query("files")
.withIndex("byDirectoryId", (q) =>
q
.withIndex("byDirectoryId", (q) => {
if (trashed) {
return q
.eq("userId", ctx.user._id)
.eq("directoryId", dirId)
.gte("deletedAt", 0)
}
return q
.eq("userId", ctx.user._id)
.eq("directoryId", dirId)
.eq("deletedAt", undefined),
)
.eq("deletedAt", undefined)
})
.collect(),
ctx.db
.query("directories")
.withIndex("byParentId", (q) =>
q
.withIndex("byParentId", (q) => {
if (trashed) {
return q
.eq("userId", ctx.user._id)
.eq("parentId", dirId)
.gte("deletedAt", 0)
}
return q
.eq("userId", ctx.user._id)
.eq("parentId", dirId)
.eq("deletedAt", undefined),
)
.eq("deletedAt", undefined)
})
.collect(),
])
@@ -216,7 +231,10 @@ export async function move(
ignoredHandles.add(handle)
} else {
promises.push(
ctx.db.patch(handle.id, { parentId: targetDirectory.id, updatedAt: Date.now() }),
ctx.db.patch(handle.id, {
parentId: targetDirectory.id,
updatedAt: Date.now(),
}),
)
}
}

View File

@@ -8,6 +8,7 @@ import {
HomeIcon,
LogOutIcon,
SettingsIcon,
TrashIcon,
User2Icon,
} from "lucide-react"
import {
@@ -62,6 +63,7 @@ function MainSidebarMenu() {
</SidebarMenuButton>
</SidebarMenuItem>
<AllFilesItem />
<TrashItem />
</SidebarMenu>
)
}
@@ -89,6 +91,29 @@ function AllFilesItem() {
)
}
function TrashItem() {
const location = useLocation()
const rootDirectory = useConvexQuery(api.files.fetchRootDirectory)
if (!rootDirectory) return null
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname.startsWith(
`/trash/directories/${rootDirectory._id}`,
)}
>
<Link to={`/trash/directories/${rootDirectory._id}`}>
<TrashIcon />
<span>Trash</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
function UserMenu() {
const { signOut } = useAuth()

View File

@@ -131,8 +131,6 @@ export function DirectoryContentTable() {
)
}
export function DirectoryContentTableContent() {
const { directoryContent } = useContext(DirectoryPageContext)
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)

View File

@@ -1,252 +1,19 @@
import { api } from "@fileone/convex/_generated/api"
import {
type DirectoryHandle,
FileType,
type PathComponent,
} from "@fileone/convex/model/filesystem"
import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { useMutation as useConvexMutation } from "convex/react"
import { useAtom } from "jotai"
import {
ChevronDownIcon,
Loader2Icon,
PlusIcon,
UploadCloudIcon,
} from "lucide-react"
import React, { type ChangeEvent, Fragment, useContext, useRef } from "react"
import { toast } from "sonner"
import { ImagePreviewDialog } from "@/components/image-preview-dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DirectoryIcon } from "../../components/icons/directory-icon"
import { TextFileIcon } from "../../components/icons/text-file-icon"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "../../components/ui/breadcrumb"
import { Button } from "../../components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../../components/ui/tooltip"
import { WithAtom } from "../../components/with-atom"
import { useFileDrop } from "../../files/use-file-drop"
import { cn } from "../../lib/utils"
import { DirectoryPageContext } from "./context"
import { DirectoryContentTable } from "./directory-content-table"
import { NewDirectoryDialog } from "./new-directory-dialog"
import { RenameFileDialog } from "./rename-file-dialog"
import { dragInfoAtom, newFileTypeAtom, openedFileAtom } from "./state"
import { openedFileAtom } from "./state"
export function DirectoryPage() {
const { directory } = useContext(DirectoryPageContext)
return (
<>
<header className="flex py-1 shrink-0 items-center gap-2 border-b px-4 w-full">
<FilePathBreadcrumb />
<div className="ml-auto flex flex-row gap-2">
<NewDirectoryItemDropdown />
<UploadFileButton />
</div>
</header>
<div className="w-full">
<DirectoryContentTable />
</div>
<RenameFileDialog />
<PreviewDialog />
<WithAtom atom={newFileTypeAtom}>
{(newFileType, setNewFileType) => (
<NewDirectoryDialog
open={newFileType === FileType.Directory}
directoryId={directory._id}
onOpenChange={(open) => {
if (!open) {
setNewFileType(null)
}
}}
/>
)}
</WithAtom>
</>
)
}
function FilePathBreadcrumb() {
const { rootDirectory, directory } = useContext(DirectoryPageContext)
const breadcrumbItems: React.ReactNode[] = []
for (let i = 1; i < directory.path.length - 1; i++) {
breadcrumbItems.push(
<Fragment key={directory.path[i]?.handle.id}>
<BreadcrumbSeparator />
<FilePathBreadcrumbItem component={directory.path[i]!} />
</Fragment>,
)
}
return (
<Breadcrumb>
<BreadcrumbList>
{rootDirectory._id === directory._id ? (
<BreadcrumbItem>
<BreadcrumbPage>All Files</BreadcrumbPage>
</BreadcrumbItem>
) : (
<FilePathBreadcrumbItem component={directory.path[0]!} />
)}
{breadcrumbItems}
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{directory.name}</BreadcrumbPage>{" "}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}
function FilePathBreadcrumbItem({ component }: { component: PathComponent }) {
const { isDraggedOver, dropHandlers } = useFileDrop({
destItem: component.handle as DirectoryHandle,
dragInfoAtom,
})
const dirName = component.name || "All Files"
return (
<Tooltip open={isDraggedOver}>
<TooltipTrigger asChild>
<BreadcrumbItem
className={cn({ "bg-muted": isDraggedOver })}
{...dropHandlers}
>
<BreadcrumbLink asChild>
<Link to={`/directories/${component.handle.id}`}>
{dirName}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
</TooltipTrigger>
<TooltipContent>Move to {dirName}</TooltipContent>
</Tooltip>
)
}
// tags: upload, uploadfile, uploadfilebutton, fileupload, fileuploadbutton
function UploadFileButton() {
const { directory } = useContext(DirectoryPageContext)
const generateUploadUrl = useConvexMutation(api.files.generateUploadUrl)
const saveFile = useConvexMutation(api.files.saveFile)
const { mutate: uploadFile, isPending: isUploading } = useMutation({
mutationFn: async (file: File) => {
const uploadUrl = await generateUploadUrl()
const uploadResult = await fetch(uploadUrl, {
method: "POST",
body: file,
headers: {
"Content-Type": file.type,
},
})
const { storageId } = await uploadResult.json()
await saveFile({
storageId,
name: file.name,
size: file.size,
mimeType: file.type,
directoryId: directory._id,
})
},
onSuccess: () => {
toast.success("File uploaded successfully.")
},
})
const fileInputRef = useRef<HTMLInputElement>(null)
const handleClick = () => {
fileInputRef.current?.click()
}
const onFileUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
uploadFile(file)
}
}
return (
<>
<input
hidden
onChange={onFileUpload}
ref={fileInputRef}
type="file"
name="files"
/>
<Button
size="sm"
type="button"
onClick={handleClick}
disabled={isUploading}
>
{isUploading ? (
<Loader2Icon className="animate-spin size-4" />
) : (
<UploadCloudIcon className="size-4" />
)}
{isUploading ? "Uploading" : "Upload File"}
</Button>
</>
)
}
function NewDirectoryItemDropdown() {
const [newFileType, setNewFileType] = useAtom(newFileTypeAtom)
const addNewDirectory = () => {
setNewFileType(FileType.Directory)
}
const handleCloseAutoFocus = (event: Event) => {
// If we just created a new item, prevent the dropdown from restoring focus to the trigger
if (newFileType) {
event.preventDefault()
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
<PlusIcon className="size-4" />
New
<ChevronDownIcon className="pl-1 size-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent onCloseAutoFocus={handleCloseAutoFocus}>
<DropdownMenuItem>
<TextFileIcon />
Text file
</DropdownMenuItem>
<DropdownMenuItem onClick={addNewDirectory}>
<DirectoryIcon />
Directory
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
function PreviewDialog() {
const [openedFile, setOpenedFile] = useAtom(openedFileAtom)

View File

@@ -0,0 +1,83 @@
import type {
DirectoryHandle,
PathComponent,
} from "@fileone/convex/model/filesystem"
import { Link } from "@tanstack/react-router"
import { Fragment, useContext } from "react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "../../components/ui/breadcrumb"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../../components/ui/tooltip"
import { useFileDrop } from "../../files/use-file-drop"
import { cn } from "../../lib/utils"
import { DirectoryPageContext } from "./context"
import { dragInfoAtom } from "./state"
export function FilePathBreadcrumb() {
const { rootDirectory, directory } = useContext(DirectoryPageContext)
const breadcrumbItems: React.ReactNode[] = []
for (let i = 1; i < directory.path.length - 1; i++) {
breadcrumbItems.push(
<Fragment key={directory.path[i]?.handle.id}>
<BreadcrumbSeparator />
<FilePathBreadcrumbItem component={directory.path[i]!} />
</Fragment>,
)
}
return (
<Breadcrumb>
<BreadcrumbList>
{rootDirectory._id === directory._id ? (
<BreadcrumbItem>
<BreadcrumbPage>All Files</BreadcrumbPage>
</BreadcrumbItem>
) : (
<FilePathBreadcrumbItem component={directory.path[0]!} />
)}
{breadcrumbItems}
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{directory.name}</BreadcrumbPage>{" "}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}
function FilePathBreadcrumbItem({ component }: { component: PathComponent }) {
const { isDraggedOver, dropHandlers } = useFileDrop({
destItem: component.handle as DirectoryHandle,
dragInfoAtom,
})
const dirName = component.name || "All Files"
return (
<Tooltip open={isDraggedOver}>
<TooltipTrigger asChild>
<BreadcrumbItem
className={cn({ "bg-muted": isDraggedOver })}
{...dropHandlers}
>
<BreadcrumbLink asChild>
<Link to={`/directories/${component.handle.id}`}>
{dirName}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
</TooltipTrigger>
<TooltipContent>Move to {dirName}</TooltipContent>
</Tooltip>
)
}

View File

@@ -11,8 +11,6 @@ export const optimisticDeletedItemsAtom = atom(
export const selectedFileRowsAtom = atom<RowSelectionState>({})
export const newFileTypeAtom = atom<FileType | null>(null)
export const itemBeingRenamedAtom = atom<{
originalItem: FileSystemItem
name: string

View File

@@ -16,6 +16,7 @@ import { Route as LoginCallbackRouteImport } from './routes/login_.callback'
import { Route as AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout'
import { Route as AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_authenticated/_sidebar-layout/home'
import { Route as AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/directories.$directoryId'
import { Route as AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/trash.directories.$directoryId'
const LoginRoute = LoginRouteImport.update({
id: '/login',
@@ -53,6 +54,12 @@ const AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute =
path: '/directories/$directoryId',
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
} as any)
const AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute =
AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRouteImport.update({
id: '/trash/directories/$directoryId',
path: '/trash/directories/$directoryId',
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
} as any)
export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
@@ -60,6 +67,7 @@ export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
@@ -67,6 +75,7 @@ export interface FileRoutesByTo {
'/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -77,6 +86,7 @@ export interface FileRoutesById {
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/_authenticated/_sidebar-layout/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -86,8 +96,15 @@ export interface FileRouteTypes {
| '/'
| '/home'
| '/directories/$directoryId'
| '/trash/directories/$directoryId'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/login/callback' | '/' | '/home' | '/directories/$directoryId'
to:
| '/login'
| '/login/callback'
| '/'
| '/home'
| '/directories/$directoryId'
| '/trash/directories/$directoryId'
id:
| '__root__'
| '/_authenticated'
@@ -97,6 +114,7 @@ export interface FileRouteTypes {
| '/_authenticated/'
| '/_authenticated/_sidebar-layout/home'
| '/_authenticated/_sidebar-layout/directories/$directoryId'
| '/_authenticated/_sidebar-layout/trash/directories/$directoryId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -156,12 +174,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport
parentRoute: typeof AuthenticatedSidebarLayoutRoute
}
'/_authenticated/_sidebar-layout/trash/directories/$directoryId': {
id: '/_authenticated/_sidebar-layout/trash/directories/$directoryId'
path: '/trash/directories/$directoryId'
fullPath: '/trash/directories/$directoryId'
preLoaderRoute: typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRouteImport
parentRoute: typeof AuthenticatedSidebarLayoutRoute
}
}
}
interface AuthenticatedSidebarLayoutRouteChildren {
AuthenticatedSidebarLayoutHomeRoute: typeof AuthenticatedSidebarLayoutHomeRoute
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
}
const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteChildren =
@@ -169,6 +195,8 @@ const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteCh
AuthenticatedSidebarLayoutHomeRoute: AuthenticatedSidebarLayoutHomeRoute,
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute:
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute,
AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute:
AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute,
}
const AuthenticatedSidebarLayoutRouteWithChildren =

View File

@@ -1,9 +1,36 @@
import { api } from "@fileone/convex/_generated/api"
import { FileType } from "@fileone/convex/model/filesystem"
import { useMutation } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { useQuery as useConvexQuery } from "convex/react"
import {
useMutation as useConvexMutation,
useQuery as useConvexQuery,
} from "convex/react"
import { atom, useAtom } from "jotai"
import {
ChevronDownIcon,
Loader2Icon,
PlusIcon,
UploadCloudIcon,
} from "lucide-react"
import { type ChangeEvent, useContext, useRef } from "react"
import { toast } from "sonner"
import { DirectoryIcon } from "@/components/icons/directory-icon"
import { TextFileIcon } from "@/components/icons/text-file-icon"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { WithAtom } from "@/components/with-atom"
import { DirectoryPageContext } from "@/directories/directory-page/context"
import { DirectoryPage } from "@/directories/directory-page/directory-page"
import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton"
import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb"
import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog"
import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog"
export const Route = createFileRoute(
"/_authenticated/_sidebar-layout/directories/$directoryId",
@@ -11,15 +38,21 @@ export const Route = createFileRoute(
component: RouteComponent,
})
const newFileTypeAtom = atom<FileType | null>(null)
function RouteComponent() {
const { directoryId } = Route.useParams()
const rootDirectory = useConvexQuery(api.files.fetchRootDirectory)
const directory = useConvexQuery(api.files.fetchDirectory, {
directoryId,
})
const directoryContent = useConvexQuery(api.files.fetchDirectoryContent, {
directoryId,
})
const directoryContent = useConvexQuery(
api.filesystem.fetchDirectoryContent,
{
directoryId,
trashed: false,
},
)
if (!directory || !directoryContent || !rootDirectory) {
return <DirectoryPageSkeleton />
@@ -29,7 +62,137 @@ function RouteComponent() {
<DirectoryPageContext
value={{ rootDirectory, directory, directoryContent }}
>
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
<FilePathBreadcrumb />
<div className="ml-auto flex flex-row gap-2">
<NewDirectoryItemDropdown />
<UploadFileButton />
</div>
</header>
<DirectoryPage />
<WithAtom atom={newFileTypeAtom}>
{(newFileType, setNewFileType) => (
<NewDirectoryDialog
open={newFileType === FileType.Directory}
directoryId={directory._id}
onOpenChange={(open) => {
if (!open) {
setNewFileType(null)
}
}}
/>
)}
</WithAtom>
<RenameFileDialog />
</DirectoryPageContext>
)
}
// tags: upload, uploadfile, uploadfilebutton, fileupload, fileuploadbutton
function UploadFileButton() {
const { directory } = useContext(DirectoryPageContext)
const generateUploadUrl = useConvexMutation(api.files.generateUploadUrl)
const saveFile = useConvexMutation(api.files.saveFile)
const { mutate: uploadFile, isPending: isUploading } = useMutation({
mutationFn: async (file: File) => {
const uploadUrl = await generateUploadUrl()
const uploadResult = await fetch(uploadUrl, {
method: "POST",
body: file,
headers: {
"Content-Type": file.type,
},
})
const { storageId } = await uploadResult.json()
await saveFile({
storageId,
name: file.name,
size: file.size,
mimeType: file.type,
directoryId: directory._id,
})
},
onSuccess: () => {
toast.success("File uploaded successfully.")
},
})
const fileInputRef = useRef<HTMLInputElement>(null)
const handleClick = () => {
fileInputRef.current?.click()
}
const onFileUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
uploadFile(file)
}
}
return (
<>
<input
hidden
onChange={onFileUpload}
ref={fileInputRef}
type="file"
name="files"
/>
<Button
size="sm"
type="button"
onClick={handleClick}
disabled={isUploading}
>
{isUploading ? (
<Loader2Icon className="animate-spin size-4" />
) : (
<UploadCloudIcon className="size-4" />
)}
{isUploading ? "Uploading" : "Upload File"}
</Button>
</>
)
}
function NewDirectoryItemDropdown() {
const [newFileType, setNewFileType] = useAtom(newFileTypeAtom)
const addNewDirectory = () => {
setNewFileType(FileType.Directory)
}
const handleCloseAutoFocus = (event: Event) => {
// If we just created a new item, prevent the dropdown from restoring focus to the trigger
if (newFileType) {
event.preventDefault()
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
<PlusIcon className="size-4" />
New
<ChevronDownIcon className="pl-1 size-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent onCloseAutoFocus={handleCloseAutoFocus}>
<DropdownMenuItem>
<TextFileIcon />
Text file
</DropdownMenuItem>
<DropdownMenuItem onClick={addNewDirectory}>
<DirectoryIcon />
Directory
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,58 @@
import { api } from "@fileone/convex/_generated/api"
import { createFileRoute } from "@tanstack/react-router"
import { useQuery as useConvexQuery } from "convex/react"
import { TrashIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DirectoryPageContext } from "@/directories/directory-page/context"
import { DirectoryPage } from "@/directories/directory-page/directory-page"
import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton"
import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb"
export const Route = createFileRoute(
"/_authenticated/_sidebar-layout/trash/directories/$directoryId",
)({
component: RouteComponent,
})
function RouteComponent() {
const { directoryId } = Route.useParams()
const rootDirectory = useConvexQuery(api.files.fetchRootDirectory)
const directory = useConvexQuery(api.files.fetchDirectory, {
directoryId,
})
const directoryContent = useConvexQuery(
api.filesystem.fetchDirectoryContent,
{
directoryId,
trashed: true,
},
)
if (!directory || !directoryContent || !rootDirectory) {
return <DirectoryPageSkeleton />
}
return (
<DirectoryPageContext
value={{ rootDirectory, directory, directoryContent }}
>
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
<FilePathBreadcrumb />
<div className="ml-auto flex flex-row gap-2">
<EmptyTrashButton />
</div>
</header>
<DirectoryPage />
</DirectoryPageContext>
)
}
function EmptyTrashButton() {
return (
<Button size="sm" type="button" variant="outline">
<TrashIcon className="size-4" />
Empty Trash
</Button>
)
}