feat: impl directory delete

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-09-14 18:12:29 +00:00
parent f32af46261
commit 59402f473f
13 changed files with 454 additions and 202 deletions

View File

@@ -15,7 +15,7 @@ import type {
} from "convex/server";
import type * as files from "../files.js";
import type * as model_directories from "../model/directories.js";
import type * as model_files from "../model/files.js";
import type * as model_error from "../model/error.js";
import type * as users from "../users.js";
/**
@@ -29,7 +29,7 @@ import type * as users from "../users.js";
declare const fullApi: ApiFromModules<{
files: typeof files;
"model/directories": typeof model_directories;
"model/files": typeof model_files;
"model/error": typeof model_error;
users: typeof users;
}>;
export declare const api: FilterApi<

View File

@@ -65,12 +65,24 @@ export const saveFile = mutation({
export const moveToTrash = mutation({
args: {
kind: v.union(v.literal("file"), v.literal("directory")),
itemId: v.union(v.id("files"), v.id("directories")),
},
handler: async (ctx, { itemId }) => {
await ctx.db.patch(itemId, {
deletedAt: new Date().toISOString(),
})
handler: async (ctx, { itemId, kind }) => {
switch (kind) {
case "file":
await ctx.db.patch(itemId, {
deletedAt: new Date().toISOString(),
})
break
case "directory":
await Directories.moveToTrashRecursive(
ctx,
itemId as Id<"directories">,
)
break
}
return itemId
},
})

View File

@@ -1,5 +1,6 @@
import type { Doc, Id } from "@convex/_generated/dataModel"
import type { MutationCtx, QueryCtx } from "@convex/_generated/server"
import * as Err from "./error"
type Directory = {
kind: "directory"
@@ -28,6 +29,7 @@ export async function fetchContent(
ctx.db
.query("directories")
.withIndex("byParentId", (q) => q.eq("parentId", directoryId))
.filter((q) => q.eq(q.field("deletedAt"), undefined))
.collect(),
])
@@ -46,6 +48,20 @@ export async function create(
ctx: MutationCtx,
{ name, parentId }: { name: string; parentId?: Id<"directories"> },
): Promise<Id<"directories">> {
const existing = await ctx.db
.query("directories")
.withIndex("uniqueDirectoryInDirectory", (q) =>
q.eq("parentId", parentId).eq("name", name),
)
.first()
if (existing) {
throw Err.create(
Err.Code.DirectoryExists,
`Directory with name ${name} already exists in ${parentId ? `directory ${parentId}` : "root"}`,
)
}
const now = new Date().toISOString()
return await ctx.db.insert("directories", {
name,
@@ -54,3 +70,54 @@ export async function create(
updatedAt: now,
})
}
export async function moveToTrashRecursive(
ctx: MutationCtx,
directoryId: Id<"directories">,
): Promise<void> {
const now = new Date().toISOString()
const filesToDelete: Id<"files">[] = []
const directoriesToDelete: Id<"directories">[] = []
const directoryQueue: Id<"directories">[] = [directoryId]
while (directoryQueue.length > 0) {
const currentDirectoryId = directoryQueue.shift()!
directoriesToDelete.push(currentDirectoryId)
const files = await ctx.db
.query("files")
.withIndex("byDirectoryId", (q) =>
q
.eq("directoryId", currentDirectoryId)
.eq("deletedAt", undefined),
)
.collect()
for (const file of files) {
filesToDelete.push(file._id)
}
const subdirectories = await ctx.db
.query("directories")
.withIndex("byParentId", (q) =>
q.eq("parentId", currentDirectoryId).eq("deletedAt", undefined),
)
.collect()
for (const subdirectory of subdirectories) {
directoryQueue.push(subdirectory._id)
}
}
const filePatches = filesToDelete.map((fileId) =>
ctx.db.patch(fileId, { deletedAt: now }),
)
const directoryPatches = directoriesToDelete.map((dirId) =>
ctx.db.patch(dirId, { deletedAt: now }),
)
await Promise.all([...filePatches, ...directoryPatches])
}

24
convex/model/error.ts Normal file
View File

@@ -0,0 +1,24 @@
import { ConvexError } from "convex/values"
export enum Code {
DirectoryExists = "DirectoryExists",
FileExists = "FileExists",
Internal = "Internal",
}
export type ApplicationError = ConvexError<{ code: Code; message: string }>
export function isApplicationError(error: unknown): error is ApplicationError {
return (
error instanceof ConvexError &&
"code" in error.data &&
"message" in error.data
)
}
export function create(code: Code, message: string = "unknown error") {
return new ConvexError({
code,
message,
})
}

View File

@@ -1,13 +0,0 @@
import { v } from "convex/values"
import { mutation } from "../_generated/server"
export const moveToTrash = mutation({
args: {
fileId: v.id("files"),
},
handler: async (ctx, { fileId }) => {
await ctx.db.patch(fileId, {
deletedAt: new Date().toISOString(),
})
},
})

View File

@@ -19,13 +19,17 @@ const schema = defineSchema({
deletedAt: v.optional(v.string()),
})
.index("byDirectoryId", ["directoryId", "deletedAt"])
.index("byDeletedAt", ["deletedAt"]),
.index("byDeletedAt", ["deletedAt"])
.index("uniqueFileInDirectory", ["directoryId", "name", "deletedAt"]),
directories: defineTable({
name: v.string(),
parentId: v.optional(v.id("directories")),
createdAt: v.string(),
updatedAt: v.string(),
}).index("byParentId", ["parentId"]),
deletedAt: v.optional(v.string()),
})
.index("byParentId", ["parentId", "deletedAt"])
.index("uniqueDirectoryInDirectory", ["parentId", "name", "deletedAt"]),
})
export default schema

View File

@@ -0,0 +1,6 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
export function LoadingSpinner({ className }: { className?: string }) {
return <Loader2Icon className={cn("animate-spin size-4", className)} />
}

View File

@@ -1,9 +1,6 @@
import { api } from "@convex/_generated/api"
import type { Id } from "@convex/_generated/dataModel"
import type {
DirectoryItem,
DirectoryItemKind,
} from "@convex/model/directories"
import type { DirectoryItem } from "@convex/model/directories"
import { useMutation } from "@tanstack/react-query"
import {
type ColumnDef,
@@ -13,8 +10,9 @@ import {
useReactTable,
} from "@tanstack/react-table"
import { useMutation as useContextMutation, useQuery } from "convex/react"
import { atom, useAtomValue, useSetAtom, useStore } from "jotai"
import { TextCursorInputIcon, TrashIcon } from "lucide-react"
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { CheckIcon, TextCursorInputIcon, TrashIcon, XIcon } from "lucide-react"
import { useEffect, useId, useRef } from "react"
import { toast } from "sonner"
import { DirectoryIcon } from "@/components/icons/directory-icon"
import { Checkbox } from "@/components/ui/checkbox"
@@ -32,6 +30,16 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { TextFileIcon } from "../components/icons/text-file-icon"
import { Button } from "../components/ui/button"
import { LoadingSpinner } from "../components/ui/loading-spinner"
import { withDefaultOnError } from "../lib/error"
import { cn } from "../lib/utils"
import {
contextMenuTargeItemAtom,
newItemKindAtom,
optimisticDeletedItemsAtom,
} from "./state"
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B"
@@ -43,16 +51,6 @@ function formatFileSize(bytes: number): string {
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
const contextMenuTargeItemAtom = atom<DirectoryItem | null>(null)
const optimisticDeletedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(),
)
const itemBeingAddedAtom = atom<{
kind: DirectoryItemKind
name: string
} | null>(null)
const columns: ColumnDef<DirectoryItem>[] = [
{
id: "select",
@@ -84,7 +82,7 @@ const columns: ColumnDef<DirectoryItem>[] = [
return <FileNameCell initialName={row.original.doc.name} />
case "directory":
return (
<div className="font-mono">
<div className="flex items-center gap-2">
<DirectoryIcon />
{row.original.doc.name}
</div>
@@ -134,11 +132,10 @@ export function FileTableContextMenu({
}) {
const store = useStore()
const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom)
const moveToTrash = useContextMutation(api.files.moveToTrash)
const moveToTrashMutation = useMutation({
mutationFn: (itemId: Id<"files"> | Id<"directories">) =>
moveToTrash({ itemId }),
onMutate: (itemId) => {
const moveToTrashMutation = useContextMutation(api.files.moveToTrash)
const { mutate: moveToTrash } = useMutation({
mutationFn: moveToTrashMutation,
onMutate: ({ itemId }) => {
setOptimisticDeletedItems((prev) => new Set([...prev, itemId]))
},
onSuccess: (itemId) => {
@@ -162,7 +159,10 @@ export function FileTableContextMenu({
const handleDelete = () => {
const selectedItem = store.get(contextMenuTargeItemAtom)
if (selectedItem) {
moveToTrashMutation.mutate(selectedItem.doc._id)
moveToTrash({
kind: selectedItem.kind,
itemId: selectedItem.doc._id,
})
}
}
@@ -267,20 +267,105 @@ export function FileTableContent() {
}
function NewItemRow() {
const itemBeingAdded = useAtomValue(itemBeingAddedAtom)
if (!itemBeingAdded) {
const inputRef = useRef<HTMLInputElement>(null)
const newItemFormId = useId()
const [newItemKind, setNewItemKind] = useAtom(newItemKindAtom)
const { mutate: createDirectory, isPending } = useMutation({
mutationFn: useContextMutation(api.files.createDirectory),
onSuccess: () => {
setNewItemKind(null)
},
onError: withDefaultOnError(() => {
setTimeout(() => {
inputRef.current?.focus()
}, 1)
}),
})
useEffect(() => {
if (newItemKind) {
setTimeout(() => {
inputRef.current?.focus()
}, 1)
}
}, [newItemKind])
if (!newItemKind) {
return null
}
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const itemName = formData.get("itemName") as string
if (itemName) {
createDirectory({ name: itemName })
} else {
toast.error("Please enter a name.")
}
}
const clearNewItemKind = () => {
// setItemBeingAdded(null)
setNewItemKind(null)
}
return (
<TableRow>
<TableRow className={cn("align-middle", { "opacity-50": isPending })}>
<TableCell />
<TableCell>
<input type="text" value={itemBeingAdded.name} />
<TableCell className="p-0">
<div className="flex items-center gap-2 px-2 py-1 h-full">
{isPending ? (
<LoadingSpinner className="size-6" />
) : (
<DirectoryIcon />
)}
<form
className="w-full"
id={newItemFormId}
onSubmit={onSubmit}
>
<input
ref={inputRef}
type="text"
name="itemName"
defaultValue={newItemKind}
disabled={isPending}
className="w-full h-8 px-2 bg-transparent border border-input rounded-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
/>
</form>
</div>
</TableCell>
<TableCell />
<TableCell align="right" className="space-x-2 p-1">
{!isPending ? (
<>
<Button
type="button"
form={newItemFormId}
variant="ghost"
size="icon"
onClick={clearNewItemKind}
>
<XIcon />
</Button>
<Button type="submit" form={newItemFormId} size="icon">
<CheckIcon />
</Button>
</>
) : null}
</TableCell>
</TableRow>
)
}
function FileNameCell({ initialName }: { initialName: string }) {
return <div>{initialName}</div>
return (
<div className="flex items-center gap-2">
<TextFileIcon />
{initialName}
</div>
)
}

161
src/files/files-page.tsx Normal file
View File

@@ -0,0 +1,161 @@
import { api } from "@convex/_generated/api"
import {
useMutation as useConvexMutation,
useQuery as useConvexQuery,
} from "convex/react"
import { useSetAtom } from "jotai"
import {
ChevronDownIcon,
Loader2Icon,
PlusIcon,
UploadCloudIcon,
} from "lucide-react"
import { type ChangeEvent, useRef, useState } from "react"
import { toast } from "sonner"
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,
BreadcrumbList,
BreadcrumbPage,
} from "../components/ui/breadcrumb"
import { Button } from "../components/ui/button"
import { SidebarTrigger } from "../components/ui/sidebar"
import { FileTable } from "./file-table"
import { newItemKindAtom } from "./state"
export function FilesPage() {
return (
<>
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
<SidebarTrigger className="-ml-1" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>Files</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex flex-row gap-2">
<NewDirectoryItemDropdown />
<UploadFileButton />
</div>
</header>
<div className="p-4 w-full">
<FileTable />
</div>
</>
)
}
function UploadFileButton() {
const [isUploading, setIsUploading] = useState(false)
const currentUser = useConvexQuery(api.users.getCurrentUser)
const generateUploadUrl = useConvexMutation(api.files.generateUploadUrl)
const saveFile = useConvexMutation(api.files.saveFile)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleClick = () => {
fileInputRef.current?.click()
}
const onFileUpload = async (e: ChangeEvent<HTMLInputElement>) => {
if (!currentUser?._id) {
return
}
const file = e.target.files?.[0]
if (file) {
try {
setIsUploading(true)
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,
userId: currentUser._id,
})
toast.success("File uploaded successfully.", {
position: "top-center",
})
} catch (error) {
console.error(error)
} finally {
setIsUploading(false)
}
}
}
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 setNewItemKind = useSetAtom(newItemKindAtom)
const addNewDirectory = () => {
setNewItemKind("directory")
}
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>
<DropdownMenuItem>
<TextFileIcon />
Text file
</DropdownMenuItem>
<DropdownMenuItem onClick={addNewDirectory}>
<DirectoryIcon />
Directory
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

13
src/files/state.ts Normal file
View File

@@ -0,0 +1,13 @@
import { atom } from "jotai"
import type { Id } from "../../convex/_generated/dataModel"
import type {
DirectoryItem,
DirectoryItemKind,
} from "../../convex/model/directories"
export const contextMenuTargeItemAtom = atom<DirectoryItem | null>(null)
export const optimisticDeletedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(),
)
export const newItemKindAtom = atom<DirectoryItemKind | null>(null)

30
src/lib/error.ts Normal file
View File

@@ -0,0 +1,30 @@
import { toast } from "sonner"
import { Code as ErrorCode, isApplicationError } from "../../convex/model/error"
const ERROR_MESSAGE = {
[ErrorCode.DirectoryExists]: "Directory already exists",
[ErrorCode.FileExists]: "File already exists",
[ErrorCode.Internal]: "Internal application error",
} as const
export function formatError(error: unknown): string {
if (isApplicationError(error)) {
return ERROR_MESSAGE[error.data.code]
}
if (error instanceof Error) {
return error.message
}
return "Unknown error"
}
export function defaultOnError(error: unknown) {
console.log(error)
toast.error(formatError(error))
}
export function withDefaultOnError(fn: (error: unknown) => void) {
return (error: unknown) => {
defaultOnError(error)
fn(error)
}
}

View File

@@ -3,16 +3,28 @@ import { Toaster } from "@/components/ui/sonner"
import DashboardSidebar from "@/dashboard/dashboard-sidebar"
import "@/styles/globals.css"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Outlet, createRootRoute } from "@tanstack/react-router"
import { createRootRoute, Outlet } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/router-devtools"
import { ConvexProvider, ConvexReactClient } from "convex/react"
import { toast } from "sonner"
import { formatError } from "@/lib/error"
export const Route = createRootRoute({
component: RootLayout,
})
const convexClient = new ConvexReactClient(process.env.BUN_PUBLIC_CONVEX_URL!)
const queryClient = new QueryClient()
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
console.log(error)
toast.error(formatError(error))
},
throwOnError: false,
},
},
})
function RootLayout() {
return (

View File

@@ -1,155 +1,6 @@
import { DirectoryIcon } from "@/components/icons/directory-icon"
import { TextFileIcon } from "@/components/icons/text-file-icon"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { FileTable } from "@/files/file-table"
import { api } from "@convex/_generated/api"
import { createFileRoute } from "@tanstack/react-router"
import { useMutation, useQuery } from "convex/react"
import {
ChevronDownIcon,
Loader2Icon,
PlusIcon,
UploadCloudIcon,
} from "lucide-react"
import { useRef, useState, type ChangeEvent } from "react"
import { toast } from "sonner"
import { FilesPage } from "@/files/files-page"
export const Route = createFileRoute("/files")({
component: FilesPage,
})
function FilesPage() {
return (
<>
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
<SidebarTrigger className="-ml-1" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>Files</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex flex-row gap-2">
<NewDirectoryItemDropdown />
<UploadFileButton />
</div>
</header>
<div className="p-4 w-full">
<FileTable />
</div>
</>
)
}
function UploadFileButton() {
const [isUploading, setIsUploading] = useState(false)
const currentUser = useQuery(api.users.getCurrentUser)
const generateUploadUrl = useMutation(api.files.generateUploadUrl)
const saveFile = useMutation(api.files.saveFile)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleClick = () => {
fileInputRef.current?.click()
}
const onFileUpload = async (e: ChangeEvent<HTMLInputElement>) => {
if (!currentUser?._id) {
return
}
const file = e.target.files?.[0]
if (file) {
try {
setIsUploading(true)
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,
userId: currentUser._id,
})
toast.success("File uploaded successfully.", {
position: "top-center",
})
} catch (error) {
console.error(error)
} finally {
setIsUploading(false)
}
}
}
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() {
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>
<DropdownMenuItem>
<TextFileIcon />
Text file
</DropdownMenuItem>
<DropdownMenuItem>
<DirectoryIcon />
Directory
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}