impl: dir content pagination

This commit is contained in:
2025-12-17 22:59:18 +00:00
parent 5484a08636
commit f2cce889af
12 changed files with 588 additions and 173 deletions

View File

@@ -3,7 +3,6 @@ import type { DirectoryContent, DirectoryInfoWithPath } from "@/vfs/vfs"
type DirectoryPageContextType = {
directory: DirectoryInfoWithPath
directoryContent: DirectoryContent
}
export const DirectoryPageContext = createContext<DirectoryPageContextType>(

View File

@@ -1,4 +1,5 @@
import { Link, useNavigate } from "@tanstack/react-router"
import { useInfiniteQuery } from "@tanstack/react-query"
import { Link, useNavigate, useSearch } from "@tanstack/react-router"
import {
type ColumnDef,
flexRender,
@@ -8,7 +9,7 @@ import {
type Table as TableType,
useReactTable,
} from "@tanstack/react-table"
import { type PrimitiveAtom, useSetAtom, useStore } from "jotai"
import { type PrimitiveAtom, useAtomValue, 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"
@@ -28,12 +29,13 @@ import {
} from "@/lib/keyboard"
import { cn } from "@/lib/utils"
import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs"
import { directoryContentQueryAtom } from "../../vfs/api"
import { DirectoryPageContext } from "./context"
import { DirectoryContentTableSkeleton } from "./directory-content-table-skeleton"
type DirectoryContentTableItemIdFilter = Set<string>
type DirectoryContentTableProps = {
hiddenItems: DirectoryContentTableItemIdFilter
directoryUrlFn: (directory: DirectoryInfo) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: (
@@ -138,26 +140,40 @@ function useTableColumns(
}
export function DirectoryContentTable({
hiddenItems,
directoryUrlFn,
onContextMenu,
fileDragInfoAtom,
onOpenFile,
}: DirectoryContentTableProps) {
const { directoryContent } = useContext(DirectoryPageContext)
const { directory } = useContext(DirectoryPageContext)
const search = useSearch({
from: "/_authenticated/_sidebar-layout/directories/$directoryId",
})
const directoryContentQuery = useAtomValue(
directoryContentQueryAtom({
directoryId: directory.id,
orderBy: search.orderBy,
direction: search.direction,
limit: 100,
}),
)
const { data: directoryContent, isLoading: isLoadingDirectoryContent } =
useInfiniteQuery(directoryContentQuery)
const store = useStore()
const navigate = useNavigate()
const table = useReactTable({
data: directoryContent || [],
data: useMemo(
() => directoryContent?.pages.flatMap((page) => page.items) || [],
[directoryContent],
),
columns: useTableColumns(onOpenFile, directoryUrlFn),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
enableGlobalFilter: true,
state: {
globalFilter: hiddenItems,
},
globalFilterFn: (
row,
_columnId,
@@ -180,6 +196,10 @@ export function DirectoryContentTable({
[table.setRowSelection],
)
if (isLoadingDirectoryContent) {
return <DirectoryContentTableSkeleton />
}
const handleRowContextMenu = (
row: Row<DirectoryItem>,
_event: React.MouseEvent,

View File

@@ -32,3 +32,9 @@ if (import.meta.hot) {
// The hot module reloading API is not available in production.
createRoot(elem).render(app)
}
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import type { Row, Table } from "@tanstack/react-table"
import { type } from "arktype"
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import {
ChevronDownIcon,
@@ -38,8 +39,10 @@ import { FilePreviewDialog } from "@/files/file-preview-dialog"
import { cutItemsAtom, inProgressFileUploadCountAtom } from "@/files/store"
import { UploadFileDialog } from "@/files/upload-file-dialog"
import type { FileDragInfo } from "@/files/use-file-drop"
import { formatError } from "@/lib/error"
import {
directoryContentQueryAtom,
DIRECTORY_CONTENT_ORDER_BY,
DIRECTORY_CONTENT_ORDER_DIRECTION,
directoryInfoQueryAtom,
moveToTrashMutationAtom,
} from "@/vfs/api"
@@ -49,11 +52,20 @@ import type {
DirectoryItem,
FileInfo,
} from "@/vfs/vfs"
import { formatError } from "../../../lib/error"
const DirectoryContentPageParams = type({
orderBy: type
.valueOf(DIRECTORY_CONTENT_ORDER_BY)
.default(DIRECTORY_CONTENT_ORDER_BY.name),
direction: type
.valueOf(DIRECTORY_CONTENT_ORDER_DIRECTION)
.default(DIRECTORY_CONTENT_ORDER_DIRECTION.asc),
})
export const Route = createFileRoute(
"/_authenticated/_sidebar-layout/directories/$directoryId",
)({
validateSearch: DirectoryContentPageParams,
component: RouteComponent,
})
@@ -87,37 +99,54 @@ const itemBeingRenamedAtom = atom<{
// MARK: page entry
function RouteComponent() {
const { directoryId } = Route.useParams()
const {
data: directoryInfo,
isLoading: isLoadingDirectoryInfo,
error: directoryInfoError,
} = useQuery(useAtomValue(directoryInfoQueryAtom(directoryId)))
const {
data: directoryContent,
isLoading: isLoadingDirectoryContent,
error: directoryContentError,
} = useQuery(useAtomValue(directoryContentQueryAtom(directoryId)))
const { data: directoryInfo, isLoading: isLoadingDirectoryInfo } = useQuery(
useAtomValue(directoryInfoQueryAtom(directoryId)),
)
const setOpenedFile = useSetAtom(openedFileAtom)
const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom)
const directoryUrlById = useCallback(
(directoryId: string) => `/directories/${directoryId}`,
[],
)
console.log({ directoryInfoError, directoryContentError })
const onTableOpenFile = useCallback(
(file: FileInfo) => {
setOpenedFile(file)
},
[setOpenedFile],
)
if (isLoadingDirectoryInfo || isLoadingDirectoryContent) {
const directoryUrlFn = useCallback(
(directory: DirectoryInfo) => `/directories/${directory.id}`,
[],
)
const handleContextMenuRequest = useCallback(
(row: Row<DirectoryItem>, table: Table<DirectoryItem>) => {
if (row.getIsSelected()) {
setContextMenuTargetItems(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
setContextMenuTargetItems([row.original])
}
},
[setContextMenuTargetItems],
)
if (isLoadingDirectoryInfo) {
return <DirectoryPageSkeleton />
}
if (!directoryInfo || !directoryContent) {
if (!directoryInfo) {
// TODO: handle empty state/error
return null
}
return (
<DirectoryPageContext
value={{ directory: directoryInfo, directoryContent }}
>
<DirectoryPageContext value={{ directory: directoryInfo }}>
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
<DirectoryPathBreadcrumb
directory={directoryInfo}
@@ -134,7 +163,12 @@ function RouteComponent() {
{/* DirectoryContentContextMenu must wrap div instead of DirectoryContentTable, otherwise radix will throw "event.preventDefault is not a function" error, idk why */}
<DirectoryContentContextMenu>
<div className="w-full">
<_DirectoryContentTable />
<DirectoryContentTable
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={onTableOpenFile}
/>
</div>
</DirectoryContentContextMenu>
@@ -191,46 +225,6 @@ function RouteComponent() {
)
}
// MARK: directory table
function _DirectoryContentTable() {
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)
const setOpenedFile = useSetAtom(openedFileAtom)
const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom)
const onTableOpenFile = (file: FileInfo) => {
setOpenedFile(file)
}
const directoryUrlFn = useCallback(
(directory: DirectoryInfo) => `/directories/${directory.id}`,
[],
)
const handleContextMenuRequest = (
row: Row<DirectoryItem>,
table: Table<DirectoryItem>,
) => {
if (row.getIsSelected()) {
setContextMenuTargetItems(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
setContextMenuTargetItems([row.original])
}
}
return (
<DirectoryContentTable
hiddenItems={optimisticDeletedItems}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={onTableOpenFile}
/>
)
}
// ==================================
// MARK: ctx menu

View File

@@ -1,6 +1,5 @@
import { useMutation } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useSetAtom } from "jotai"
import { GalleryVerticalEnd } from "lucide-react"
import { loginMutation } from "@/auth/api"
import { Button } from "@/components/ui/button"
@@ -20,7 +19,6 @@ import {
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { currentAccountAtom } from "../account/account"
export const Route = createFileRoute("/login")({
component: RouteComponent,

View File

@@ -1,4 +1,10 @@
import { mutationOptions, queryOptions, skipToken } from "@tanstack/react-query"
import {
type InfiniteData,
infiniteQueryOptions,
mutationOptions,
queryOptions,
skipToken,
} from "@tanstack/react-query"
import { type } from "arktype"
import { atom } from "jotai"
import { atomFamily } from "jotai/utils"
@@ -12,6 +18,11 @@ import {
FileInfo,
} from "./vfs"
const DirectoryContentResponse = type({
items: DirectoryContent,
"nextCursor?": "string",
})
/**
* This atom derives the file url for a given file.
* It is recommended to use {@link useFileUrl} instead of using this atom directly.
@@ -58,27 +69,63 @@ export const directoryInfoQueryAtom = atomFamily((directoryId: string) =>
}),
)
export const directoryContentQueryAtom = atomFamily((directoryId: string) =>
atom((get) => {
const account = get(currentAccountAtom)
return queryOptions({
queryKey: [
"accounts",
account?.id,
"directories",
directoryId,
"content",
],
queryFn: account
? () =>
fetchApi(
"GET",
`/accounts/${account.id}/directories/${directoryId}/content`,
{ returns: DirectoryContent },
).then(([_, result]) => result)
: skipToken,
})
}),
export const DIRECTORY_CONTENT_ORDER_BY = {
name: "name",
createdAt: "createdAt",
updatedAt: "updatedAt",
} as const
export type DirectoryContentOrderBy =
(typeof DIRECTORY_CONTENT_ORDER_BY)[keyof typeof DIRECTORY_CONTENT_ORDER_BY]
export const DIRECTORY_CONTENT_ORDER_DIRECTION = {
asc: "asc",
desc: "desc",
} as const
type DirectoryContentOrderDirection =
(typeof DIRECTORY_CONTENT_ORDER_DIRECTION)[keyof typeof DIRECTORY_CONTENT_ORDER_DIRECTION]
type DirectoryContentQueryParams = {
directoryId: string
orderBy: DirectoryContentOrderBy
direction: DirectoryContentOrderDirection
limit: number
}
const directoryContentQueryKey = (
accountId: string | undefined,
directoryId: string,
) => ["accounts", accountId, "directories", directoryId, "content"]
export const directoryContentQueryAtom = atomFamily(
({ directoryId, orderBy, direction, limit }: DirectoryContentQueryParams) =>
atom((get) => {
const account = get(currentAccountAtom)
return infiniteQueryOptions({
queryKey: directoryContentQueryKey(account?.id, directoryId),
initialPageParam: {
orderBy,
direction,
limit,
cursor: "",
},
queryFn: ({ pageParam }) =>
account
? fetchApi(
"GET",
`/accounts/${account.id}/directories/${directoryId}/content?orderBy=${pageParam.orderBy}&dir=${pageParam.direction}&limit=${pageParam.limit}${pageParam.cursor ? `&cursor=${pageParam.cursor}` : ""}`,
{ returns: DirectoryContentResponse },
).then(([_, result]) => result)
: Promise.reject(new Error("No account selected")),
getNextPageParam: (lastPage, _pages, lastPageParam) => ({
...lastPageParam,
cursor: lastPage.nextCursor ?? "",
}),
})
}),
(paramsA, paramsB) =>
paramsA.directoryId === paramsB.directoryId &&
paramsA.orderBy === paramsB.orderBy &&
paramsA.direction === paramsB.direction &&
paramsA.limit === paramsB.limit,
)
export const createDirectoryMutationAtom = atom((get) => {
@@ -103,13 +150,6 @@ export const createDirectoryMutationAtom = atom((get) => {
get(directoryInfoQueryAtom(data.id)).queryKey,
data,
)
const parent = data.path.at(-2)
if (parent) {
client.setQueryData(
get(directoryContentQueryAtom(parent.id)).queryKey,
(prev) => (prev ? [...prev, data] : [data]),
)
}
},
})
})
@@ -157,13 +197,18 @@ export const moveDirectoryItemsMutationAtom = atom((get) =>
return result
},
onMutate: ({ items }, { client }) => {
const account = get(currentAccountAtom)
if (!account) {
return null
}
const movedItems = new Map<string, Set<string>>()
for (const item of items) {
if (item.parentId) {
const s = movedItems.get(item.parentId)
if (!s) {
movedItems.set(item.parentId, new Set(s))
movedItems.set(item.parentId, new Set([item.id]))
} else {
s.add(item.id)
}
@@ -172,45 +217,67 @@ export const moveDirectoryItemsMutationAtom = atom((get) =>
const prevDirContentMap = new Map<
string,
DirectoryItem[] | undefined
InfiniteData<typeof DirectoryContentResponse.infer> | undefined
>()
movedItems.forEach((s, parentId) => {
const query = get(directoryContentQueryAtom(parentId))
const prevDirContent = client.getQueryData(query.queryKey)
client.setQueryData(
query.queryKey,
(prev) => prev?.filter((it) => !s.has(it.id)) ?? prev,
)
const key = directoryContentQueryKey(account.id, parentId)
const prevDirContent =
client.getQueryData<
InfiniteData<typeof DirectoryContentResponse.infer>
>(key)
client.setQueryData<
InfiniteData<typeof DirectoryContentResponse.infer>
>(key, (prev) => {
if (!prev) return prev
return {
...prev,
pages: prev.pages.map((page) => ({
...page,
items: page.items.filter((it) => !s.has(it.id)),
})),
}
})
prevDirContentMap.set(parentId, prevDirContent)
})
return { prevDirContentMap }
},
onSuccess: (_data, { targetDirectory, items }, _result, { client }) => {
const account = get(currentAccountAtom)
if (!account) return
const dirId =
typeof targetDirectory === "string"
? targetDirectory
: targetDirectory.id
client.invalidateQueries(get(directoryContentQueryAtom(dirId)))
client.invalidateQueries({
queryKey: directoryContentQueryKey(account.id, dirId),
})
for (const item of items) {
if (item.parentId) {
client.invalidateQueries(
get(directoryContentQueryAtom(item.parentId)),
)
client.invalidateQueries({
queryKey: directoryContentQueryKey(
account.id,
item.parentId,
),
})
}
}
},
onError: (_error, _vars, context, { client }) => {
if (context) {
context.prevDirContentMap.forEach(
(prevDirContent, parentId) => {
client.setQueryData(
get(directoryContentQueryAtom(parentId)).queryKey,
prevDirContent,
)
},
)
const account = get(currentAccountAtom)
if (account) {
context.prevDirContentMap.forEach(
(prevDirContent, parentId) => {
client.setQueryData(
directoryContentQueryKey(account.id, parentId),
prevDirContent,
)
},
)
}
}
},
}),
@@ -277,12 +344,17 @@ export const moveToTrashMutationAtom = atom((get) =>
return [...deletedFiles, ...deletedDirectories]
},
onMutate: (items, { client }) => {
const account = get(currentAccountAtom)
if (!account) {
return null
}
const trashedItems = new Map<string, Set<string>>()
for (const item of items) {
if (item.parentId) {
const s = trashedItems.get(item.parentId)
if (!s) {
trashedItems.set(item.parentId, new Set(s))
trashedItems.set(item.parentId, new Set([item.id]))
} else {
s.add(item.id)
}
@@ -291,38 +363,58 @@ export const moveToTrashMutationAtom = atom((get) =>
const prevDirContentMap = new Map<
string,
DirectoryItem[] | undefined
InfiniteData<typeof DirectoryContentResponse.infer> | undefined
>()
trashedItems.forEach((s, parentId) => {
const query = get(directoryContentQueryAtom(parentId))
const prevDirContent = client.getQueryData(query.queryKey)
client.setQueryData(
query.queryKey,
(prev) => prev?.filter((it) => !s.has(it.id)) ?? prev,
)
const key = directoryContentQueryKey(account.id, parentId)
const prevDirContent =
client.getQueryData<
InfiniteData<typeof DirectoryContentResponse.infer>
>(key)
client.setQueryData<
InfiniteData<typeof DirectoryContentResponse.infer>
>(key, (prev) => {
if (!prev) return prev
return {
...prev,
pages: prev.pages.map((page) => ({
...page,
items: page.items.filter((it) => !s.has(it.id)),
})),
}
})
prevDirContentMap.set(parentId, prevDirContent)
})
return { prevDirContentMap }
},
onSuccess: (_data, items, _result, { client }) => {
for (const item of items) {
if (item.parentId) {
client.invalidateQueries(
get(directoryContentQueryAtom(item.parentId)),
)
const account = get(currentAccountAtom)
if (account) {
for (const item of items) {
if (item.parentId) {
client.invalidateQueries({
queryKey: directoryContentQueryKey(
account.id,
item.parentId,
),
})
}
}
}
},
onError: (_error, items, context, { client }) => {
onError: (_error, _items, context, { client }) => {
if (context) {
context.prevDirContentMap.forEach(
(prevDirContent, parentId) => {
client.setQueryData(
get(directoryContentQueryAtom(parentId)).queryKey,
prevDirContent,
)
},
)
const account = get(currentAccountAtom)
if (account) {
context.prevDirContentMap.forEach(
(prevDirContent, parentId) => {
client.setQueryData(
directoryContentQueryKey(account.id, parentId),
prevDirContent,
)
},
)
}
}
},
}),