mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-08-27 11:11:19 +08:00
feat: add LoadingSkeleton component
This commit is contained in:
@@ -25,6 +25,7 @@ import { FileDetailModal } from './components/FileDetailModal';
|
|||||||
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
||||||
import type { ViewMode } from './types';
|
import type { ViewMode } from './types';
|
||||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||||
|
import { LoadingSkeleton } from './components/LoadingSkeleton';
|
||||||
|
|
||||||
const FileExplorerPage = memo(function FileExplorerPage() {
|
const FileExplorerPage = memo(function FileExplorerPage() {
|
||||||
const { navKey = 'files', '*': restPath = '' } = useParams();
|
const { navKey = 'files', '*': restPath = '' } = useParams();
|
||||||
@@ -56,10 +57,11 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
||||||
|
|
||||||
// --- Effects ---
|
// --- Effects ---
|
||||||
|
const routePath = '/' + (restPath || '').replace(/^\/+/, '');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const routeP = '/' + (restPath || '').replace(/^\/+/, '');
|
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
||||||
load(routeP, 1, pagination.pageSize, sortBy, sortOrder);
|
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||||
}, [restPath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
const handleOpenEntry = (entry: VfsEntry) => {
|
const handleOpenEntry = (entry: VfsEntry) => {
|
||||||
@@ -167,14 +169,15 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
<input ref={uploader.fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={uploader.handleFileChange} />
|
<input ref={uploader.fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={uploader.handleFileChange} />
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||||
{loading && entries.length === 0 ? (
|
{loading && (entries.length === 0 || path !== routePath) ? (
|
||||||
|
<LoadingSkeleton mode={viewMode} />
|
||||||
|
) : !loading && entries.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
||||||
) : viewMode === 'grid' ? (
|
) : viewMode === 'grid' ? (
|
||||||
<GridView
|
<GridView
|
||||||
entries={entries}
|
entries={entries}
|
||||||
thumbs={thumbs}
|
thumbs={thumbs}
|
||||||
selectedEntries={selectedEntries}
|
selectedEntries={selectedEntries}
|
||||||
loading={loading}
|
|
||||||
path={path}
|
path={path}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onSelectRange={handleSelectRange}
|
onSelectRange={handleSelectRange}
|
||||||
@@ -184,7 +187,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
) : (
|
) : (
|
||||||
<FileListView
|
<FileListView
|
||||||
entries={entries}
|
entries={entries}
|
||||||
loading={loading}
|
|
||||||
selectedEntries={selectedEntries}
|
selectedEntries={selectedEntries}
|
||||||
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
|
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
|
||||||
onSelectionChange={setSelectedEntries}
|
onSelectionChange={setSelectedEntries}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useI18n } from '../../../i18n';
|
|||||||
|
|
||||||
interface FileListViewProps {
|
interface FileListViewProps {
|
||||||
entries: VfsEntry[];
|
entries: VfsEntry[];
|
||||||
loading: boolean;
|
|
||||||
selectedEntries: string[];
|
selectedEntries: string[];
|
||||||
onRowClick: (entry: VfsEntry, e: React.MouseEvent) => void;
|
onRowClick: (entry: VfsEntry, e: React.MouseEvent) => void;
|
||||||
onSelectionChange: (selectedKeys: string[]) => void;
|
onSelectionChange: (selectedKeys: string[]) => void;
|
||||||
@@ -22,7 +21,6 @@ interface FileListViewProps {
|
|||||||
|
|
||||||
export const FileListView: React.FC<FileListViewProps> = ({
|
export const FileListView: React.FC<FileListViewProps> = ({
|
||||||
entries,
|
entries,
|
||||||
loading,
|
|
||||||
selectedEntries,
|
selectedEntries,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
@@ -107,7 +105,6 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
|||||||
rowKey={r => r.name}
|
rowKey={r => r.name}
|
||||||
dataSource={entries}
|
dataSource={entries}
|
||||||
columns={columns as any}
|
columns={columns as any}
|
||||||
loading={loading}
|
|
||||||
pagination={false}
|
pagination={false}
|
||||||
onRow={(r) => ({
|
onRow={(r) => ({
|
||||||
onClick: (e: any) => onRowClick(r, e),
|
onClick: (e: any) => onRowClick(r, e),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
import { Tooltip, Spin, theme } from 'antd';
|
import { Tooltip, theme } from 'antd';
|
||||||
import { FolderFilled, PictureOutlined } from '@ant-design/icons';
|
import { FolderFilled, PictureOutlined } from '@ant-design/icons';
|
||||||
import type { VfsEntry } from '../../../api/client';
|
import type { VfsEntry } from '../../../api/client';
|
||||||
import { getFileIcon } from './FileIcons';
|
import { getFileIcon } from './FileIcons';
|
||||||
@@ -10,7 +10,6 @@ interface Props {
|
|||||||
entries: VfsEntry[];
|
entries: VfsEntry[];
|
||||||
thumbs: Record<string, string>;
|
thumbs: Record<string, string>;
|
||||||
selectedEntries: string[];
|
selectedEntries: string[];
|
||||||
loading: boolean;
|
|
||||||
path: string;
|
path: string;
|
||||||
onSelect: (e: VfsEntry, additive?: boolean) => void;
|
onSelect: (e: VfsEntry, additive?: boolean) => void;
|
||||||
onSelectRange: (names: string[]) => void;
|
onSelectRange: (names: string[]) => void;
|
||||||
@@ -25,7 +24,7 @@ const formatSize = (size: number) => {
|
|||||||
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
|
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { resolvedMode } = useTheme();
|
const { resolvedMode } = useTheme();
|
||||||
const lightenColor = (hex: string, amount: number) => {
|
const lightenColor = (hex: string, amount: number) => {
|
||||||
@@ -185,8 +184,7 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{loading && <div style={{ width: '100%', textAlign: 'center', padding: 40 }}><Spin /></div>}
|
{entries.length === 0 && <EmptyState isRoot={path === '/'} />}
|
||||||
{!loading && entries.length === 0 && <EmptyState isRoot={path === '/'} />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { Skeleton, theme } from 'antd';
|
||||||
|
|
||||||
|
type LoadingMode = 'grid' | 'list';
|
||||||
|
|
||||||
|
interface LoadingSkeletonProps {
|
||||||
|
mode: LoadingMode;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createArray = (length: number) => Array.from({ length }, (_, index) => index);
|
||||||
|
|
||||||
|
export const LoadingSkeleton: FC<LoadingSkeletonProps> = ({ mode, count }) => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const fallbackCount = mode === 'grid' ? 50 : 30;
|
||||||
|
const items = createArray(count ?? fallbackCount);
|
||||||
|
|
||||||
|
if (mode === 'grid') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((key) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
background: token.colorBgElevated,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
padding: 16,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton.Button active block style={{ height: 96, borderRadius: token.borderRadiusLG }} />
|
||||||
|
<Skeleton active title={false} paragraph={{ rows: 2, width: ['80%', '60%'] }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0 16px' }}>
|
||||||
|
{items.map((key) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '48px 1fr',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton.Avatar active shape="square" size={32} />
|
||||||
|
<div style={{ paddingLeft: 16 }}>
|
||||||
|
<Skeleton active title={false} paragraph={{ rows: 1, width: '60%' }} />
|
||||||
|
<Skeleton active title={false} paragraph={{ rows: 1, width: '40%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user