mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-04 23:29:00 +08:00
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:
+24
-3
@@ -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)}`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "请选择要分享的文件或目录",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user