Files
Cli-Proxy-API-Management-Ce…/src/pages/AuthFilesPage.tsx
T
Supra4E8C b25f722fef Refactor provider usage tracking to utilize recent requests data
- Introduced `useProviderRecentRequests` hook to manage recent request data for providers.
- Replaced usage of `useProviderStats` with `useProviderRecentRequests` in relevant components.
- Updated `VertexSection` and other provider components to consume recent request data instead of key stats.
- Removed obsolete `useProviderStats` hook and related usage details.
- Added new utility functions for handling recent request data, including normalization and aggregation.
- Updated API service to fetch recent request usage data.
- Refactored `AuthFileCard` and related components to utilize recent request statistics.
- Cleaned up unused code and types related to previous usage stats implementation.
2026-05-02 02:53:15 +08:00

998 lines
34 KiB
TypeScript

import {
useCallback,
type CSSProperties,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { animate } from 'motion/mini';
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { IconFilterAll } from '@/components/ui/icons';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { copyToClipboard } from '@/utils/clipboard';
import {
MAX_CARD_PAGE_SIZE,
MIN_CARD_PAGE_SIZE,
QUOTA_PROVIDER_TYPES,
clampCardPageSize,
getAuthFileIcon,
getTypeColor,
getTypeLabel,
hasAuthFileStatusMessage,
isRuntimeOnlyAuthFile,
normalizeProviderKey,
parsePriorityValue,
type QuotaProviderType,
type ResolvedTheme,
} from '@/features/authFiles/constants';
import { AuthFileCard } from '@/features/authFiles/components/AuthFileCard';
import { AuthFileModelsModal } from '@/features/authFiles/components/AuthFileModelsModal';
import { AuthFilesPrefixProxyEditorModal } from '@/features/authFiles/components/AuthFilesPrefixProxyEditorModal';
import { OAuthExcludedCard } from '@/features/authFiles/components/OAuthExcludedCard';
import { OAuthModelAliasCard } from '@/features/authFiles/components/OAuthModelAliasCard';
import { useAuthFilesData } from '@/features/authFiles/hooks/useAuthFilesData';
import { useAuthFilesModels } from '@/features/authFiles/hooks/useAuthFilesModels';
import { useAuthFilesOauth } from '@/features/authFiles/hooks/useAuthFilesOauth';
import { useAuthFilesPrefixProxyEditor } from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
import {
isAuthFilesSortMode,
readAuthFilesUiState,
readPersistedAuthFilesCompactMode,
writeAuthFilesUiState,
writePersistedAuthFilesCompactMode,
type AuthFilesSortMode,
} from '@/features/authFiles/uiState';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import styles from './AuthFilesPage.module.scss';
const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4;
const easePower2In = (progress: number) => progress ** 3;
const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
const DEFAULT_REGULAR_PAGE_SIZE = 9;
const DEFAULT_COMPACT_PAGE_SIZE = 12;
const escapeWildcardSearchSegment = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildWildcardSearch = (value: string): RegExp | null => {
if (!value.includes('*')) return null;
const pattern = value.split('*').map(escapeWildcardSearchSegment).join('.*');
return new RegExp(pattern, 'i');
};
export function AuthFilesPage() {
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const navigate = useNavigate();
const [filter, setFilter] = useState<'all' | string>('all');
const [problemOnly, setProblemOnly] = useState(false);
const [disabledOnly, setDisabledOnly] = useState(false);
const [compactMode, setCompactMode] = useState(false);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSizeByMode, setPageSizeByMode] = useState({
regular: DEFAULT_REGULAR_PAGE_SIZE,
compact: DEFAULT_COMPACT_PAGE_SIZE,
});
const [pageSizeInput, setPageSizeInput] = useState('9');
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const [uiStateHydrated, setUiStateHydrated] = useState(false);
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
const previousSelectionCountRef = useRef(0);
const selectionCountRef = useRef(0);
const {
files,
selectedFiles,
selectionCount,
loading,
error,
uploading,
deleting,
deletingAll,
statusUpdating,
batchStatusUpdating,
fileInputRef,
loadFiles,
handleUploadClick,
handleFileChange,
handleDelete,
handleDeleteAll,
handleDownload,
handleStatusToggle,
toggleSelect,
selectAllVisible,
invertVisibleSelection,
deselectAll,
batchDownload,
batchSetStatus,
batchDelete,
} = useAuthFilesData();
const statusBarCache = useAuthFilesStatusBarCache(files);
const {
excluded,
excludedError,
modelAlias,
modelAliasError,
allProviderModels,
loadExcluded,
loadModelAlias,
deleteExcluded,
deleteModelAlias,
handleMappingUpdate,
handleDeleteLink,
handleToggleFork,
handleRenameAlias,
handleDeleteAlias,
} = useAuthFilesOauth({ viewMode, files });
const {
modelsModalOpen,
modelsLoading,
modelsList,
modelsFileName,
modelsFileType,
modelsError,
showModels,
closeModelsModal,
} = useAuthFilesModels();
const {
prefixProxyEditor,
prefixProxyUpdatedText,
prefixProxyDirty,
openPrefixProxyEditor,
closePrefixProxyEditor,
handlePrefixProxyChange,
handlePrefixProxySave,
} = useAuthFilesPrefixProxyEditor({
disableControls: connectionStatus !== 'connected',
loadFiles,
});
const disableControls = connectionStatus !== 'connected';
const normalizedFilter = normalizeProviderKey(String(filter));
const quotaFilterType: QuotaProviderType | null = QUOTA_PROVIDER_TYPES.has(
normalizedFilter as QuotaProviderType
)
? (normalizedFilter as QuotaProviderType)
: null;
const pageSize = compactMode ? pageSizeByMode.compact : pageSizeByMode.regular;
useEffect(() => {
const persistedCompactMode = readPersistedAuthFilesCompactMode();
if (typeof persistedCompactMode === 'boolean') {
setCompactMode(persistedCompactMode);
}
const persisted = readAuthFilesUiState();
if (persisted) {
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
setFilter(persisted.filter);
}
if (typeof persisted.problemOnly === 'boolean') {
setProblemOnly(persisted.problemOnly);
}
if (typeof persisted.disabledOnly === 'boolean') {
setDisabledOnly(persisted.disabledOnly);
}
if (
typeof persistedCompactMode !== 'boolean' &&
typeof persisted.compactMode === 'boolean'
) {
setCompactMode(persisted.compactMode);
}
if (typeof persisted.search === 'string') {
setSearch(persisted.search);
}
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
setPage(Math.max(1, Math.round(persisted.page)));
}
const legacyPageSize =
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
? clampCardPageSize(persisted.pageSize)
: null;
const regularPageSize =
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
? clampCardPageSize(persisted.regularPageSize)
: legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE;
const compactPageSize =
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
? clampCardPageSize(persisted.compactPageSize)
: legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE;
setPageSizeByMode({
regular: regularPageSize,
compact: compactPageSize,
});
if (isAuthFilesSortMode(persisted.sortMode)) {
setSortMode(persisted.sortMode);
}
}
setUiStateHydrated(true);
}, []);
useEffect(() => {
if (!uiStateHydrated) return;
writeAuthFilesUiState({
filter,
problemOnly,
disabledOnly,
compactMode,
search,
page,
pageSize,
regularPageSize: pageSizeByMode.regular,
compactPageSize: pageSizeByMode.compact,
sortMode,
});
writePersistedAuthFilesCompactMode(compactMode);
}, [
compactMode,
disabledOnly,
filter,
page,
pageSize,
pageSizeByMode,
problemOnly,
search,
sortMode,
uiStateHydrated,
]);
useEffect(() => {
setPageSizeInput(String(pageSize));
}, [pageSize]);
const setCurrentModePageSize = useCallback(
(next: number) => {
setPageSizeByMode((current) =>
compactMode ? { ...current, compact: next } : { ...current, regular: next }
);
},
[compactMode]
);
const commitPageSizeInput = (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed) {
setPageSizeInput(String(pageSize));
return;
}
const value = Number(trimmed);
if (!Number.isFinite(value)) {
setPageSizeInput(String(pageSize));
return;
}
const next = clampCardPageSize(value);
setCurrentModePageSize(next);
setPageSizeInput(String(next));
setPage(1);
};
const handlePageSizeChange = (event: ChangeEvent<HTMLInputElement>) => {
const rawValue = event.currentTarget.value;
setPageSizeInput(rawValue);
const trimmed = rawValue.trim();
if (!trimmed) return;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return;
const rounded = Math.round(parsed);
if (rounded < MIN_CARD_PAGE_SIZE || rounded > MAX_CARD_PAGE_SIZE) return;
setCurrentModePageSize(rounded);
setPage(1);
};
const handleSortModeChange = useCallback(
(value: string) => {
if (!isAuthFilesSortMode(value) || value === sortMode) return;
setSortMode(value);
setPage(1);
void loadFiles().catch(() => {});
},
[loadFiles, sortMode]
);
const handleHeaderRefresh = useCallback(async () => {
await Promise.all([loadFiles(), loadExcluded(), loadModelAlias()]);
}, [loadFiles, loadExcluded, loadModelAlias]);
useHeaderRefresh(handleHeaderRefresh);
useEffect(() => {
if (!isCurrentLayer) return;
loadFiles();
loadExcluded();
loadModelAlias();
}, [isCurrentLayer, loadFiles, loadExcluded, loadModelAlias]);
useInterval(
() => {
void loadFiles().catch(() => {});
},
isCurrentLayer ? 240_000 : null
);
const existingTypes = useMemo(() => {
const types = new Set<string>(['all']);
files.forEach((file) => {
if (file.type) {
types.add(file.type);
}
});
return Array.from(types);
}, [files]);
const filesMatchingStatusFilters = useMemo(
() =>
files.filter((file) => {
if (problemOnly && !hasAuthFileStatusMessage(file)) return false;
if (disabledOnly && file.disabled !== true) return false;
return true;
}),
[disabledOnly, files, problemOnly]
);
const sortOptions = useMemo(
() => [
{ value: 'default', label: t('auth_files.sort_default') },
{ value: 'az', label: t('auth_files.sort_az') },
{ value: 'priority', label: t('auth_files.sort_priority') },
],
[t]
);
const typeCounts = useMemo(() => {
const counts: Record<string, number> = { all: filesMatchingStatusFilters.length };
filesMatchingStatusFilters.forEach((file) => {
if (!file.type) return;
counts[file.type] = (counts[file.type] || 0) + 1;
});
return counts;
}, [filesMatchingStatusFilters]);
const normalizedSearch = search.trim();
const wildcardSearch = useMemo(() => buildWildcardSearch(normalizedSearch), [normalizedSearch]);
const filtered = useMemo(() => {
const normalizedTerm = normalizedSearch.toLowerCase();
return filesMatchingStatusFilters.filter((item) => {
const matchType = filter === 'all' || item.type === filter;
const matchSearch =
!normalizedSearch ||
[item.name, item.type, item.provider].some((value) => {
const content = (value || '').toString();
return wildcardSearch
? wildcardSearch.test(content)
: content.toLowerCase().includes(normalizedTerm);
});
return matchType && matchSearch;
});
}, [filesMatchingStatusFilters, filter, normalizedSearch, wildcardSearch]);
const sorted = useMemo(() => {
const copy = [...filtered];
if (sortMode === 'default') {
copy.sort((a, b) => {
const providerA = normalizeProviderKey(String(a.provider ?? a.type ?? 'unknown'));
const providerB = normalizeProviderKey(String(b.provider ?? b.type ?? 'unknown'));
const providerCompare = providerA.localeCompare(providerB);
if (providerCompare !== 0) return providerCompare;
return a.name.localeCompare(b.name);
});
} else if (sortMode === 'az') {
copy.sort((a, b) => a.name.localeCompare(b.name));
} else if (sortMode === 'priority') {
copy.sort((a, b) => {
const pa = parsePriorityValue(a.priority ?? a['priority']) ?? 0;
const pb = parsePriorityValue(b.priority ?? b['priority']) ?? 0;
return pb - pa; // 高优先级排前面
});
}
return copy;
}, [filtered, sortMode]);
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * pageSize;
const pageItems = sorted.slice(start, start + pageSize);
const selectablePageItems = useMemo(
() => pageItems.filter((file) => !isRuntimeOnlyAuthFile(file)),
[pageItems]
);
const selectableFilteredItems = useMemo(
() => sorted.filter((file) => !isRuntimeOnlyAuthFile(file)),
[sorted]
);
const selectedNames = useMemo(() => Array.from(selectedFiles), [selectedFiles]);
const selectedHasStatusUpdating = useMemo(
() => selectedNames.some((name) => statusUpdating[name] === true),
[selectedNames, statusUpdating]
);
const batchStatusButtonsDisabled =
disableControls ||
selectedNames.length === 0 ||
batchStatusUpdating ||
selectedHasStatusUpdating;
const copyTextWithNotification = useCallback(
async (text: string) => {
const copied = await copyToClipboard(text);
showNotification(
copied
? t('notification.link_copied', { defaultValue: 'Copied to clipboard' })
: t('notification.copy_failed', { defaultValue: 'Copy failed' }),
copied ? 'success' : 'error'
);
},
[showNotification, t]
);
const openExcludedEditor = useCallback(
(provider?: string) => {
const providerValue = (provider || (filter !== 'all' ? String(filter) : '')).trim();
const params = new URLSearchParams();
if (providerValue) {
params.set('provider', providerValue);
}
const nextSearch = params.toString();
navigate(`/auth-files/oauth-excluded${nextSearch ? `?${nextSearch}` : ''}`, {
state: { fromAuthFiles: true },
});
},
[filter, navigate]
);
const openModelAliasEditor = useCallback(
(provider?: string) => {
const providerValue = (provider || (filter !== 'all' ? String(filter) : '')).trim();
const params = new URLSearchParams();
if (providerValue) {
params.set('provider', providerValue);
}
const nextSearch = params.toString();
navigate(`/auth-files/oauth-model-alias${nextSearch ? `?${nextSearch}` : ''}`, {
state: { fromAuthFiles: true },
});
},
[filter, navigate]
);
useLayoutEffect(() => {
if (typeof window === 'undefined') return;
const actionsEl = floatingBatchActionsRef.current;
if (!actionsEl) {
document.documentElement.style.removeProperty('--auth-files-action-bar-height');
return;
}
const updatePadding = () => {
const height = actionsEl.getBoundingClientRect().height;
document.documentElement.style.setProperty('--auth-files-action-bar-height', `${height}px`);
};
updatePadding();
window.addEventListener('resize', updatePadding);
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePadding);
ro?.observe(actionsEl);
return () => {
ro?.disconnect();
window.removeEventListener('resize', updatePadding);
document.documentElement.style.removeProperty('--auth-files-action-bar-height');
};
}, [batchActionBarVisible, selectionCount]);
useEffect(() => {
selectionCountRef.current = selectionCount;
if (selectionCount > 0) {
setBatchActionBarVisible(true);
}
}, [selectionCount]);
useLayoutEffect(() => {
if (!batchActionBarVisible) return;
const currentCount = selectionCount;
const previousCount = previousSelectionCountRef.current;
const actionsEl = floatingBatchActionsRef.current;
if (!actionsEl) return;
batchActionAnimationRef.current?.stop();
batchActionAnimationRef.current = null;
if (currentCount > 0 && previousCount === 0) {
batchActionAnimationRef.current = animate(
actionsEl,
{
transform: [BATCH_BAR_HIDDEN_TRANSFORM, BATCH_BAR_BASE_TRANSFORM],
opacity: [0, 1],
},
{
duration: 0.28,
ease: easePower3Out,
onComplete: () => {
actionsEl.style.transform = BATCH_BAR_BASE_TRANSFORM;
actionsEl.style.opacity = '1';
},
}
);
} else if (currentCount === 0 && previousCount > 0) {
batchActionAnimationRef.current = animate(
actionsEl,
{
transform: [BATCH_BAR_BASE_TRANSFORM, BATCH_BAR_HIDDEN_TRANSFORM],
opacity: [1, 0],
},
{
duration: 0.22,
ease: easePower2In,
onComplete: () => {
if (selectionCountRef.current === 0) {
setBatchActionBarVisible(false);
}
},
}
);
}
previousSelectionCountRef.current = currentCount;
}, [batchActionBarVisible, selectionCount]);
useEffect(
() => () => {
batchActionAnimationRef.current?.stop();
batchActionAnimationRef.current = null;
},
[]
);
const renderFilterTags = () => (
<div className={styles.filterRail}>
<div className={styles.filterTags}>
{existingTypes.map((type) => {
const isActive = filter === type;
const iconSrc = getAuthFileIcon(type, resolvedTheme);
const color =
type === 'all'
? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' }
: getTypeColor(type, resolvedTheme);
const buttonStyle = {
'--filter-color': color.text,
'--filter-surface': color.bg,
'--filter-active-text': resolvedTheme === 'dark' ? '#111827' : '#ffffff',
} as CSSProperties;
return (
<button
key={type}
className={`${styles.filterTag} ${isActive ? styles.filterTagActive : ''}`}
style={buttonStyle}
onClick={() => {
setFilter(type);
setPage(1);
}}
>
<span className={styles.filterTagLabel}>
{type === 'all' ? (
<span className={`${styles.filterTagIconWrap} ${styles.filterAllIconWrap}`}>
<IconFilterAll className={styles.filterAllIcon} size={16} />
</span>
) : (
<span className={styles.filterTagIconWrap}>
{iconSrc ? (
<img src={iconSrc} alt="" className={styles.filterTagIcon} />
) : (
<span className={styles.filterTagIconFallback}>
{getTypeLabel(t, type).slice(0, 1).toUpperCase()}
</span>
)}
</span>
)}
<span className={styles.filterTagText}>{getTypeLabel(t, type)}</span>
</span>
<span className={styles.filterTagCount}>{typeCounts[type] ?? 0}</span>
</button>
);
})}
</div>
</div>
);
const titleNode = (
<div className={styles.titleWrapper}>
<span>{t('auth_files.title_section')}</span>
{files.length > 0 && <span className={styles.countBadge}>{files.length}</span>}
</div>
);
const deleteAllButtonLabel = (() => {
if (disabledOnly) {
return t('auth_files.delete_filtered_result_button');
}
if (problemOnly) {
return filter === 'all'
? t('auth_files.delete_problem_button')
: t('auth_files.delete_problem_button_with_type', { type: getTypeLabel(t, filter) });
}
return filter === 'all'
? t('auth_files.delete_all_button')
: `${t('common.delete')} ${getTypeLabel(t, filter)}`;
})();
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('auth_files.title')}</h1>
<p className={styles.description}>{t('auth_files.description')}</p>
</div>
<Card
title={titleNode}
extra={
<div className={styles.headerActions}>
<Button variant="secondary" size="sm" onClick={handleHeaderRefresh} disabled={loading}>
{t('common.refresh')}
</Button>
<Button
size="sm"
onClick={handleUploadClick}
disabled={disableControls || uploading}
loading={uploading}
>
{t('auth_files.upload_button')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() =>
handleDeleteAll({
filter,
problemOnly,
disabledOnly,
onResetFilterToAll: () => setFilter('all'),
onResetProblemOnly: () => setProblemOnly(false),
onResetDisabledOnly: () => setDisabledOnly(false),
})
}
disabled={disableControls || loading || deletingAll}
loading={deletingAll}
>
{deleteAllButtonLabel}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
}
>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.filterSection}>
{renderFilterTags()}
<div className={styles.filterContent}>
<div className={styles.filterControlsPanel}>
<div className={styles.filterControls}>
<div className={styles.filterItem}>
<label>{t('auth_files.search_label')}</label>
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('auth_files.search_placeholder')}
/>
</div>
<div className={styles.filterItem}>
<label>{t('auth_files.page_size_label')}</label>
<input
className={styles.pageSizeSelect}
type="number"
min={MIN_CARD_PAGE_SIZE}
max={MAX_CARD_PAGE_SIZE}
step={1}
value={pageSizeInput}
onChange={handlePageSizeChange}
onBlur={(e) => commitPageSizeInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
/>
</div>
<div className={styles.filterItem}>
<label>{t('auth_files.sort_label')}</label>
<Select
className={styles.sortSelect}
value={sortMode}
options={sortOptions}
onChange={handleSortModeChange}
ariaLabel={t('auth_files.sort_label')}
fullWidth
/>
</div>
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
<label>{t('auth_files.display_options_label')}</label>
<div className={styles.filterToggleGroup}>
<div className={styles.filterToggleCard}>
<ToggleSwitch
checked={problemOnly}
onChange={(value) => {
setProblemOnly(value);
setPage(1);
}}
ariaLabel={t('auth_files.problem_filter_only')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.problem_filter_only')}
</span>
}
/>
</div>
<div className={styles.filterToggleCard}>
<ToggleSwitch
checked={disabledOnly}
onChange={(value) => {
setDisabledOnly(value);
setPage(1);
}}
ariaLabel={t('auth_files.disabled_filter_only')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.disabled_filter_only')}
</span>
}
/>
</div>
<div className={styles.filterToggleCard}>
<ToggleSwitch
checked={compactMode}
onChange={(value) => setCompactMode(value)}
ariaLabel={t('auth_files.compact_mode_label')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.compact_mode_label')}
</span>
}
/>
</div>
</div>
</div>
</div>
</div>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : pageItems.length === 0 ? (
<EmptyState
title={t('auth_files.search_empty_title')}
description={t('auth_files.search_empty_desc')}
/>
) : (
<div
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''} ${compactMode ? styles.fileGridCompact : ''}`}
>
{pageItems.map((file) => (
<AuthFileCard
key={file.name}
file={file}
compact={compactMode}
selected={selectedFiles.has(file.name)}
resolvedTheme={resolvedTheme}
disableControls={disableControls}
deleting={deleting}
statusUpdating={statusUpdating}
quotaFilterType={quotaFilterType}
statusBarCache={statusBarCache}
onShowModels={showModels}
onDownload={handleDownload}
onOpenPrefixProxyEditor={openPrefixProxyEditor}
onDelete={handleDelete}
onToggleStatus={handleStatusToggle}
onToggleSelect={toggleSelect}
/>
))}
</div>
)}
{!loading && sorted.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: sorted.length,
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</div>
</div>
</Card>
<OAuthExcludedCard
disableControls={disableControls}
excludedError={excludedError}
excluded={excluded}
onAdd={() => openExcludedEditor()}
onEdit={openExcludedEditor}
onDelete={deleteExcluded}
/>
<OAuthModelAliasCard
disableControls={disableControls}
viewMode={viewMode}
onViewModeChange={setViewMode}
onAdd={() => openModelAliasEditor()}
onEditProvider={openModelAliasEditor}
onDeleteProvider={deleteModelAlias}
modelAliasError={modelAliasError}
modelAlias={modelAlias}
allProviderModels={allProviderModels}
onUpdate={handleMappingUpdate}
onDeleteLink={handleDeleteLink}
onToggleFork={handleToggleFork}
onRenameAlias={handleRenameAlias}
onDeleteAlias={handleDeleteAlias}
/>
<AuthFileModelsModal
open={modelsModalOpen}
fileName={modelsFileName}
fileType={modelsFileType}
loading={modelsLoading}
error={modelsError}
models={modelsList}
excluded={excluded}
onClose={closeModelsModal}
onCopyText={copyTextWithNotification}
/>
<AuthFilesPrefixProxyEditorModal
disableControls={disableControls}
editor={prefixProxyEditor}
updatedText={prefixProxyUpdatedText}
dirty={prefixProxyDirty}
onClose={closePrefixProxyEditor}
onCopyText={copyTextWithNotification}
onSave={handlePrefixProxySave}
onChange={handlePrefixProxyChange}
/>
{batchActionBarVisible && typeof document !== 'undefined'
? createPortal(
<div className={styles.batchActionContainer} ref={floatingBatchActionsRef}>
<div className={styles.batchActionBar}>
<div className={styles.batchActionLeft}>
<span className={styles.batchSelectionText}>
{t('auth_files.batch_selected', { count: selectionCount })}
</span>
<Button
variant="secondary"
size="sm"
onClick={() => selectAllVisible(pageItems)}
disabled={selectablePageItems.length === 0}
>
{t('auth_files.batch_select_page')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => selectAllVisible(sorted)}
disabled={selectableFilteredItems.length === 0}
>
{t('auth_files.batch_select_filtered')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => invertVisibleSelection(pageItems)}
disabled={selectablePageItems.length === 0}
>
{t('auth_files.batch_invert_page')}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll}>
{t('auth_files.batch_deselect')}
</Button>
</div>
<div className={styles.batchActionRight}>
<Button
variant="secondary"
size="sm"
onClick={() => void batchDownload(selectedNames)}
disabled={disableControls || selectedNames.length === 0}
>
{t('auth_files.batch_download')}
</Button>
<Button
size="sm"
onClick={() => batchSetStatus(selectedNames, true)}
disabled={batchStatusButtonsDisabled}
>
{t('auth_files.batch_enable')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => batchSetStatus(selectedNames, false)}
disabled={batchStatusButtonsDisabled}
>
{t('auth_files.batch_disable')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => batchDelete(selectedNames)}
disabled={disableControls || selectedNames.length === 0}
>
{t('common.delete')}
</Button>
</div>
</div>
</div>,
document.body
)
: null}
</div>
);
}