fix: impl serverside pagination folders (#1052)

This commit is contained in:
diced
2026-04-24 22:06:46 -07:00
parent a0907e8791
commit 756dee6bba
3 changed files with 118 additions and 49 deletions

View File

@@ -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<string> }) {
const res = await fetch(`/api/server/folder/${params.id}`);
export async function loader({ params, request }: { params: Params<string>; 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<Folder> }) {
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export function Component() {
const { folder } = useLoaderData<typeof loader>();
const { initial } = useLoaderData<typeof loader>();
const navigate = useNavigate();
useTitle(folder.name);
const [, setSearchParams] = useSearchParams();
const [page, setPage] = useQueryState('page', 1);
const [perpage] = useQueryState('perpage', 15);
const { data, isLoading } = useApiPagination<Response['/api/server/folder/[id]']>(
{
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<Folder>[];
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 && (
<>
<Title order={3} mt='md' mb='sm'>
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>

View File

@@ -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,
};
}

View File

@@ -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,
});
},
);
},