diff --git a/src/client/pages/folder/[id]/index.tsx b/src/client/pages/folder/[id]/index.tsx index 75dbc0d5..d35d096d 100644 --- a/src/client/pages/folder/[id]/index.tsx +++ b/src/client/pages/folder/[id]/index.tsx @@ -1,7 +1,8 @@ import { type Response } from '@/lib/api/response'; +import { useQueryState } from '@/lib/client/hooks/useQueryState'; +import { useTitle } from '@/lib/client/hooks/useTitle'; import { Folder } from '@/lib/db/models/folder'; import { FolderBreadcrumb } from '@/lib/folderHierarchy'; -import { useTitle } from '@/lib/client/hooks/useTitle'; import { ActionIcon, Anchor, @@ -9,6 +10,8 @@ import { Card, Container, Group, + Pagination, + Select, SimpleGrid, Skeleton, Stack, @@ -16,7 +19,7 @@ import { Title, } from '@mantine/core'; import { IconFolder, IconUpload } from '@tabler/icons-react'; -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useMemo, useState } from 'react'; import { Link, Params, useLoaderData, useNavigate } from 'react-router-dom'; const DashboardFile = lazy(() => import('@/components/file/DashboardFile')); @@ -58,6 +61,8 @@ function PublicFolderCard({ folder }: { folder: Partial }) { ); } +const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45]; + export function Component() { const { folder } = useLoaderData(); const navigate = useNavigate(); @@ -81,6 +86,21 @@ 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]); + return ( <> @@ -132,7 +152,7 @@ export function Component() { )} - {(folder.files?.length ?? 0) > 0 && ( + {(visible.length ?? 0) > 0 && ( <> Files @@ -145,7 +165,7 @@ export function Component() { }} spacing='md' > - {folder.files?.map((file: any) => ( + {visible.map((file: any) => ( <Suspense fallback={<Skeleton height={350} animate />} key={file.id}> <DashboardFile file={file} reduce /> </Suspense> @@ -159,6 +179,33 @@ export function Component() { This folder is empty. </Text> )} + + <Group justify='space-between' align='center' mt='md'> + <Text size='sm'>{`${from} - ${to} / ${totalRecords} files`}</Text> + + <Group gap='sm'> + <Select + value={perpage.toString()} + data={PER_PAGE_OPTIONS.map((val) => ({ value: val.toString(), label: `${val}` }))} + onChange={(value) => { + setPerpage(Number(value)); + setPage(1); + }} + w={80} + size='xs' + variant='filled' + /> + + <Pagination + value={page} + onChange={setPage} + total={cachedPages} + size='sm' + withControls + withEdges + /> + </Group> + </Group> </Container> </> ); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index c114213e..391a50c2 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -21,3 +21,26 @@ export function zValidatePath(val: string | undefined, ctx: z.RefinementCtx) { export const zStringTrimmed = z.string().trim().min(1); export const zQsBoolean = z.enum(['true', 'false']).transform((val) => val === 'true'); + +export const paginationQs = z.object({ + page: z.coerce.number(), + perpage: z.coerce.number().default(15), + filter: z.enum(['dashboard', 'none', 'all']).optional().default('none'), + favorite: zQsBoolean.default(false).optional(), + sortBy: z + .enum([ + 'id', + 'createdAt', + 'updatedAt', + 'deletesAt', + 'name', + 'originalName', + 'size', + 'type', + 'views', + 'favorite', + ]) + .optional() + .default('createdAt'), + order: z.enum(['asc', 'desc']).optional().default('desc'), +}); diff --git a/src/server/routes/api/user/files/index.ts b/src/server/routes/api/user/files/index.ts index 61e9daa4..e0ac7c49 100644 --- a/src/server/routes/api/user/files/index.ts +++ b/src/server/routes/api/user/files/index.ts @@ -2,7 +2,7 @@ import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; import { File, cleanFiles, fileSchema, fileSelect } from '@/lib/db/models/file'; import { canInteract } from '@/lib/role'; -import { zQsBoolean } from '@/lib/validation'; +import { paginationQs, zQsBoolean } from '@/lib/validation'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; @@ -29,27 +29,7 @@ export default typedPlugin( schema: { description: 'List, filter, and search files for the authenticated user (or another user if permitted).', - querystring: z.object({ - page: z.coerce.number(), - perpage: z.coerce.number().default(15), - filter: z.enum(['dashboard', 'none', 'all']).optional().default('none'), - favorite: zQsBoolean.default(false).optional(), - sortBy: z - .enum([ - 'id', - 'createdAt', - 'updatedAt', - 'deletesAt', - 'name', - 'originalName', - 'size', - 'type', - 'views', - 'favorite', - ]) - .optional() - .default('createdAt'), - order: z.enum(['asc', 'desc']).optional().default('desc'), + querystring: paginationQs.extend({ searchField: z.enum(['name', 'originalName', 'type', 'tags', 'id']).optional().default('name'), searchQuery: z.string().optional(), id: z.string().optional(),