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 <im@shiyu.dev>
This commit is contained in:
XiaoQing235
2026-08-27 22:21:08 +08:00
committed by GitHub
co-authored by shiyu
parent 4d907c7bfd
commit 0763d457d5
11 changed files with 486 additions and 88 deletions
+24 -3
View File
@@ -34,12 +34,33 @@ export const shareApi = {
clearExpired: () => request<ClearExpiredResult>(`/shares/expired`, { method: 'DELETE' }),
get: (token: string) => request<ShareInfo>(`/s/${token}`),
verifyPassword: (token: string, password: string) => request<void>(`/s/${token}/verify`, { method: 'POST', json: { password } }),
listDir: (token: string, path: string = '/', password?: string) => {
const params: Record<string, string> = { 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<string, string> = {
path,
page: String(page),
page_size: String(pageSize),
};
if (password) {
params.password = password;
}
return request<DirListing>(`/s/${token}/ls?${new URLSearchParams(params)}`);
if (options?.cursor) {
params.cursor = options.cursor;
}
return request<DirListing>(`/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)}`;
+2
View File
@@ -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",
+2
View File
@@ -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": "请选择要分享的文件或目录",
+155 -18
View File
@@ -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<VfsEntry[]>([]);
const [currentPath, setCurrentPath] = useState('/');
const [pagination, setPagination] = useState<SharePaginationState>(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<AbortController | null>(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 <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
}
@@ -112,6 +224,31 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
</List.Item>
)}
/>
{showPagedPagination ? (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 16 }}>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
showSizeChanger
pageSizeOptions={['20', '50', '100', '200']}
showTotal={(total, range) => `${total} ${t('items')} ${range[0]}-${range[1]}`}
onChange={handlePageChange}
/>
</div>
) : null}
{showCursorPagination ? (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 16 }}>
<Space>
<Button size="small" onClick={handleCursorPrev} disabled={pagination.cursorHistory.length === 0 || loading}>
{t('Previous page')}
</Button>
<Button size="small" type="primary" onClick={handleCursorNext} disabled={!pagination.hasNext || loading}>
{t('Next page')}
</Button>
</Space>
</div>
) : null}
</Card>
</div>
);