diff --git a/src/client/pages/folder/[id]/index.tsx b/src/client/pages/folder/[id]/index.tsx index 8d12adaf..f40c678d 100644 --- a/src/client/pages/folder/[id]/index.tsx +++ b/src/client/pages/folder/[id]/index.tsx @@ -1,3 +1,4 @@ +import { useApiPagination } from '@/components/pages/files/useApiPagination'; import { type Response } from '@/lib/api/response'; import { useQueryState } from '@/lib/client/hooks/useQueryState'; import { useTitle } from '@/lib/client/hooks/useTitle'; @@ -20,20 +21,26 @@ import { Title, } from '@mantine/core'; import { IconFolder, IconUpload } from '@tabler/icons-react'; -import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; -import { Link, Params, useLoaderData, useNavigate } from 'react-router-dom'; +import { lazy, Suspense, useEffect, useMemo } from 'react'; +import { Link, Params, useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'; import { useShallow } from 'zustand/shallow'; const DashboardFile = lazy(() => import('@/components/file/DashboardFile')); const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal')); -export async function loader({ params }: { params: Params }) { - const res = await fetch(`/api/server/folder/${params.id}`); +export async function loader({ params, request }: { params: Params; request: Request }) { + const url = new URL(request.url); + const page = url.searchParams.get('page') ?? '1'; + const perpage = url.searchParams.get('perpage') ?? '15'; + + const res = await fetch( + `/api/server/folder/${params.id}?page=${encodeURIComponent(page)}&perpage=${encodeURIComponent(perpage)}`, + ); if (!res.ok) { throw new Response('Folder not found', { status: 404 }); } return { - folder: (await res.json()) as Response['/api/server/folder/[id]'], + initial: (await res.json()) as Response['/api/server/folder/[id]'], }; } @@ -67,10 +74,30 @@ function PublicFolderCard({ folder }: { folder: Partial }) { const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45]; export function Component() { - const { folder } = useLoaderData(); + const { initial } = useLoaderData(); const navigate = useNavigate(); - useTitle(folder.name); + const [, setSearchParams] = useSearchParams(); + const [page, setPage] = useQueryState('page', 1); + const [perpage] = useQueryState('perpage', 15); + + const { data, isLoading } = useApiPagination( + { + route: `/api/server/folder/${initial.folder.id}`, + page, + perpage, + sort: 'createdAt', + order: 'desc', + }, + { fallbackData: initial, keepPreviousData: true, revalidateOnFocus: false }, + ); + + const folder = data?.folder ?? initial.folder; + const files = data?.page ?? []; + const totalRecords = data?.total ?? 0; + const cachedPages = data?.pages ?? 0; + + useTitle(folder.name ?? 'Folder'); const buildBreadcrumbs = () => { const items: FolderBreadcrumb[] = []; @@ -88,27 +115,14 @@ export function Component() { const breadcrumbs = buildBreadcrumbs(); const children = (folder.children ?? []) as Partial[]; - - const [perpage, setPerpage] = useState(15); - const [page, setPage] = useQueryState('page', 1); - - const from = (page - 1) * perpage + 1; - const to = Math.min(page * perpage, folder.files?.length ?? 0); - const totalRecords = folder.files?.length ?? 0; - const cachedPages = Math.ceil(totalRecords / perpage); - - const visible = useMemo(() => { - if (!folder.files) return []; - - const start = (page - 1) * perpage; - return folder.files.slice(start, start + perpage); - }, [folder.files, page, perpage]); + const from = totalRecords === 0 ? 0 : (page - 1) * perpage + 1; + const to = Math.min(page * perpage, totalRecords); const [current, setCurrent, setFiles] = useFileNavStore( useShallow((state) => [state.current, state.setCurrent, state.setFiles]), ); - const currentFile = current ? (visible.find((file) => file.id === current) ?? null) : null; - const ids = useMemo(() => visible.map((file) => file.id), [visible]); + const currentFile = current ? (files.find((file) => file.id === current) ?? null) : null; + const ids = useMemo(() => files.map((file) => file.id), [files]); useEffect(() => { setFiles(ids); @@ -173,7 +187,7 @@ export function Component() { )} - {(visible.length ?? 0) > 0 && ( + {(files.length ?? 0) > 0 && ( <> Files @@ -186,7 +200,7 @@ export function Component() { }} spacing='md' > - {visible.map((file: any) => ( + {files.map((file: any) => ( <Suspense fallback={<Skeleton height={350} animate />} key={file.id}> <DashboardFile file={file} reduce onOpen={(fileId) => setCurrent(fileId)} /> </Suspense> @@ -195,7 +209,7 @@ export function Component() { </> )} - {children.length === 0 && (folder.files?.length ?? 0) === 0 && ( + {children.length === 0 && totalRecords === 0 && ( <Text c='dimmed' mt='md'> This folder is empty. </Text> @@ -209,12 +223,16 @@ export function Component() { value={perpage.toString()} data={PER_PAGE_OPTIONS.map((val) => ({ value: val.toString(), label: `${val}` }))} onChange={(value) => { - setPerpage(Number(value)); - setPage(1); + setSearchParams((prev) => { + prev.set('perpage', value ?? '15'); + prev.set('page', '1'); + return prev; + }); }} w={80} size='xs' variant='filled' + disabled={isLoading} /> <Pagination @@ -224,6 +242,7 @@ export function Component() { size='sm' withControls withEdges + disabled={isLoading} /> </Group> </Group> diff --git a/src/components/pages/files/useApiPagination.tsx b/src/components/pages/files/useApiPagination.tsx index 2a875214..2622820f 100644 --- a/src/components/pages/files/useApiPagination.tsx +++ b/src/components/pages/files/useApiPagination.tsx @@ -1,7 +1,8 @@ -import { Response } from '@/lib/api/response'; +import type { Response } from '@/lib/api/response'; import useSWR from 'swr'; type ApiPaginationOptions = { + route?: string; page?: number; filter?: string; perpage?: number; @@ -26,14 +27,15 @@ type ApiPaginationOptions = { }; }; -const fetcher = async ( +const fetcher = async <T,>( { options }: { options: ApiPaginationOptions; key: string } = { options: { page: 1, }, key: '/api/user/files', }, -): Promise<Response['/api/user/files']> => { +): Promise<T> => { + const route = options.route ?? '/api/user/files'; const searchParams = new URLSearchParams(); if (options.page) searchParams.append('page', options.page.toString()); if (options.filter) searchParams.append('filter', options.filter); @@ -48,7 +50,7 @@ const fetcher = async ( } if (options.folderId) searchParams.append('folder', options.folderId); - const res = await fetch(`/api/user/files${searchParams.toString() ? `?${searchParams.toString()}` : ''}`); + const res = await fetch(`${route}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`); if (!res.ok) { const json = await res.json(); @@ -59,14 +61,18 @@ const fetcher = async ( return res.json(); }; -export function useApiPagination( +export function useApiPagination<T = Response['/api/user/files']>( options: ApiPaginationOptions = { page: 1, }, + swrConfig?: Parameters<typeof useSWR<T>>[2], ) { - const { data, error, isLoading, mutate } = useSWR<Response['/api/user/files']>( - { key: '/api/user/files', options }, - { fetcher }, + const { data, error, isLoading, mutate } = useSWR<T>( + { key: options.route ?? '/api/user/files', options }, + { + fetcher: (k) => fetcher<T>(k), + ...swrConfig, + }, ); return { @@ -76,3 +82,4 @@ export function useApiPagination( mutate, }; } + diff --git a/src/server/routes/api/server/folder.ts b/src/server/routes/api/server/folder.ts index 7439a43f..4a41cf4b 100644 --- a/src/server/routes/api/server/folder.ts +++ b/src/server/routes/api/server/folder.ts @@ -1,11 +1,17 @@ import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; -import { fileSelect } from '@/lib/db/models/file'; +import { File, cleanFiles, fileSchema, fileSelect } from '@/lib/db/models/file'; import { buildPublicParentChain, cleanFolder, Folder, folderSchema } from '@/lib/db/models/folder'; +import { paginationQs } from '@/lib/validation'; import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; -export type ApiServerFolderResponse = Partial<Folder>; +export type ApiServerFolderResponse = { + folder: Partial<Folder>; + page: File[]; + total: number; + pages: number; +}; export const PATH = '/api/server/folder/:id'; export default typedPlugin( @@ -18,21 +24,29 @@ export default typedPlugin( params: z.object({ id: z.string(), }), + querystring: paginationQs.pick({ + page: true, + perpage: true, + sortBy: true, + order: true, + }), response: { - 200: folderSchema.partial(), + 200: z.object({ + folder: folderSchema.partial(), + page: z.array(fileSchema), + total: z.number(), + pages: z.number(), + }), }, }, }, async (req, res) => { const { id } = req.params; + const { page, perpage, sortBy, order } = req.query; const folder = await prisma.folder.findUnique({ where: { id }, include: { - files: { - select: { ...fileSelect, password: true, tags: false }, - orderBy: { createdAt: 'desc' }, - }, children: { where: { public: true }, orderBy: { createdAt: 'desc' }, @@ -54,12 +68,34 @@ export default typedPlugin( if (!folder) throw new ApiError(9002); if (!folder.public && !folder.allowUploads) throw new ApiError(9002); + const where = { folderId: folder.id }; + const total = await prisma.file.count({ where }); + const pages = total === 0 ? 0 : Math.ceil(total / perpage); + + const files = cleanFiles( + await prisma.file.findMany({ + where, + select: { ...fileSelect, password: true, tags: false }, + orderBy: { + [sortBy]: order, + }, + skip: (Number(page) - 1) * perpage, + take: perpage, + }), + true, + ); + if (!folder.public && folder.allowUploads) { return res.send({ - id: folder.id, - name: folder.name, - allowUploads: folder.allowUploads, - public: folder.public, + folder: { + id: folder.id, + name: folder.name, + allowUploads: folder.allowUploads, + public: folder.public, + }, + page: [], + total, + pages, }); } @@ -67,7 +103,14 @@ export default typedPlugin( folder.parent = await buildPublicParentChain(folder.parentId); } - return res.send(cleanFolder(folder, true)); + const cleanedFolder = cleanFolder(folder, true); + + return res.send({ + folder: cleanedFolder, + page: files, + total, + pages, + }); }, ); },