refactor: migrate to vite and restructure repo

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-10-18 14:02:20 +00:00
parent 83a5f92506
commit 25796ab609
94 changed files with 478 additions and 312 deletions

View File

@@ -0,0 +1,71 @@
import { useAtomValue } from "jotai"
import { CircleAlertIcon, XIcon } from "lucide-react"
import type React from "react"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { Tooltip } from "@/components/ui/tooltip"
import { FileUploadStatusKind, fileUploadStatusAtomFamily } from "./store"
import type { PickedFile } from "./upload-file-dialog"
export function PickedFileItem({
file: pickedFile,
onRemove,
}: {
file: PickedFile
onRemove: (file: PickedFile) => void
}) {
const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id)
const fileUpload = useAtomValue(fileUploadAtom)
console.log("fileUpload", fileUpload)
const { file, id } = pickedFile
let statusIndicator: React.ReactNode
if (!fileUpload) {
statusIndicator = (
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(pickedFile)}
>
<XIcon className="size-4" />
</Button>
)
} else {
switch (fileUpload.kind) {
case FileUploadStatusKind.InProgress:
statusIndicator = <Progress value={fileUpload.progress * 100} />
break
case FileUploadStatusKind.Error:
statusIndicator = (
<Tooltip>
<TooltipTrigger>
<CircleAlertIcon />
</TooltipTrigger>
</Tooltip>
)
}
}
return (
<li
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center"
key={id}
>
<span>{file.name}</span>
{fileUpload ? (
<Progress
className="max-w-20"
value={fileUpload.progress * 100}
/>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(pickedFile)}
>
<XIcon className="size-4" />
</Button>
)}
</li>
)
}

View File

@@ -0,0 +1,21 @@
import type { Doc } from "@fileone/convex/_generated/dataModel"
import { ImagePreviewDialog } from "./image-preview-dialog"
export function FilePreviewDialog({
file,
onClose,
}: {
file: Doc<"files">
onClose: () => void
}) {
if (!file) return null
switch (file.mimeType) {
case "image/jpeg":
case "image/png":
case "image/gif":
return <ImagePreviewDialog file={file} onClose={onClose} />
default:
return null
}
}

View File

@@ -0,0 +1,422 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import type { DirectoryItem } from "@fileone/convex/types"
import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import {
type ColumnDef,
flexRender,
getCoreRowModel,
type Row,
useReactTable,
} from "@tanstack/react-table"
import { useMutation as useContextMutation, useQuery } from "convex/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"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
Table,
TableBody,
TableCell,
TableHead,
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,
itemBeingRenamedAtom,
newItemKindAtom,
optimisticDeletedItemsAtom,
} from "./state"
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
const columns: ColumnDef<DirectoryItem>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={row.getToggleSelectedHandler()}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 24,
},
{
header: "Name",
accessorKey: "doc.name",
cell: ({ row }) => {
switch (row.original.kind) {
case "file":
return <FileNameCell initialName={row.original.doc.name} />
case "directory":
return <DirectoryNameCell directory={row.original.doc} />
}
},
size: 1000,
},
{
header: "Size",
accessorKey: "size",
cell: ({ row }) => {
switch (row.original.kind) {
case "file":
return <div>{formatFileSize(row.original.doc.size)}</div>
case "directory":
return <div className="font-mono">-</div>
}
},
},
{
header: "Created At",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<div>
{new Date(row.original.doc.createdAt).toLocaleString()}
</div>
)
},
},
]
export function FileTable({ path }: { path: string }) {
return (
<FileTableContextMenu>
<div className="w-full">
<FileTableContent path={path} />
</div>
</FileTableContextMenu>
)
}
export function FileTableContextMenu({
children,
}: {
children: React.ReactNode
}) {
const store = useStore()
const target = useAtomValue(contextMenuTargeItemAtom)
const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom)
const moveToTrashMutation = useContextMutation(api.files.moveToTrash)
const setItemBeingRenamed = useSetAtom(itemBeingRenamedAtom)
const { mutate: moveToTrash } = useMutation({
mutationFn: moveToTrashMutation,
onMutate: ({ itemId }) => {
setOptimisticDeletedItems((prev) => new Set([...prev, itemId]))
},
onSuccess: (itemId) => {
setOptimisticDeletedItems((prev) => {
const newSet = new Set(prev)
newSet.delete(itemId)
return newSet
})
toast.success("Moved to trash")
},
})
const handleRename = () => {
const selectedItem = store.get(contextMenuTargeItemAtom)
if (selectedItem) {
setItemBeingRenamed({
kind: selectedItem.kind,
originalItem: selectedItem,
name: selectedItem.doc.name,
})
}
}
const handleDelete = () => {
const selectedItem = store.get(contextMenuTargeItemAtom)
if (selectedItem) {
moveToTrash({
kind: selectedItem.kind,
itemId: selectedItem.doc._id,
})
}
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
{target && (
<ContextMenuContent>
<ContextMenuItem onClick={handleRename}>
<TextCursorInputIcon />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={handleDelete}>
<TrashIcon />
Move to trash
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
)
}
export function FileTableContent({ path }: { path: string }) {
const directory = useQuery(api.files.fetchDirectoryContent, { path })
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)
const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemAtom)
const store = useStore()
const handleRowContextMenu = (
row: Row<DirectoryItem>,
_event: React.MouseEvent,
) => {
const target = store.get(contextMenuTargeItemAtom)
if (target === row.original) {
setContextMenuTargetItem(null)
} else {
selectRow(row)
setContextMenuTargetItem(row.original)
}
}
const table = useReactTable({
data: directory || [],
columns,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
enableGlobalFilter: true,
globalFilterFn: (row, _columnId, _filterValue, _addMeta) => {
return !optimisticDeletedItems.has(row.original.doc._id)
},
getRowId: (row) => row.doc._id,
})
const selectRow = (row: Row<DirectoryItem>) => {
console.log("row.getIsSelected()", row.getIsSelected())
if (!row.getIsSelected()) {
table.toggleAllPageRowsSelected(false)
row.toggleSelected(true)
}
}
if (!directory) {
return null
}
return (
<div className="overflow-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow className="px-4" key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
className="first:pl-4 last:pr-4"
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => {
selectRow(row)
}}
onContextMenu={(e) => {
handleRowContextMenu(row, e)
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell
className="first:pl-4 last:pr-4"
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<NoResultsRow />
)}
<NewItemRow />
</TableBody>
</Table>
</div>
)
}
function NoResultsRow() {
const newItemKind = useAtomValue(newItemKindAtom)
if (newItemKind) {
return null
}
return (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
No results.
</TableCell>
</TableRow>
)
}
function NewItemRow() {
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 className={cn("align-middle", { "opacity-50": isPending })}>
<TableCell />
<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 DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) {
return (
<div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" />
<Link className="hover:underline" to={`/files/${directory.path}`}>
{directory.name}
</Link>
</div>
)
}
function FileNameCell({ initialName }: { initialName: string }) {
return (
<div className="flex w-full items-center gap-2">
<TextFileIcon className="size-4" />
{initialName}
</div>
)
}

View File

@@ -0,0 +1,179 @@
import { api } from "@fileone/convex/_generated/api"
import { baseName, splitPath } from "@fileone/path"
import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { useMutation as useConvexMutation } from "convex/react"
import { useSetAtom } from "jotai"
import {
ChevronDownIcon,
Loader2Icon,
PlusIcon,
UploadCloudIcon,
} from "lucide-react"
import { type ChangeEvent, Fragment, useRef } 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,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "../components/ui/breadcrumb"
import { Button } from "../components/ui/button"
import { FileTable } from "./file-table"
import { RenameFileDialog } from "./rename-file-dialog"
import { newItemKindAtom } from "./state"
export function FilesPage({ path }: { path: string }) {
return (
<>
<header className="flex py-1 shrink-0 items-center gap-2 border-b px-4 w-full">
<FilePathBreadcrumb path={path} />
<div className="ml-auto flex flex-row gap-2">
<NewDirectoryItemDropdown />
<UploadFileButton />
</div>
</header>
<div className="w-full">
<FileTable path={path} />
</div>
<RenameFileDialog />
</>
)
}
function FilePathBreadcrumb({ path }: { path: string }) {
const pathComponents = splitPath(path)
const base = baseName(path)
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/files">All Files</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{pathComponents.map((p) => (
<Fragment key={p}>
<BreadcrumbSeparator />
{p === base ? (
<BreadcrumbPage>{p}</BreadcrumbPage>
) : (
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/files/${p}`}>{p}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
)
}
// tags: upload, uploadfile, uploadfilebutton, fileupload, fileuploadbutton
function UploadFileButton() {
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,
})
},
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 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>
)
}

View File

@@ -0,0 +1,210 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import { DialogTitle } from "@radix-ui/react-dialog"
import { useQuery as useConvexQuery } from "convex/react"
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import {
DownloadIcon,
Maximize2Icon,
Minimize2Icon,
XIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react"
import { useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogOverlay,
} from "@/components/ui/dialog"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
const zoomLevelAtom = atom(
1,
(get, set, update: number | ((current: number) => number)) => {
const current = get(zoomLevelAtom)
console.log("current", current)
const newValue = typeof update === "function" ? update(current) : update
if (newValue >= 0.1) {
set(zoomLevelAtom, newValue)
}
},
)
export function ImagePreviewDialog({
file,
onClose,
}: {
file: Doc<"files">
onClose: () => void
}) {
const fileUrl = useConvexQuery(api.filesystem.fetchFileUrl, {
fileId: file._id,
})
const setZoomLevel = useSetAtom(zoomLevelAtom)
useEffect(
() => () => {
setZoomLevel(1)
},
[setZoomLevel],
)
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
<DialogOverlay className="flex items-center justify-center">
{!fileUrl ? (
<LoadingSpinner className="text-neutral-200 size-10" />
) : null}
</DialogOverlay>
{fileUrl ? <PreviewContent fileUrl={fileUrl} file={file} /> : null}
</Dialog>
)
}
function PreviewContent({
fileUrl,
file,
}: {
fileUrl: string
file: Doc<"files">
}) {
return (
<DialogContent
showCloseButton={false}
className="p-0 lg:min-w-1/3 gap-0"
>
<DialogHeader className="overflow-auto border-b border-b-border p-4 flex flex-row items-center justify-between">
<DialogTitle className="truncate flex-1">
{file.name}
</DialogTitle>
<div className="flex flex-row items-center space-x-2">
<Toolbar fileUrl={fileUrl} file={file} />
<Button variant="ghost" size="icon" asChild>
<DialogClose>
<XIcon />
<span className="sr-only">Close</span>
</DialogClose>
</Button>
</div>
</DialogHeader>
<div className="w-full h-full flex items-center justify-center max-h-[calc(100vh-10rem)] overflow-auto">
<ImagePreview fileUrl={fileUrl} file={file} />
</div>
</DialogContent>
)
}
function Toolbar({ fileUrl, file }: { fileUrl: string; file: Doc<"files"> }) {
const setZoomLevel = useSetAtom(zoomLevelAtom)
const zoomInterval = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(
() => () => {
if (zoomInterval.current) {
clearInterval(zoomInterval.current)
console.log("clearInterval")
zoomInterval.current = null
}
},
[],
)
function startZooming(delta: number) {
setZoomLevel((zoom) => zoom + delta)
zoomInterval.current = setInterval(() => {
setZoomLevel((zoom) => zoom + delta)
}, 100)
}
function stopZooming() {
if (zoomInterval.current) {
clearInterval(zoomInterval.current)
zoomInterval.current = null
}
}
return (
<div className="flex flex-row items-center space-x-2 border-r border-r-border pr-4">
<ResetZoomButton />
<Button
variant="ghost"
onMouseDown={() => {
startZooming(0.1)
}}
onMouseLeave={stopZooming}
onMouseUp={stopZooming}
>
<ZoomInIcon />
</Button>
<Button
variant="ghost"
onMouseDown={() => {
startZooming(-0.1)
}}
onMouseLeave={stopZooming}
onMouseUp={stopZooming}
>
<ZoomOutIcon />
</Button>
<Button asChild>
<a
href={fileUrl}
download={file.name}
target="_blank"
className="flex flex-row items-center"
>
<DownloadIcon />
<span className="sr-only md:not-sr-only">Download</span>
</a>
</Button>
</div>
)
}
function ResetZoomButton() {
const [zoomLevel, setZoomLevel] = useAtom(zoomLevelAtom)
if (zoomLevel === 1) {
return null
}
return (
<Button
variant="ghost"
onClick={() => {
setZoomLevel(1)
}}
>
{zoomLevel > 1 ? <Minimize2Icon /> : <Maximize2Icon />}
</Button>
)
}
function ImagePreview({
fileUrl,
file,
}: {
fileUrl: string
file: Doc<"files">
}) {
const zoomLevel = useAtomValue(zoomLevelAtom)
return (
<img
src={fileUrl}
alt={file.name}
className="object-contain"
style={{ transform: `scale(${zoomLevel})` }}
/>
)
}

View File

@@ -0,0 +1,111 @@
import { api } from "@fileone/convex/_generated/api"
import { useMutation } from "@tanstack/react-query"
import { useMutation as useContextMutation } from "convex/react"
import { atom, useAtom, useStore } from "jotai"
import { useId } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { itemBeingRenamedAtom } from "./state"
const fielNameAtom = atom(
(get) => get(itemBeingRenamedAtom)?.name,
(get, set, newName: string) => {
const current = get(itemBeingRenamedAtom)
if (current) {
set(itemBeingRenamedAtom, {
...current,
name: newName,
})
}
},
)
export function RenameFileDialog() {
const [itemBeingRenamed, setItemBeingRenamed] =
useAtom(itemBeingRenamedAtom)
const store = useStore()
const formId = useId()
const { mutate: renameFile, isPending: isRenaming } = useMutation({
mutationFn: useContextMutation(api.files.renameFile),
onSuccess: () => {
setItemBeingRenamed(null)
toast.success("File renamed successfully")
},
})
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const itemBeingRenamed = store.get(itemBeingRenamedAtom)
if (itemBeingRenamed) {
const formData = new FormData(event.currentTarget)
const newName = formData.get("itemName") as string
if (newName) {
switch (itemBeingRenamed.originalItem.kind) {
case "file":
renameFile({
directoryId:
itemBeingRenamed.originalItem.doc.directoryId,
itemId: itemBeingRenamed.originalItem.doc._id,
newName,
})
break
default:
break
}
}
}
}
return (
<Dialog
open={itemBeingRenamed !== null}
onOpenChange={(open) =>
setItemBeingRenamed(open ? itemBeingRenamed : null)
}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rename File</DialogTitle>
</DialogHeader>
<form id={formId} onSubmit={onSubmit}>
<RenameFileInput />
</form>
<DialogFooter>
<DialogClose asChild>
<Button loading={isRenaming} variant="outline">
<span>Cancel</span>
</Button>
</DialogClose>
<Button loading={isRenaming} type="submit" form={formId}>
<span>Rename</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function RenameFileInput() {
const [fileName, setFileName] = useAtom(fielNameAtom)
return (
<Input
value={fileName}
name="itemName"
onChange={(e) => setFileName(e.target.value)}
/>
)
}

View File

@@ -0,0 +1,22 @@
import type { Id } from "@fileone/convex/_generated/dataModel"
import type {
DirectoryItem,
DirectoryItemKind,
} from "@fileone/convex/types"
import type { RowSelectionState } from "@tanstack/react-table"
import { atom } from "jotai"
export const contextMenuTargeItemAtom = atom<DirectoryItem | null>(null)
export const optimisticDeletedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(),
)
export const selectedFileRowsAtom = atom<RowSelectionState>({})
export const newItemKindAtom = atom<DirectoryItemKind | null>(null)
export const itemBeingRenamedAtom = atom<{
kind: DirectoryItemKind
originalItem: DirectoryItem
name: string
} | null>(null)

View File

@@ -0,0 +1,97 @@
import { atom } from "jotai"
import { atomFamily } from "jotai/utils"
export enum FileUploadStatusKind {
InProgress = "InProgress",
Error = "Error",
Success = "Success",
}
export type FileUploadInProgress = {
kind: FileUploadStatusKind.InProgress
progress: number
}
export type FileUploadError = {
kind: FileUploadStatusKind.Error
error: unknown
}
export type FileUploadSuccess = {
kind: FileUploadStatusKind.Success
}
export type FileUploadStatus =
| FileUploadInProgress
| FileUploadError
| FileUploadSuccess
export const fileUploadStatusesAtom = atom<Record<string, FileUploadStatus>>({})
export const fileUploadStatusAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(fileUploadStatusesAtom)[id],
(get, set, status: FileUploadStatus) => {
const fileUploads = { ...get(fileUploadStatusesAtom) }
fileUploads[id] = status
set(fileUploadStatusesAtom, fileUploads)
},
),
)
export const clearFileUploadStatusesAtom = atom(
null,
(get, set, ids: string[]) => {
const fileUploads = { ...get(fileUploadStatusesAtom) }
for (const id of ids) {
if (fileUploads[id]) {
delete fileUploads[id]
}
fileUploadStatusAtomFamily.remove(id)
}
set(fileUploadStatusesAtom, fileUploads)
},
)
export const clearAllFileUploadStatusesAtom = atom(
null,
(get, set) => {
set(fileUploadStatusesAtom, {})
},
)
export const fileUploadCountAtom = atom(
(get) => Object.keys(get(fileUploadStatusesAtom)).length,
)
export const inProgressFileUploadCountAtom = atom((get) => {
const statuses = get(fileUploadStatusesAtom)
let count = 0
for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.InProgress) {
count += 1
}
}
return count
})
export const successfulFileUploadCountAtom = atom((get) => {
const statuses = get(fileUploadStatusesAtom)
let count = 0
for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.Success) {
count += 1
}
}
return count
})
export const hasFileUploadsErrorAtom = atom((get) => {
const statuses = get(fileUploadStatusesAtom)
for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.Error) {
return true
}
}
return false
})

View File

@@ -0,0 +1,599 @@
import type { Doc } from "@fileone/convex/_generated/dataModel"
import { mutationOptions } from "@tanstack/react-query"
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { atomEffect } from "jotai-effect"
import { atomWithMutation } from "jotai-tanstack-query"
import {
CircleAlertIcon,
CircleCheckIcon,
FilePlus2Icon,
UploadCloudIcon,
XIcon,
} from "lucide-react"
import { nanoid } from "nanoid"
import type React from "react"
import { useId, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Progress } from "@/components/ui/progress"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { formatError } from "@/lib/error"
import {
clearAllFileUploadStatusesAtom,
clearFileUploadStatusesAtom,
FileUploadStatusKind,
fileUploadCountAtom,
fileUploadStatusAtomFamily,
fileUploadStatusesAtom,
hasFileUploadsErrorAtom,
successfulFileUploadCountAtom,
} from "./store"
import useUploadFile from "./use-upload-file"
type UploadFileDialogProps = {
targetDirectory: Doc<"directories">
onClose: () => void
}
// Upload file atoms
export type PickedFile = {
id: string
file: File
}
export const pickedFilesAtom = atom<PickedFile[]>([])
function useUploadFilesAtom({
targetDirectory,
}: {
targetDirectory: Doc<"directories">
}) {
const uploadFile = useUploadFile({ targetDirectory })
const store = useStore()
const options = useMemo(
() =>
mutationOptions({
mutationFn: async (files: PickedFile[]) => {
const promises = files.map((pickedFile) =>
uploadFile({
file: pickedFile.file,
onStart: () => {
store.set(
fileUploadStatusAtomFamily(pickedFile.id),
{
kind: FileUploadStatusKind.InProgress,
progress: 0,
},
)
},
onProgress: (progress) => {
store.set(
fileUploadStatusAtomFamily(pickedFile.id),
{
kind: FileUploadStatusKind.InProgress,
progress,
},
)
},
}).catch((error) => {
console.log("error", error)
store.set(
fileUploadStatusAtomFamily(pickedFile.id),
{
kind: FileUploadStatusKind.Error,
error,
},
)
throw error
}),
)
return await Promise.allSettled(promises)
},
onSuccess: (results, files) => {
const remainingPickedFiles: PickedFile[] = []
results.forEach((result, i) => {
// biome-ignore lint/style/noNonNullAssertion: results lenght must match input files array length
const pickedFile = files[i]!
const statusAtom = fileUploadStatusAtomFamily(
pickedFile.id,
)
switch (result.status) {
case "fulfilled":
store.set(statusAtom, {
kind: FileUploadStatusKind.Success,
})
break
case "rejected":
store.set(statusAtom, {
kind: FileUploadStatusKind.Error,
error: result.reason,
})
remainingPickedFiles.push(pickedFile)
break
}
})
// setPickedFiles(remainingPickedFiles)
if (remainingPickedFiles.length === 0) {
toast.success("All files uploaded successfully")
}
},
}),
[uploadFile, store.set],
)
return useMemo(() => atomWithMutation(() => options), [options])
}
type UploadFilesAtom = ReturnType<typeof useUploadFilesAtom>
export function UploadFileDialog({
targetDirectory,
onClose,
}: UploadFileDialogProps) {
const formId = useId()
const fileInputRef = useRef<HTMLInputElement>(null)
const setPickedFiles = useSetAtom(pickedFilesAtom)
const clearFileUploadStatuses = useSetAtom(clearFileUploadStatusesAtom)
const store = useStore()
const updateFileInputEffect = useMemo(
() =>
atomEffect((get) => {
const dataTransfer = new DataTransfer()
const pickedFiles = get(pickedFilesAtom)
for (const { file } of pickedFiles) {
dataTransfer.items.add(file)
}
if (fileInputRef.current) {
fileInputRef.current.files = dataTransfer.files
}
}),
[],
)
useAtom(updateFileInputEffect)
const uploadFilesAtom = useUploadFilesAtom({
targetDirectory,
})
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
}
function openFilePicker() {
fileInputRef.current?.click()
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.target.files
if (files) {
setPickedFiles((prev) => [
...prev,
...Array.from(files).map((file) => ({ id: nanoid(), file })),
])
}
}
function onUploadButtonClick() {
const uploadStatuses = store.get(fileUploadStatusesAtom)
const fileUploadCount = store.get(fileUploadCountAtom)
const pickedFiles = store.get(pickedFilesAtom)
const { mutate: uploadFiles, reset: restUploadFilesMutation } =
store.get(uploadFilesAtom)
if (pickedFiles.length === 0) {
// no files are picked, nothing to upload
return
}
if (fileUploadCount === 0) {
// no files are being uploaded, upload all picked files
uploadFiles(pickedFiles)
return
}
const successfulUploads: PickedFile["id"][] = []
const nextPickedFiles: PickedFile[] = []
for (const file of pickedFiles) {
const uploadStatus = uploadStatuses[file.id]
if (uploadStatus) {
switch (uploadStatus.kind) {
case FileUploadStatusKind.Success:
successfulUploads.push(file.id)
continue
case FileUploadStatusKind.InProgress:
continue
case FileUploadStatusKind.Error:
nextPickedFiles.push(file)
break
}
}
}
clearFileUploadStatuses(successfulUploads)
if (successfulUploads.length === pickedFiles.length) {
// all files were successfully uploaded, close the dialog
onClose()
} else {
// some files were not successfully uploaded, set the next picked files
setPickedFiles(nextPickedFiles)
restUploadFilesMutation()
uploadFiles(nextPickedFiles)
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<DialogContent className="sm:max-w-2xl">
<UploadDialogHeader
uploadFilesAtom={uploadFilesAtom}
targetDirectory={targetDirectory}
/>
<form id={formId} onSubmit={handleSubmit}>
<input
hidden
multiple
type="file"
name="files"
ref={fileInputRef}
onChange={handleFileChange}
/>
<UploadFileDropContainer>
<UploadFileArea onClick={openFilePicker} />
</UploadFileDropContainer>
</form>
<DialogFooter>
<ContinueUploadAfterSuccessfulUploadButton
uploadFilesAtom={uploadFilesAtom}
/>
<SelectMoreFilesButton
onClick={openFilePicker}
uploadFilesAtom={uploadFilesAtom}
/>
<UploadButton
uploadFilesAtom={uploadFilesAtom}
onClick={onUploadButtonClick}
/>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function UploadDialogHeader({
uploadFilesAtom,
targetDirectory,
}: {
uploadFilesAtom: UploadFilesAtom
targetDirectory: Doc<"directories">
}) {
const { data: uploadResults, isPending: isUploading } =
useAtomValue(uploadFilesAtom)
const successfulUploadCount = useAtomValue(successfulFileUploadCountAtom)
let dialogTitle: string
let dialogDescription: string
if (isUploading) {
dialogTitle = "Uploading files"
dialogDescription =
"You can close the dialog while they are being uploaded in the background."
} else if (
uploadResults &&
uploadResults.length > 0 &&
successfulUploadCount === uploadResults.length
) {
dialogTitle = "Files uploaded"
dialogDescription =
"Click 'Done' to close the dialog, or select more files to upload."
} else if (targetDirectory.name) {
dialogTitle = `Upload file to "${targetDirectory.name}"`
dialogDescription = "Drag and drop files here or click to select files"
} else {
dialogTitle = "Upload file"
dialogDescription = "Drag and drop files here or click to select files"
}
return (
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
)
}
function ContinueUploadAfterSuccessfulUploadButton({
uploadFilesAtom,
}: {
uploadFilesAtom: UploadFilesAtom
}) {
const setPickedFiles = useSetAtom(pickedFilesAtom)
const clearAllFileUploadStatuses = useSetAtom(
clearAllFileUploadStatusesAtom,
)
const {
data: uploadResults,
isPending: isUploading,
reset: resetUploadFilesMutation,
} = useAtomValue(uploadFilesAtom)
const successfulUploadCount = useAtomValue(successfulFileUploadCountAtom)
if (
!uploadResults ||
uploadResults.length === 0 ||
successfulUploadCount !== uploadResults.length
) {
return null
}
function resetUploadState() {
setPickedFiles([])
clearAllFileUploadStatuses()
resetUploadFilesMutation()
}
return (
<Button
variant="outline"
onClick={resetUploadState}
disabled={isUploading}
>
Upload more files
</Button>
)
}
/**
* allows the user to select more files after they have selected some files for upload. only visible before any upload has been started.
*/
function SelectMoreFilesButton({
onClick,
uploadFilesAtom,
}: {
onClick: () => void
uploadFilesAtom: UploadFilesAtom
}) {
const pickedFiles = useAtomValue(pickedFilesAtom)
const { data: uploadResults, isPending: isUploading } =
useAtomValue(uploadFilesAtom)
if (pickedFiles.length === 0 || uploadResults) {
return null
}
return (
<Button variant="outline" onClick={onClick} disabled={isUploading}>
Select more files
</Button>
)
}
function UploadButton({
uploadFilesAtom,
onClick,
}: {
uploadFilesAtom: UploadFilesAtom
onClick: () => void
}) {
const pickedFiles = useAtomValue(pickedFilesAtom)
const hasUploadErrors = useAtomValue(hasFileUploadsErrorAtom)
const fileUploadCount = useAtomValue(fileUploadCountAtom)
const { isPending: isUploading } = useAtomValue(uploadFilesAtom)
let label: string
if (hasUploadErrors) {
label = "Retry failed uploads"
} else if (pickedFiles.length > 0) {
if (fileUploadCount > 0) {
label = "Done"
} else {
label = `Upload ${pickedFiles.length} files`
}
} else {
label = "Upload"
}
return (
<Button onClick={onClick} disabled={isUploading} loading={isUploading}>
{label}
</Button>
)
}
function UploadFileDropContainer({ children }: React.PropsWithChildren) {
const [draggedFiles, setDraggedFiles] = useState<DataTransferItem[]>([])
const setPickedFiles = useSetAtom(pickedFilesAtom)
function handleDragOver(e: React.DragEvent) {
e.preventDefault()
const items = Array.from(e.dataTransfer.items)
const draggedFiles = []
for (const item of items) {
if (item.kind === "file") {
draggedFiles.push(item)
}
}
setDraggedFiles(draggedFiles)
}
function handleDragLeave() {
setDraggedFiles([])
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
const items = Array.from(e.dataTransfer.items)
const droppedFiles: PickedFile[] = []
for (const item of items) {
const file = item.getAsFile()
if (file) {
droppedFiles.push({
id: nanoid(),
file,
})
}
}
setPickedFiles((prev) => [...prev, ...droppedFiles])
setDraggedFiles([])
}
return (
<section
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
aria-label="File drop area"
className="relative"
>
{children}
{draggedFiles.length > 0 ? (
<div className="border border-accent bg-primary text-primary-foreground absolute inset-0 rounded flex flex-col items-center justify-center text-sm space-y-1">
<FilePlus2Icon className="animate-bounce" />
<p>Drop {draggedFiles.length} files here</p>
</div>
) : null}
</section>
)
}
// tag: uploadfilearea area fileuploadarea
function UploadFileArea({ onClick }: { onClick: () => void }) {
const [pickedFiles, setPickedFiles] = useAtom(pickedFilesAtom)
function removeSelectedFile(file: PickedFile) {
setPickedFiles((prev) => prev.filter((f) => f.id !== file.id))
}
if (pickedFiles.length > 0) {
return (
<PickedFilesList
pickedFiles={pickedFiles}
onRemoveFile={removeSelectedFile}
/>
)
}
return (
<button
type="button"
className="w-full h-48 border-2 rounded border-dashed border-border flex flex-col items-center justify-center text-muted-foreground text-sm space-y-1 hover:bg-muted transition-all hover:border-solid"
onClick={onClick}
>
<UploadCloudIcon />
<span>Click to select files or drag and drop them here</span>
</button>
)
}
function PickedFilesList({
pickedFiles,
onRemoveFile,
}: {
pickedFiles: PickedFile[]
onRemoveFile: (file: PickedFile) => void
}) {
return (
<ul className="min-h-48 border border-border rounded bg-card text-sm">
{pickedFiles.map((file: PickedFile) => (
<PickedFileItem
key={file.id}
file={file}
onRemove={onRemoveFile}
/>
))}
</ul>
)
}
function PickedFileItem({
file: pickedFile,
onRemove,
}: {
file: PickedFile
onRemove: (file: PickedFile) => void
}) {
const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id)
const fileUpload = useAtomValue(fileUploadAtom)
console.log("fileUpload", fileUpload)
const { file, id } = pickedFile
let statusIndicator: React.ReactNode
if (!fileUpload) {
statusIndicator = (
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(pickedFile)}
>
<XIcon className="size-4" />
</Button>
)
} else {
switch (fileUpload.kind) {
case FileUploadStatusKind.InProgress:
statusIndicator = (
<Progress
className="max-w-20"
value={fileUpload.progress * 100}
/>
)
break
case FileUploadStatusKind.Error:
statusIndicator = (
<Tooltip>
<TooltipTrigger>
<CircleAlertIcon className="pr-2 text-destructive" />
</TooltipTrigger>
<TooltipContent>
<p>
Failed to upload file:{" "}
{formatError(fileUpload.error)}
</p>
</TooltipContent>
</Tooltip>
)
break
case FileUploadStatusKind.Success:
statusIndicator = (
<Tooltip>
<TooltipTrigger>
<CircleCheckIcon className="pr-2 text-green-500" />
</TooltipTrigger>
<TooltipContent>
<p>File uploaded</p>
</TooltipContent>
</Tooltip>
)
break
}
}
return (
<li
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center border-b border-border"
key={id}
>
<p>{file.name} </p>
{statusIndicator}
</li>
)
}

View File

@@ -0,0 +1,113 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import * as Err from "@fileone/convex/error"
import {
type DirectoryHandle,
type FileSystemHandle,
isSameHandle,
} from "@fileone/convex/filesystem"
import { useMutation } from "@tanstack/react-query"
import { useMutation as useContextMutation } from "convex/react"
import type { PrimitiveAtom } from "jotai"
import { useSetAtom, useStore } from "jotai"
import { useState } from "react"
import { toast } from "sonner"
export interface FileDragInfo {
source: FileSystemHandle
items: FileSystemHandle[]
}
export interface UseFileDropOptions {
destItem: DirectoryHandle | null
dragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onDropSuccess?: (
items: Id<"files">[],
targetDirectory: Doc<"directories">,
) => void
}
export interface UseFileDropReturn {
isDraggedOver: boolean
dropHandlers: {
onDrop: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDragLeave: () => void
}
}
export function useFileDrop({
destItem,
dragInfoAtom,
}: UseFileDropOptions): UseFileDropReturn {
const [isDraggedOver, setIsDraggedOver] = useState(false)
const setDragInfo = useSetAtom(dragInfoAtom)
const store = useStore()
const { mutate: moveDroppedItems } = useMutation({
mutationFn: useContextMutation(api.filesystem.moveItems),
onSuccess: ({
moved,
errors,
}: {
moved: FileSystemHandle[]
errors: Err.ApplicationErrorData[]
}) => {
const conflictCount = errors.reduce((acc, error) => {
if (error.code === Err.Code.Conflict) {
return acc + 1
}
return acc
}, 0)
if (conflictCount > 0) {
toast.warning(
`${moved.length} items moved${conflictCount > 0 ? `, ${conflictCount} conflicts` : ""}`,
)
} else {
toast.success(`${moved.length} items moved!`)
}
},
})
const handleDrop = (_e: React.DragEvent) => {
const dragInfo = store.get(dragInfoAtom)
console.log("handleDrop", { dragInfo, destItem })
if (dragInfo && destItem) {
const items = dragInfo.items.filter(
(item) => !isSameHandle(item, destItem),
)
if (items.length > 0) {
moveDroppedItems({
targetDirectory: destItem,
items,
})
}
}
setIsDraggedOver(false)
setDragInfo(null)
}
const handleDragOver = (e: React.DragEvent) => {
const dragInfo = store.get(dragInfoAtom)
if (dragInfo && destItem) {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setIsDraggedOver(true)
} else {
e.dataTransfer.dropEffect = "none"
}
}
const handleDragLeave = () => {
setIsDraggedOver(false)
}
return {
isDraggedOver,
dropHandlers: {
onDrop: handleDrop,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
},
}
}

View File

@@ -0,0 +1,57 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import { useMutation as useConvexMutation } from "convex/react"
import { useCallback } from "react"
function useUploadFile({
targetDirectory,
}: {
targetDirectory: Doc<"directories">
}) {
const generateUploadUrl = useConvexMutation(api.files.generateUploadUrl)
const saveFile = useConvexMutation(api.files.saveFile)
async function upload({
file,
onStart,
onProgress,
}: {
file: File
onStart: (xhr: XMLHttpRequest) => void
onProgress: (progress: number) => void
}) {
const uploadUrl = await generateUploadUrl()
return new Promise<{ storageId: Id<"_storage"> }>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener("progress", (e) => {
onProgress(e.loaded / e.total)
})
xhr.upload.addEventListener("error", reject)
xhr.addEventListener("load", () => {
resolve(
xhr.response as {
storageId: Id<"_storage">
},
)
})
xhr.open("POST", uploadUrl)
xhr.responseType = "json"
xhr.setRequestHeader("Content-Type", file.type)
xhr.send(file)
onStart(xhr)
}).then(({ storageId }) =>
saveFile({
storageId,
name: file.name,
size: file.size,
mimeType: file.type,
directoryId: targetDirectory._id,
}),
)
}
return useCallback(upload, [])
}
export default useUploadFile