refactor: initial frontend wiring for new api

This commit is contained in:
2025-12-15 00:13:10 +00:00
parent 528aa943fa
commit 05edf69ca7
63 changed files with 1876 additions and 1991 deletions

View File

@@ -1,12 +1,9 @@
import type { Doc } from "@fileone/convex/dataModel"
import type { FileSystemItem } from "@fileone/convex/filesystem"
import type { DirectoryInfo } from "@fileone/convex/types"
import { createContext } from "react"
import type { DirectoryContent, DirectoryInfoWithPath } from "@/vfs/vfs"
type DirectoryPageContextType = {
rootDirectory: Doc<"directories">
directory: DirectoryInfo
directoryContent: FileSystemItem[]
directory: DirectoryInfoWithPath
directoryContent: DirectoryContent
}
export const DirectoryPageContext = createContext<DirectoryPageContextType>(

View File

@@ -1,116 +0,0 @@
import { api } from "@fileone/convex/api"
import { newFileSystemHandle } from "@fileone/convex/filesystem"
import { useMutation } from "@tanstack/react-query"
import { useMutation as useContextMutation } from "convex/react"
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { TextCursorInputIcon, TrashIcon } from "lucide-react"
import { toast } from "sonner"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
contextMenuTargeItemsAtom,
itemBeingRenamedAtom,
optimisticDeletedItemsAtom,
} from "./state"
export function DirectoryContentContextMenu({
children,
}: {
children: React.ReactNode
}) {
const store = useStore()
const [target, setTarget] = useAtom(contextMenuTargeItemsAtom)
const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom)
const moveToTrashMutation = useContextMutation(api.filesystem.moveToTrash)
const { mutate: moveToTrash } = useMutation({
mutationFn: moveToTrashMutation,
onMutate: ({ handles }) => {
setOptimisticDeletedItems(
(prev) =>
new Set([...prev, ...handles.map((handle) => handle.id)]),
)
},
onSuccess: ({ deleted, errors }, { handles }) => {
setOptimisticDeletedItems((prev) => {
const newSet = new Set(prev)
for (const handle of handles) {
newSet.delete(handle.id)
}
return newSet
})
if (errors.length === 0 && deleted.length === handles.length) {
toast.success(`Moved ${handles.length} items to trash`)
} else if (errors.length === handles.length) {
toast.error("Failed to move to trash")
} else {
toast.info(
`Moved ${deleted.length} items to trash; failed to move ${errors.length} items`,
)
}
},
})
const handleDelete = () => {
const selectedItems = store.get(contextMenuTargeItemsAtom)
if (selectedItems.length > 0) {
moveToTrash({
handles: selectedItems.map(newFileSystemHandle),
})
}
}
return (
<ContextMenu
onOpenChange={(open) => {
if (!open) {
setTarget([])
}
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
{target && (
<ContextMenuContent>
<RenameMenuItem />
<ContextMenuItem onClick={handleDelete}>
<TrashIcon />
Move to trash
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
)
}
function RenameMenuItem() {
const store = useStore()
const target = useAtomValue(contextMenuTargeItemsAtom)
const setItemBeingRenamed = useSetAtom(itemBeingRenamedAtom)
const handleRename = () => {
const selectedItems = store.get(contextMenuTargeItemsAtom)
if (selectedItems.length === 1) {
// biome-ignore lint/style/noNonNullAssertion: length is checked
const selectedItem = selectedItems[0]!
setItemBeingRenamed({
originalItem: selectedItem,
name: selectedItem.doc.name,
})
}
}
// Only render if exactly one item is selected
if (target.length !== 1) {
return null
}
return (
<ContextMenuItem onClick={handleRename}>
<TextCursorInputIcon />
Rename
</ContextMenuItem>
)
}

View File

@@ -1,15 +1,3 @@
import type { Doc } from "@fileone/convex/dataModel"
import {
type DirectoryHandle,
type FileHandle,
type FileSystemHandle,
type FileSystemItem,
FileType,
isSameHandle,
newDirectoryHandle,
newFileHandle,
newFileSystemHandle,
} from "@fileone/convex/filesystem"
import { Link, useNavigate } from "@tanstack/react-router"
import {
type ColumnDef,
@@ -23,6 +11,7 @@ import {
import { type PrimitiveAtom, useSetAtom, useStore } from "jotai"
import { useContext, useEffect, useMemo, useRef } from "react"
import { DirectoryIcon } from "@/components/icons/directory-icon"
import { TextFileIcon } from "@/components/icons/text-file-icon"
import { Checkbox } from "@/components/ui/checkbox"
import {
Table,
@@ -32,26 +21,26 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { type FileDragInfo, useFileDrop } from "@/files/use-file-drop"
import {
isControlOrCommandKeyActive,
keyboardModifierAtom,
} from "@/lib/keyboard"
import { TextFileIcon } from "../../components/icons/text-file-icon"
import { type FileDragInfo, useFileDrop } from "../../files/use-file-drop"
import { cn } from "../../lib/utils"
import { cn } from "@/lib/utils"
import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs"
import { DirectoryPageContext } from "./context"
type DirectoryContentTableItemIdFilter = Set<FileSystemItem["doc"]["_id"]>
type DirectoryContentTableItemIdFilter = Set<string>
type DirectoryContentTableProps = {
hiddenItems: DirectoryContentTableItemIdFilter
directoryUrlFn: (directory: Doc<"directories">) => string
directoryUrlFn: (directory: DirectoryInfo) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: (
row: Row<FileSystemItem>,
table: TableType<FileSystemItem>,
row: Row<DirectoryItem>,
table: TableType<DirectoryItem>,
) => void
onOpenFile: (file: Doc<"files">) => void
onOpenFile: (file: FileInfo) => void
}
function formatFileSize(bytes: number): string {
@@ -65,9 +54,9 @@ function formatFileSize(bytes: number): string {
}
function useTableColumns(
onOpenFile: (file: Doc<"files">) => void,
directoryUrlFn: (directory: Doc<"directories">) => string,
): ColumnDef<FileSystemItem>[] {
onOpenFile: (file: FileInfo) => void,
directoryUrlFn: (directory: DirectoryInfo) => string,
): ColumnDef<DirectoryItem>[] {
return useMemo(
() => [
{
@@ -100,17 +89,17 @@ function useTableColumns(
accessorKey: "doc.name",
cell: ({ row }) => {
switch (row.original.kind) {
case FileType.File:
case "file":
return (
<FileNameCell
file={row.original.doc}
file={row.original}
onOpenFile={onOpenFile}
/>
)
case FileType.Directory:
case "directory":
return (
<DirectoryNameCell
directory={row.original.doc}
directory={row.original}
directoryUrlFn={directoryUrlFn}
/>
)
@@ -123,13 +112,11 @@ function useTableColumns(
accessorKey: "size",
cell: ({ row }) => {
switch (row.original.kind) {
case FileType.File:
case "file":
return (
<div>
{formatFileSize(row.original.doc.size)}
</div>
<div>{formatFileSize(row.original.size)}</div>
)
case FileType.Directory:
case "directory":
return <div className="font-mono">-</div>
}
},
@@ -140,9 +127,7 @@ function useTableColumns(
cell: ({ row }) => {
return (
<div>
{new Date(
row.original.doc.createdAt,
).toLocaleString()}
{new Date(row.original.createdAt).toLocaleString()}
</div>
)
},
@@ -178,8 +163,8 @@ export function DirectoryContentTable({
_columnId,
filterValue: DirectoryContentTableItemIdFilter,
_addMeta,
) => !filterValue.has(row.original.doc._id),
getRowId: (row) => row.doc._id,
) => !filterValue.has(row.original.id),
getRowId: (row) => row.id,
})
useEffect(
@@ -196,7 +181,7 @@ export function DirectoryContentTable({
)
const handleRowContextMenu = (
row: Row<FileSystemItem>,
row: Row<DirectoryItem>,
_event: React.MouseEvent,
) => {
if (!row.getIsSelected()) {
@@ -205,7 +190,7 @@ export function DirectoryContentTable({
onContextMenu(row, table)
}
const selectRow = (row: Row<FileSystemItem>) => {
const selectRow = (row: Row<DirectoryItem>) => {
const keyboardModifiers = store.get(keyboardModifierAtom)
const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers)
const isRowSelected = row.getIsSelected()
@@ -227,10 +212,10 @@ export function DirectoryContentTable({
}
}
const handleRowDoubleClick = (row: Row<FileSystemItem>) => {
if (row.original.kind === FileType.Directory) {
const handleRowDoubleClick = (row: Row<DirectoryItem>) => {
if (row.original.kind === "directory") {
navigate({
to: `/directories/${row.original.doc._id}`,
to: `/directories/${row.original.id}`,
})
}
}
@@ -302,8 +287,8 @@ function FileItemRow({
onDoubleClick,
fileDragInfoAtom,
}: {
table: TableType<FileSystemItem>
row: Row<FileSystemItem>
table: TableType<DirectoryItem>
row: Row<DirectoryItem>
onClick: () => void
onContextMenu: (e: React.MouseEvent) => void
onDoubleClick: () => void
@@ -313,39 +298,24 @@ function FileItemRow({
const setFileDragInfo = useSetAtom(fileDragInfoAtom)
const { isDraggedOver, dropHandlers } = useFileDrop({
destItem:
row.original.kind === FileType.Directory
? newDirectoryHandle(row.original.doc._id)
: null,
destDir: row.original,
dragInfoAtom: fileDragInfoAtom,
})
const handleDragStart = (_e: React.DragEvent) => {
let source: DirectoryHandle | FileHandle
switch (row.original.kind) {
case FileType.File:
source = newFileHandle(row.original.doc._id)
break
case FileType.Directory:
source = newDirectoryHandle(row.original.doc._id)
break
}
let draggedItems: FileSystemHandle[]
let draggedItems: DirectoryItem[]
// drag all selections, but only if the currently dragged row is also selected
if (row.getIsSelected()) {
draggedItems = table
.getSelectedRowModel()
.rows.map((row) => newFileSystemHandle(row.original))
if (!draggedItems.some((item) => isSameHandle(item, source))) {
draggedItems.push(source)
draggedItems = [...table.getSelectedRowModel().rows]
if (!draggedItems.some((item) => item.id === row.original.id)) {
draggedItems.push(row.original)
}
} else {
draggedItems = [source]
draggedItems = [row.original]
}
setFileDragInfo({
source,
source: row.original,
items: draggedItems,
})
}
@@ -385,8 +355,8 @@ function DirectoryNameCell({
directory,
directoryUrlFn,
}: {
directory: Doc<"directories">
directoryUrlFn: (directory: Doc<"directories">) => string
directory: DirectoryInfo
directoryUrlFn: (directory: DirectoryInfo) => string
}) {
return (
<div className="flex w-full items-center gap-2">
@@ -402,8 +372,8 @@ function FileNameCell({
file,
onOpenFile,
}: {
file: Doc<"files">
onOpenFile: (file: Doc<"files">) => void
file: FileInfo
onOpenFile: (file: FileInfo) => void
}) {
return (
<div className="flex w-full items-center gap-2">

View File

@@ -1,7 +1,5 @@
import { api } from "@fileone/convex/api"
import type { Id } from "@fileone/convex/dataModel"
import { useMutation } from "@tanstack/react-query"
import { useMutation as useContextMutation } from "convex/react"
import { useAtomValue } from "jotai"
import { useId } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
@@ -14,21 +12,26 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { createDirectoryMutationAtom } from "@/vfs/api"
import type { DirectoryInfo } from "@/vfs/vfs"
export function NewDirectoryDialog({
open,
onOpenChange,
directoryId,
parentDirectory,
}: {
open: boolean
onOpenChange: (open: boolean) => void
directoryId: Id<"directories">
parentDirectory: DirectoryInfo
}) {
const formId = useId()
const createDirectoryMutation = useAtomValue(createDirectoryMutationAtom)
const { mutate: createDirectory, isPending: isCreating } = useMutation({
mutationFn: useContextMutation(api.files.createDirectory),
onSuccess: () => {
...createDirectoryMutation,
onSuccess: (data, vars, result, context) => {
createDirectoryMutation.onSuccess?.(data, vars, result, context)
onOpenChange(false)
toast.success("Directory created successfully")
},
@@ -41,7 +44,7 @@ export function NewDirectoryDialog({
const name = formData.get("directoryName") as string
if (name) {
createDirectory({ name, directoryId })
createDirectory({ name, parentId: parentDirectory.id })
}
}

View File

@@ -1,7 +1,5 @@
import { api } from "@fileone/convex/api"
import { type FileSystemItem, FileType } from "@fileone/convex/filesystem"
import { useMutation } from "@tanstack/react-query"
import { useMutation as useContextMutation } from "convex/react"
import { useAtomValue } from "jotai"
import { useId } from "react"
import { Button } from "@/components/ui/button"
import {
@@ -13,9 +11,11 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { renameDirectoryMutationAtom, renameFileMutationAtom } from "@/vfs/api"
import type { DirectoryItem } from "@/vfs/vfs"
type RenameFileDialogProps = {
item: FileSystemItem
item: DirectoryItem
onRenameSuccess: () => void
onClose: () => void
}
@@ -27,13 +27,22 @@ export function RenameFileDialog({
}: RenameFileDialogProps) {
const formId = useId()
const { mutate: renameFile, isPending: isRenaming } = useMutation({
mutationFn: useContextMutation(api.files.renameFile),
onSuccess: () => {
onRenameSuccess()
},
const renameFileMutation = useAtomValue(renameFileMutationAtom)
const renameDirectoryMutation = useAtomValue(renameDirectoryMutationAtom)
const { mutate: renameFile, isPending: isRenamingFile } = useMutation({
...renameFileMutation,
onSuccess: onRenameSuccess,
})
const { mutate: renameDirectory, isPending: isRenamingDirectory } =
useMutation({
...renameDirectoryMutation,
onSuccess: onRenameSuccess,
})
const isRenaming = isRenamingFile || isRenamingDirectory
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
@@ -42,14 +51,11 @@ export function RenameFileDialog({
if (newName) {
switch (item.kind) {
case FileType.File:
renameFile({
directoryId: item.doc.directoryId,
itemId: item.doc._id,
newName,
})
case "file":
renameFile(item)
break
default:
case "directory":
renameDirectory(item)
break
}
}
@@ -70,7 +76,7 @@ export function RenameFileDialog({
</DialogHeader>
<form id={formId} onSubmit={onSubmit}>
<RenameFileInput initialValue={item.doc.name} />
<RenameFileInput initialValue={item.name} />
</form>
<DialogFooter>

View File

@@ -1,21 +0,0 @@
import type { Doc, Id } from "@fileone/convex/dataModel"
import type { FileSystemItem } from "@fileone/convex/filesystem"
import type { RowSelectionState } from "@tanstack/react-table"
import { atom } from "jotai"
import type { FileDragInfo } from "../../files/use-file-drop"
export const contextMenuTargeItemsAtom = atom<FileSystemItem[]>([])
export const optimisticDeletedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(),
)
export const selectedFileRowsAtom = atom<RowSelectionState>({})
export const itemBeingRenamedAtom = atom<{
originalItem: FileSystemItem
name: string
} | null>(null)
export const openedFileAtom = atom<Doc<"files"> | null>(null)
export const dragInfoAtom = atom<FileDragInfo | null>(null)

View File

@@ -1,9 +1,3 @@
import type { Id } from "@fileone/convex/dataModel"
import type {
DirectoryHandle,
DirectoryPathComponent,
} from "@fileone/convex/filesystem"
import type { DirectoryInfo } from "@fileone/convex/types"
import { Link } from "@tanstack/react-router"
import type { PrimitiveAtom } from "jotai"
import { atom } from "jotai"
@@ -24,6 +18,8 @@ import {
import type { FileDragInfo } from "@/files/use-file-drop"
import { useFileDrop } from "@/files/use-file-drop"
import { cn } from "@/lib/utils"
import type { DirectoryInfoWithPath } from "@/vfs/vfs"
import type { PathSegment } from "../lib/path"
/**
* This is a placeholder file drag info atom that always stores null and is never mutated.
@@ -36,15 +32,28 @@ export function DirectoryPathBreadcrumb({
directoryUrlFn,
fileDragInfoAtom = nullFileDragInfoAtom,
}: {
directory: DirectoryInfo
directory: DirectoryInfoWithPath
rootLabel: string
directoryUrlFn: (directory: Id<"directories">) => string
directoryUrlFn: (directoryId: string) => string
fileDragInfoAtom?: PrimitiveAtom<FileDragInfo | null>
}) {
if (directory.path.length === 1) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>{rootLabel}</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator />
</BreadcrumbList>
</Breadcrumb>
)
}
const breadcrumbItems: React.ReactNode[] = [
<FilePathBreadcrumbItem
key={directory.path[0].handle.id}
component={directory.path[0]}
key={directory.path[0].id}
segment={directory.path[0]}
rootLabel={rootLabel}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
@@ -52,10 +61,10 @@ export function DirectoryPathBreadcrumb({
]
for (let i = 1; i < directory.path.length - 1; i++) {
breadcrumbItems.push(
<Fragment key={directory.path[i]?.handle.id}>
<Fragment key={directory.path[i]!.id}>
<BreadcrumbSeparator />
<FilePathBreadcrumbItem
component={directory.path[i]!}
segment={directory.path[i]!}
rootLabel={rootLabel}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
@@ -78,22 +87,22 @@ export function DirectoryPathBreadcrumb({
}
function FilePathBreadcrumbItem({
component,
segment,
rootLabel,
directoryUrlFn,
fileDragInfoAtom,
}: {
component: DirectoryPathComponent
segment: PathSegment
rootLabel: string
directoryUrlFn: (directory: Id<"directories">) => string
directoryUrlFn: (directoryId: string) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
}) {
const { isDraggedOver, dropHandlers } = useFileDrop({
destItem: component.handle as DirectoryHandle,
destDir: segment.id,
dragInfoAtom: fileDragInfoAtom,
})
const dirName = component.name || rootLabel
const dirName = segment.name || rootLabel
return (
<Tooltip open={isDraggedOver}>
@@ -103,9 +112,7 @@ function FilePathBreadcrumbItem({
{...dropHandlers}
>
<BreadcrumbLink asChild>
<Link to={directoryUrlFn(component.handle.id)}>
{dirName}
</Link>
<Link to={directoryUrlFn(segment.id)}>{dirName}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
</TooltipTrigger>