From 0763d457d5344a52f572a2dee66336e69cc34fa2 Mon Sep 17 00:00:00 2001 From: XiaoQing235 <60169803+XiaoQing235@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:21:08 +0800 Subject: [PATCH] feat: paginate public share directories (#132) * feat: paginate public share directories * feat: add cursor pagination to public share listings Unify share and VFS directory payloads, pass cursor through the share API, and add prev/next controls plus tests for cursor-mode adapters. --------- Co-authored-by: shiyu --- api/response.py | 51 ++++++ domain/share/api.py | 25 ++- domain/share/service.py | 66 ++++++- domain/virtual_fs/routes.py | 32 +--- domain/virtual_fs/service.py | 34 +--- tests/test_listing_pagination.py | 73 ++++++++ tests/test_share_listing.py | 89 +++++++++ web/src/api/share.ts | 27 ++- web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/zh.json | 2 + .../pages/PublicSharePage/DirectoryViewer.tsx | 173 ++++++++++++++++-- 11 files changed, 486 insertions(+), 88 deletions(-) create mode 100644 tests/test_listing_pagination.py create mode 100644 tests/test_share_listing.py diff --git a/api/response.py b/api/response.py index 7e52a87..4030ab2 100644 --- a/api/response.py +++ b/api/response.py @@ -30,5 +30,56 @@ def cursor_page( } +def listing_pagination( + result: Any, + *, + page_num: int = 1, + page_size: int = 50, + cursor: str | None = None, +) -> tuple[list[Any], dict[str, Any]]: + """把 list_virtual_dir / page / cursor_page 结果规范成对外 pagination。""" + data = result if isinstance(result, dict) else {} + items = data.get("items") or [] + mode = data.get("pagination_mode") or "paged" + pagination: dict[str, Any] = { + "mode": mode, + "page_size": data.get("page_size", page_size), + } + if mode == "cursor": + pagination.update( + { + "cursor": data.get("cursor", cursor), + "next_cursor": data.get("next_cursor"), + "has_next": bool(data.get("has_next")), + } + ) + else: + total = data.get("total") + pages = data.get("pages") + pagination.update( + { + "total": 0 if total is None else total, + "page": data.get("page", page_num), + "pages": 0 if pages is None else pages, + } + ) + return items, pagination + + +def dir_listing( + result: Any, + *, + path: str, + page_num: int = 1, + page_size: int = 50, + cursor: str | None = None, +) -> dict[str, Any]: + """对外目录列表载荷:path / entries / pagination。""" + items, pagination = listing_pagination( + result, page_num=page_num, page_size=page_size, cursor=cursor + ) + return {"path": path, "entries": items, "pagination": pagination} + + def error(msg: str, code: int = 1, data: Optional[Any] = None): return {"code": code, "msg": msg, "data": data} diff --git a/domain/share/api.py b/domain/share/api.py index 76159a0..78d2883 100644 --- a/domain/share/api.py +++ b/domain/share/api.py @@ -1,6 +1,6 @@ from typing import Annotated, List, Optional -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Query, Request from api.response import success from domain.audit import AuditAction, audit @@ -95,20 +95,27 @@ async def verify_password(request: Request, token: str, payload: SharePassword): @public_router.get("/{token}/ls") @audit(action=AuditAction.SHARE, description="浏览分享内容") async def list_share_content( - request: Request, token: str, path: str = "/", password: Optional[str] = None + request: Request, + token: str, + path: str = "/", + password: Optional[str] = None, + page_num: int = Query(1, alias="page", ge=1, description="页码"), + page_size: int = Query(50, ge=1, le=500, description="每页条数"), + cursor: str | None = Query(None, description="游标分页位置"), ): share = await ShareService.ensure_share_access(token, password) - content = await ShareService.get_shared_item_details(share, path) + content = await ShareService.get_shared_item_details( + share=share, + sub_path=path, + page_num=page_num, + page_size=page_size, + cursor=cursor, + ) return success( { "path": path, "entries": content.get("items", []), - "pagination": { - "total": content.get("total", 0), - "page": content.get("page", 1), - "page_size": content.get("page_size", 1), - "pages": content.get("pages", 1), - }, + "pagination": content.get("pagination"), } ) diff --git a/domain/share/service.py b/domain/share/service.py index e0d8a5f..dcc1902 100644 --- a/domain/share/service.py +++ b/domain/share/service.py @@ -7,6 +7,7 @@ import bcrypt from fastapi import HTTPException, status from fastapi.responses import Response +from api.response import listing_pagination from domain.virtual_fs import VirtualFSService from models.database import ShareLink, UserAccount @@ -114,7 +115,53 @@ class ShareService: return deleted_count @classmethod - async def get_shared_item_details(cls, share: ShareLink, sub_path: str = ""): + async def _list_shared_virtual_dir( + cls, + vfs_path: str, + page_num: int, + page_size: int, + cursor: str | None, + ): + try: + result = await VirtualFSService.list_virtual_dir( + vfs_path, + page_num, + page_size, + cursor=cursor, + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="目录未找到") + except HTTPException as e: + if e.status_code == 404: + raise HTTPException(status_code=404, detail="目录未找到") from e + raise + items, pagination = listing_pagination( + result, page_num=page_num, page_size=page_size, cursor=cursor + ) + return {"items": items, "pagination": pagination} + + @classmethod + def _single_file_listing(cls, stat: dict, page_num: int, page_size: int): + return { + "items": [stat] if page_num == 1 else [], + "pagination": { + "mode": "paged", + "page_size": page_size, + "total": 1, + "page": page_num, + "pages": 1, + }, + } + + @classmethod + async def get_shared_item_details( + cls, + share: ShareLink, + sub_path: str = "", + page_num: int = 1, + page_size: int = 50, + cursor: str | None = None, + ): if not share.paths: raise HTTPException(status_code=404, detail="分享内容为空") @@ -124,21 +171,24 @@ class ShareService: full_path = f"{base_shared_path.rstrip('/')}/{sub_path.lstrip('/')}".rstrip("/") if not full_path.startswith(base_shared_path): raise HTTPException(status_code=403, detail="无权访问此路径") - try: - return await VirtualFSService.list_virtual_dir(full_path) - except FileNotFoundError: - raise HTTPException(status_code=404, detail="目录未找到") + return await cls._list_shared_virtual_dir( + full_path, page_num, page_size, cursor + ) try: stat = await VirtualFSService.stat_file(base_shared_path) if stat.get("is_dir"): - return await VirtualFSService.list_virtual_dir(base_shared_path) + return await cls._list_shared_virtual_dir( + base_shared_path, page_num, page_size, cursor + ) stat["name"] = base_shared_path.split("/")[-1] - return {"items": [stat], "total": 1, "page": 1, "page_size": 1, "pages": 1} + return cls._single_file_listing(stat, page_num, page_size) except HTTPException as e: if "Path is a directory" in str(e.detail) or "Not a file" in str(e.detail): - return await VirtualFSService.list_virtual_dir(base_shared_path) + return await cls._list_shared_virtual_dir( + base_shared_path, page_num, page_size, cursor + ) raise e @classmethod diff --git a/domain/virtual_fs/routes.py b/domain/virtual_fs/routes.py index a00df1e..367c72f 100644 --- a/domain/virtual_fs/routes.py +++ b/domain/virtual_fs/routes.py @@ -5,6 +5,7 @@ from urllib.parse import quote from fastapi import HTTPException, Request, UploadFile from fastapi.responses import Response +from api.response import dir_listing from domain.config import ConfigService from domain.tasks import TaskService from .thumbnail import ( @@ -293,31 +294,12 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin): async def list_directory(cls, full_path: str, page_num: int, page_size: int, sort_by: str, sort_order: str): full_path = cls._normalize_path(full_path) result = await cls.list_virtual_dir(full_path, page_num, page_size, sort_by, sort_order) - pagination = { - "mode": result.get("pagination_mode", "paged"), - "page_size": result.get("page_size", page_size), - } - if pagination["mode"] == "cursor": - pagination.update( - { - "cursor": result.get("cursor"), - "next_cursor": result.get("next_cursor"), - "has_next": bool(result.get("has_next")), - } - ) - else: - pagination.update( - { - "total": result["total"], - "page": result["page"], - "pages": result["pages"], - } - ) - return { - "path": full_path, - "entries": result["items"], - "pagination": pagination, - } + return dir_listing( + result, + path=full_path, + page_num=page_num, + page_size=page_size, + ) @classmethod async def delete(cls, full_path: str): diff --git a/domain/virtual_fs/service.py b/domain/virtual_fs/service.py index 0237710..bfbbd39 100644 --- a/domain/virtual_fs/service.py +++ b/domain/virtual_fs/service.py @@ -1,3 +1,5 @@ +from api.response import dir_listing + from .common import VirtualFSCommonMixin from .resolver import VirtualFSResolverMixin from .listing import VirtualFSListingMixin @@ -47,28 +49,10 @@ class VirtualFSService( result = await cls.list_virtual_dir_with_permission( full_path, user_id, page_num, page_size, sort_by, sort_order, cursor ) - pagination = { - "mode": result.get("pagination_mode", "paged") if isinstance(result, dict) else "paged", - "page_size": result.get("page_size", page_size) if isinstance(result, dict) else page_size, - } - if pagination["mode"] == "cursor": - pagination.update( - { - "cursor": result.get("cursor") if isinstance(result, dict) else cursor, - "next_cursor": result.get("next_cursor") if isinstance(result, dict) else None, - "has_next": bool(result.get("has_next")) if isinstance(result, dict) else False, - } - ) - else: - pagination.update( - { - "total": result.get("total", 0) if isinstance(result, dict) else 0, - "page": result.get("page", page_num) if isinstance(result, dict) else page_num, - "pages": result.get("pages", 0) if isinstance(result, dict) else 0, - } - ) - return { - "path": full_path, - "entries": result.get("items", []) if isinstance(result, dict) else [], - "pagination": pagination, - } + return dir_listing( + result, + path=full_path, + page_num=page_num, + page_size=page_size, + cursor=cursor, + ) diff --git a/tests/test_listing_pagination.py b/tests/test_listing_pagination.py new file mode 100644 index 0000000..1814117 --- /dev/null +++ b/tests/test_listing_pagination.py @@ -0,0 +1,73 @@ +import unittest + +from api.response import cursor_page, dir_listing, listing_pagination, page + + +class ListingPaginationTests(unittest.TestCase): + def test_paged_result_keeps_totals(self): + result = page([{"name": "a"}], total=120, page=2, page_size=50) + items, pagination = listing_pagination(result, page_num=2, page_size=50) + self.assertEqual(len(items), 1) + self.assertEqual( + pagination, + { + "mode": "paged", + "page_size": 50, + "total": 120, + "page": 2, + "pages": 3, + }, + ) + + def test_cursor_result_keeps_cursors(self): + result = cursor_page( + [{"name": "tg"}], + page_size=50, + cursor="10", + next_cursor="20", + ) + items, pagination = listing_pagination(result, page_num=2, page_size=50, cursor="10") + self.assertEqual(items, [{"name": "tg"}]) + self.assertEqual( + pagination, + { + "mode": "cursor", + "page_size": 50, + "cursor": "10", + "next_cursor": "20", + "has_next": True, + }, + ) + self.assertNotIn("total", pagination) + self.assertNotIn("page", pagination) + + def test_missing_total_does_not_fake_page_count(self): + items, pagination = listing_pagination( + {"items": [{"name": "a"}] * 50, "page_size": 50}, + page_num=1, + page_size=50, + ) + self.assertEqual(len(items), 50) + self.assertEqual(pagination["mode"], "paged") + self.assertEqual(pagination["total"], 0) + self.assertEqual(pagination["pages"], 0) + + def test_dir_listing_wraps_path_and_entries(self): + result = page([{"name": "a"}], total=1, page=1, page_size=50) + payload = dir_listing(result, path="/share/docs", page_num=1, page_size=50) + self.assertEqual(payload["path"], "/share/docs") + self.assertEqual(payload["entries"], [{"name": "a"}]) + self.assertEqual(payload["pagination"]["mode"], "paged") + self.assertEqual(payload["pagination"]["total"], 1) + + def test_non_dict_result_is_empty_paged(self): + items, pagination = listing_pagination(None, page_num=3, page_size=20) + self.assertEqual(items, []) + self.assertEqual(pagination["mode"], "paged") + self.assertEqual(pagination["total"], 0) + self.assertEqual(pagination["page"], 3) + self.assertEqual(pagination["page_size"], 20) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_share_listing.py b/tests/test_share_listing.py new file mode 100644 index 0000000..b2a46bc --- /dev/null +++ b/tests/test_share_listing.py @@ -0,0 +1,89 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException + +from api.response import cursor_page, page +from domain.share.service import ShareService + + +class ShareSingleFileListingTests(unittest.TestCase): + def test_first_page_returns_file(self): + stat = {"name": "notes.md", "is_dir": False, "size": 12} + result = ShareService._single_file_listing(stat, 1, 50) + self.assertEqual(result["items"], [stat]) + self.assertEqual(result["pagination"]["mode"], "paged") + self.assertEqual(result["pagination"]["total"], 1) + self.assertEqual(result["pagination"]["page"], 1) + self.assertEqual(result["pagination"]["pages"], 1) + + def test_later_page_is_empty(self): + stat = {"name": "notes.md", "is_dir": False} + result = ShareService._single_file_listing(stat, 2, 50) + self.assertEqual(result["items"], []) + self.assertEqual(result["pagination"]["total"], 1) + self.assertEqual(result["pagination"]["page"], 2) + self.assertEqual(result["pagination"]["pages"], 1) + + +class ShareDirectoryListingTests(unittest.IsolatedAsyncioTestCase): + async def test_directory_maps_offset_pagination(self): + share = SimpleNamespace(paths=["/photos"]) + listing = page([{"name": "a.jpg"}, {"name": "b.jpg"}], total=90, page=2, page_size=20) + with patch.object(ShareService, "_list_shared_virtual_dir", new_callable=AsyncMock) as listed: + with patch("domain.share.service.VirtualFSService.stat_file", new_callable=AsyncMock) as stat_file: + stat_file.return_value = {"is_dir": True, "name": "photos"} + listed.return_value = {"items": listing["items"], "pagination": {"mode": "paged", "total": 90}} + result = await ShareService.get_shared_item_details( + share, "/", page_num=2, page_size=20 + ) + listed.assert_awaited_once_with("/photos", 2, 20, None) + self.assertEqual(result["pagination"]["total"], 90) + + async def test_directory_passes_cursor_to_vfs(self): + share = SimpleNamespace(paths=["/tg"]) + vfs_result = cursor_page( + [{"name": "1_clip.mp4"}], + page_size=50, + cursor="10", + next_cursor="20", + ) + with patch("domain.share.service.VirtualFSService.stat_file", new_callable=AsyncMock) as stat_file: + with patch( + "domain.share.service.VirtualFSService.list_virtual_dir", + new_callable=AsyncMock, + ) as list_dir: + stat_file.return_value = {"is_dir": True, "name": "tg"} + list_dir.return_value = vfs_result + result = await ShareService.get_shared_item_details( + share, "/", page_num=1, page_size=50, cursor="10" + ) + list_dir.assert_awaited_once_with("/tg", 1, 50, cursor="10") + self.assertEqual(result["items"], [{"name": "1_clip.mp4"}]) + self.assertEqual(result["pagination"]["mode"], "cursor") + self.assertEqual(result["pagination"]["next_cursor"], "20") + self.assertTrue(result["pagination"]["has_next"]) + self.assertNotIn("total", result["pagination"]) + + async def test_missing_directory_http_404_is_normalized(self): + share = SimpleNamespace(paths=["/photos"]) + with patch( + "domain.share.service.VirtualFSService.list_virtual_dir", + new_callable=AsyncMock, + ) as list_dir: + list_dir.side_effect = HTTPException(status_code=404, detail="Path not found") + with self.assertRaises(HTTPException) as ctx: + await ShareService.get_shared_item_details(share, "/missing") + self.assertEqual(ctx.exception.status_code, 404) + self.assertEqual(ctx.exception.detail, "目录未找到") + + async def test_empty_share_is_404(self): + share = SimpleNamespace(paths=[]) + with self.assertRaises(HTTPException) as ctx: + await ShareService.get_shared_item_details(share) + self.assertEqual(ctx.exception.status_code, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/src/api/share.ts b/web/src/api/share.ts index 5bc1c67..9bb30d1 100644 --- a/web/src/api/share.ts +++ b/web/src/api/share.ts @@ -34,12 +34,33 @@ export const shareApi = { clearExpired: () => request(`/shares/expired`, { method: 'DELETE' }), get: (token: string) => request(`/s/${token}`), verifyPassword: (token: string, password: string) => request(`/s/${token}/verify`, { method: 'POST', json: { password } }), - listDir: (token: string, path: string = '/', password?: string) => { - const params: Record = { path }; + listDir: ( + token: string, + path: string = '/', + password?: string, + options?: { + page?: number; + pageSize?: number; + cursor?: string | null; + signal?: AbortSignal; + }, + ) => { + const page = options?.page ?? 1; + const pageSize = options?.pageSize ?? 50; + const params: Record = { + path, + page: String(page), + page_size: String(pageSize), + }; if (password) { params.password = password; } - return request(`/s/${token}/ls?${new URLSearchParams(params)}`); + if (options?.cursor) { + params.cursor = options.cursor; + } + return request(`/s/${token}/ls?${new URLSearchParams(params)}`, { + signal: options?.signal, + }); }, downloadUrl: (token: string, path: string, password?: string) => { const url = `${API_BASE_URL}/s/${token}/download?path=${encodeURIComponent(path)}`; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f3f597d..5620307 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -636,6 +636,8 @@ "Please input name": "Please input name", "Confirm delete {name}?": "Confirm delete {name}?", "items": "items", + "Previous page": "Previous page", + "Next page": "Next page", "Downloading folders is not supported": "Downloading folders is not supported", "Download failed": "Download failed", "Please select files or folders to share": "Please select files or folders to share", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 30bc00e..b753474 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -629,6 +629,8 @@ "Please input name": "请输入名称", "Confirm delete {name}?": "确认删除 {name} ?", "items": "项", + "Previous page": "上一页", + "Next page": "下一页", "Downloading folders is not supported": "暂不支持下载目录", "Download failed": "下载失败", "Please select files or folders to share": "请选择要分享的文件或目录", diff --git a/web/src/pages/PublicSharePage/DirectoryViewer.tsx b/web/src/pages/PublicSharePage/DirectoryViewer.tsx index 2fd77b7..c6da9ce 100644 --- a/web/src/pages/PublicSharePage/DirectoryViewer.tsx +++ b/web/src/pages/PublicSharePage/DirectoryViewer.tsx @@ -1,5 +1,5 @@ -import { memo, useState, useEffect, useCallback } from 'react'; -import { Card, List, Typography, Button, Empty, Breadcrumb } from 'antd'; +import { memo, useState, useEffect, useCallback, useRef } from 'react'; +import { Card, List, Typography, Button, Empty, Breadcrumb, Pagination, Space } from 'antd'; import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons'; import { shareApi, type ShareInfo } from '../../api/share'; import { type VfsEntry } from '../../api/vfs'; @@ -15,42 +15,151 @@ interface DirectoryViewerProps { onFileClick: (entry: VfsEntry, path: string) => void; } +const DEFAULT_PAGE_SIZE = 50; + +type SharePaginationState = { + mode: 'paged' | 'cursor'; + current: number; + pageSize: number; + total: number; + cursor: string | null; + nextCursor: string | null; + hasNext: boolean; + cursorHistory: (string | null)[]; +}; + +const INITIAL_PAGINATION: SharePaginationState = { + mode: 'paged', + current: 1, + pageSize: DEFAULT_PAGE_SIZE, + total: 0, + cursor: null, + nextCursor: null, + hasNext: false, + cursorHistory: [], +}; + export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password, onFileClick }: DirectoryViewerProps) { const [loading, setLoading] = useState(true); const [entries, setEntries] = useState([]); const [currentPath, setCurrentPath] = useState('/'); + const [pagination, setPagination] = useState(INITIAL_PAGINATION); const [error, setError] = useState(''); const { t } = useI18n(); + const tRef = useRef(t); + tRef.current = t; + const paginationRef = useRef(pagination); + paginationRef.current = pagination; + const loadGenRef = useRef(0); + const abortRef = useRef(null); - const loadData = useCallback(async (p: string) => { - setLoading(true); - setError(''); - try { - const listing = await shareApi.listDir(token, p, password); - setEntries(listing.entries || []); - setCurrentPath(p); - } catch (e: any) { - setError(e.message || t('Share load failed')); - } finally { - setLoading(false); - } - }, [password, t, token]); + const loadData = useCallback(async (opts: { + path: string; + page?: number; + pageSize?: number; + cursor?: string | null; + cursorHistory?: (string | null)[]; + }) => { + const page = opts.page ?? 1; + const pageSize = opts.pageSize ?? paginationRef.current.pageSize; + const cursor = opts.cursor ?? null; + const cursorHistory = opts.cursorHistory ?? []; + const gen = ++loadGenRef.current; + abortRef.current?.abort(); + const ac = new AbortController(); + abortRef.current = ac; + setLoading(true); + setError(''); + try { + const listing = await shareApi.listDir(token, opts.path, password, { + page, + pageSize, + cursor, + signal: ac.signal, + }); + if (gen !== loadGenRef.current) return; + const listingPagination = listing.pagination; + const pageMode = listingPagination?.mode === 'cursor' ? 'cursor' : 'paged'; + setEntries(listing.entries || []); + setPagination({ + mode: pageMode, + current: listingPagination?.page ?? page, + pageSize: listingPagination?.page_size ?? pageSize, + total: listingPagination?.total ?? 0, + cursor: listingPagination?.cursor ?? null, + nextCursor: listingPagination?.next_cursor ?? null, + hasNext: Boolean(listingPagination?.has_next), + cursorHistory: pageMode === 'cursor' ? cursorHistory : [], + }); + } catch (e: any) { + if (gen !== loadGenRef.current) return; + if (e?.name === 'AbortError') return; + setError(e.message || tRef.current('Share load failed')); + } finally { + if (gen === loadGenRef.current) { + setLoading(false); + } + } + }, [password, token]); useEffect(() => { - loadData(currentPath); + loadData({ path: currentPath, page: 1 }); + return () => { + abortRef.current?.abort(); + }; }, [loadData, currentPath]); + const goToPath = (path: string) => { + setPagination(prev => ({ + ...prev, + current: 1, + cursor: null, + nextCursor: null, + hasNext: false, + cursorHistory: [], + })); + setCurrentPath(path); + }; + const handleEntryClick = (entry: VfsEntry) => { const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name; if (entry.is_dir) { - loadData(newPath); + goToPath(newPath); } else { onFileClick(entry, newPath); } }; const handleBreadcrumbClick = (path: string) => { - loadData(path); + goToPath(path); + }; + + const handlePageChange = (page: number, pageSize: number) => { + loadData({ path: currentPath, page, pageSize }); + }; + + const handleCursorNext = () => { + if (!pagination.nextCursor) return; + loadData({ + path: currentPath, + page: 1, + pageSize: pagination.pageSize, + cursor: pagination.nextCursor, + cursorHistory: [...pagination.cursorHistory, pagination.cursor], + }); + }; + + const handleCursorPrev = () => { + if (pagination.cursorHistory.length === 0) return; + const nextHistory = pagination.cursorHistory.slice(0, -1); + const prevCursor = pagination.cursorHistory[pagination.cursorHistory.length - 1]; + loadData({ + path: currentPath, + page: 1, + pageSize: pagination.pageSize, + cursor: prevCursor, + cursorHistory: nextHistory, + }); }; const renderBreadcrumb = () => { @@ -75,6 +184,9 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, ); }; + const showPagedPagination = pagination.mode === 'paged' && pagination.total > 0; + const showCursorPagination = pagination.mode === 'cursor' && (pagination.cursorHistory.length > 0 || pagination.hasNext); + if (error) { return
; } @@ -112,6 +224,31 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, )} /> + {showPagedPagination ? ( +
+ `${total} ${t('items')} ${range[0]}-${range[1]}`} + onChange={handlePageChange} + /> +
+ ) : null} + {showCursorPagination ? ( +
+ + + + +
+ ) : null} );