4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -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<
|
||||
|
@@ -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 }) => {
|
||||
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
|
||||
},
|
||||
})
|
||||
|
@@ -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
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()),
|
||||
})
|
||||
.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
|
||||
|
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 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
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 "@/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 (
|
||||
|
@@ -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>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user