4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -15,7 +15,7 @@ import type {
|
|||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
import type * as files from "../files.js";
|
import type * as files from "../files.js";
|
||||||
import type * as model_directories from "../model/directories.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";
|
import type * as users from "../users.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,7 +29,7 @@ import type * as users from "../users.js";
|
|||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
files: typeof files;
|
files: typeof files;
|
||||||
"model/directories": typeof model_directories;
|
"model/directories": typeof model_directories;
|
||||||
"model/files": typeof model_files;
|
"model/error": typeof model_error;
|
||||||
users: typeof users;
|
users: typeof users;
|
||||||
}>;
|
}>;
|
||||||
export declare const api: FilterApi<
|
export declare const api: FilterApi<
|
||||||
|
@@ -65,12 +65,24 @@ export const saveFile = mutation({
|
|||||||
|
|
||||||
export const moveToTrash = mutation({
|
export const moveToTrash = mutation({
|
||||||
args: {
|
args: {
|
||||||
|
kind: v.union(v.literal("file"), v.literal("directory")),
|
||||||
itemId: v.union(v.id("files"), v.id("directories")),
|
itemId: v.union(v.id("files"), v.id("directories")),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { itemId }) => {
|
handler: async (ctx, { itemId, kind }) => {
|
||||||
|
switch (kind) {
|
||||||
|
case "file":
|
||||||
await ctx.db.patch(itemId, {
|
await ctx.db.patch(itemId, {
|
||||||
deletedAt: new Date().toISOString(),
|
deletedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
break
|
||||||
|
case "directory":
|
||||||
|
await Directories.moveToTrashRecursive(
|
||||||
|
ctx,
|
||||||
|
itemId as Id<"directories">,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
return itemId
|
return itemId
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import type { Doc, Id } from "@convex/_generated/dataModel"
|
import type { Doc, Id } from "@convex/_generated/dataModel"
|
||||||
import type { MutationCtx, QueryCtx } from "@convex/_generated/server"
|
import type { MutationCtx, QueryCtx } from "@convex/_generated/server"
|
||||||
|
import * as Err from "./error"
|
||||||
|
|
||||||
type Directory = {
|
type Directory = {
|
||||||
kind: "directory"
|
kind: "directory"
|
||||||
@@ -28,6 +29,7 @@ export async function fetchContent(
|
|||||||
ctx.db
|
ctx.db
|
||||||
.query("directories")
|
.query("directories")
|
||||||
.withIndex("byParentId", (q) => q.eq("parentId", directoryId))
|
.withIndex("byParentId", (q) => q.eq("parentId", directoryId))
|
||||||
|
.filter((q) => q.eq(q.field("deletedAt"), undefined))
|
||||||
.collect(),
|
.collect(),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -46,6 +48,20 @@ export async function create(
|
|||||||
ctx: MutationCtx,
|
ctx: MutationCtx,
|
||||||
{ name, parentId }: { name: string; parentId?: Id<"directories"> },
|
{ name, parentId }: { name: string; parentId?: Id<"directories"> },
|
||||||
): Promise<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()
|
const now = new Date().toISOString()
|
||||||
return await ctx.db.insert("directories", {
|
return await ctx.db.insert("directories", {
|
||||||
name,
|
name,
|
||||||
@@ -54,3 +70,54 @@ export async function create(
|
|||||||
updatedAt: now,
|
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
24
convex/model/error.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
@@ -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(),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
@@ -19,13 +19,17 @@ const schema = defineSchema({
|
|||||||
deletedAt: v.optional(v.string()),
|
deletedAt: v.optional(v.string()),
|
||||||
})
|
})
|
||||||
.index("byDirectoryId", ["directoryId", "deletedAt"])
|
.index("byDirectoryId", ["directoryId", "deletedAt"])
|
||||||
.index("byDeletedAt", ["deletedAt"]),
|
.index("byDeletedAt", ["deletedAt"])
|
||||||
|
.index("uniqueFileInDirectory", ["directoryId", "name", "deletedAt"]),
|
||||||
directories: defineTable({
|
directories: defineTable({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
parentId: v.optional(v.id("directories")),
|
parentId: v.optional(v.id("directories")),
|
||||||
createdAt: v.string(),
|
createdAt: v.string(),
|
||||||
updatedAt: 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
|
export default schema
|
||||||
|
6
src/components/ui/loading-spinner.tsx
Normal file
6
src/components/ui/loading-spinner.tsx
Normal 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)} />
|
||||||
|
}
|
@@ -1,9 +1,6 @@
|
|||||||
import { api } from "@convex/_generated/api"
|
import { api } from "@convex/_generated/api"
|
||||||
import type { Id } from "@convex/_generated/dataModel"
|
import type { Id } from "@convex/_generated/dataModel"
|
||||||
import type {
|
import type { DirectoryItem } from "@convex/model/directories"
|
||||||
DirectoryItem,
|
|
||||||
DirectoryItemKind,
|
|
||||||
} from "@convex/model/directories"
|
|
||||||
import { useMutation } from "@tanstack/react-query"
|
import { useMutation } from "@tanstack/react-query"
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
@@ -13,8 +10,9 @@ import {
|
|||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { useMutation as useContextMutation, useQuery } from "convex/react"
|
import { useMutation as useContextMutation, useQuery } from "convex/react"
|
||||||
import { atom, useAtomValue, useSetAtom, useStore } from "jotai"
|
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
|
||||||
import { TextCursorInputIcon, TrashIcon } from "lucide-react"
|
import { CheckIcon, TextCursorInputIcon, TrashIcon, XIcon } from "lucide-react"
|
||||||
|
import { useEffect, useId, useRef } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { DirectoryIcon } from "@/components/icons/directory-icon"
|
import { DirectoryIcon } from "@/components/icons/directory-icon"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
@@ -32,6 +30,16 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} 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 {
|
function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return "0 B"
|
if (bytes === 0) return "0 B"
|
||||||
@@ -43,16 +51,6 @@ function formatFileSize(bytes: number): string {
|
|||||||
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
|
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>[] = [
|
const columns: ColumnDef<DirectoryItem>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
@@ -84,7 +82,7 @@ const columns: ColumnDef<DirectoryItem>[] = [
|
|||||||
return <FileNameCell initialName={row.original.doc.name} />
|
return <FileNameCell initialName={row.original.doc.name} />
|
||||||
case "directory":
|
case "directory":
|
||||||
return (
|
return (
|
||||||
<div className="font-mono">
|
<div className="flex items-center gap-2">
|
||||||
<DirectoryIcon />
|
<DirectoryIcon />
|
||||||
{row.original.doc.name}
|
{row.original.doc.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,11 +132,10 @@ export function FileTableContextMenu({
|
|||||||
}) {
|
}) {
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom)
|
const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom)
|
||||||
const moveToTrash = useContextMutation(api.files.moveToTrash)
|
const moveToTrashMutation = useContextMutation(api.files.moveToTrash)
|
||||||
const moveToTrashMutation = useMutation({
|
const { mutate: moveToTrash } = useMutation({
|
||||||
mutationFn: (itemId: Id<"files"> | Id<"directories">) =>
|
mutationFn: moveToTrashMutation,
|
||||||
moveToTrash({ itemId }),
|
onMutate: ({ itemId }) => {
|
||||||
onMutate: (itemId) => {
|
|
||||||
setOptimisticDeletedItems((prev) => new Set([...prev, itemId]))
|
setOptimisticDeletedItems((prev) => new Set([...prev, itemId]))
|
||||||
},
|
},
|
||||||
onSuccess: (itemId) => {
|
onSuccess: (itemId) => {
|
||||||
@@ -162,7 +159,10 @@ export function FileTableContextMenu({
|
|||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
const selectedItem = store.get(contextMenuTargeItemAtom)
|
const selectedItem = store.get(contextMenuTargeItemAtom)
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
moveToTrashMutation.mutate(selectedItem.doc._id)
|
moveToTrash({
|
||||||
|
kind: selectedItem.kind,
|
||||||
|
itemId: selectedItem.doc._id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,20 +267,105 @@ export function FileTableContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NewItemRow() {
|
function NewItemRow() {
|
||||||
const itemBeingAdded = useAtomValue(itemBeingAddedAtom)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
if (!itemBeingAdded) {
|
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
|
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 (
|
return (
|
||||||
<TableRow>
|
<TableRow className={cn("align-middle", { "opacity-50": isPending })}>
|
||||||
<TableCell />
|
<TableCell />
|
||||||
<TableCell>
|
<TableCell className="p-0">
|
||||||
<input type="text" value={itemBeingAdded.name} />
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileNameCell({ initialName }: { initialName: string }) {
|
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
161
src/files/files-page.tsx
Normal 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
13
src/files/state.ts
Normal 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
30
src/lib/error.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@@ -3,16 +3,28 @@ import { Toaster } from "@/components/ui/sonner"
|
|||||||
import DashboardSidebar from "@/dashboard/dashboard-sidebar"
|
import DashboardSidebar from "@/dashboard/dashboard-sidebar"
|
||||||
import "@/styles/globals.css"
|
import "@/styles/globals.css"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
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 { TanStackRouterDevtools } from "@tanstack/router-devtools"
|
||||||
import { ConvexProvider, ConvexReactClient } from "convex/react"
|
import { ConvexProvider, ConvexReactClient } from "convex/react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { formatError } from "@/lib/error"
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
const convexClient = new ConvexReactClient(process.env.BUN_PUBLIC_CONVEX_URL!)
|
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() {
|
function RootLayout() {
|
||||||
return (
|
return (
|
||||||
|
@@ -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 { createFileRoute } from "@tanstack/react-router"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { FilesPage } from "@/files/files-page"
|
||||||
import {
|
|
||||||
ChevronDownIcon,
|
|
||||||
Loader2Icon,
|
|
||||||
PlusIcon,
|
|
||||||
UploadCloudIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useRef, useState, type ChangeEvent } from "react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/files")({
|
export const Route = createFileRoute("/files")({
|
||||||
component: FilesPage,
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
Reference in New Issue
Block a user