mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 02:30:51 +08:00
feat(auth-files): add bulk select, status toggle, and delete actions
This commit is contained in:
@@ -25,6 +25,7 @@ import styles from '@/pages/AuthFilesPage.module.scss';
|
||||
|
||||
export type AuthFileCardProps = {
|
||||
file: AuthFileItem;
|
||||
selected: boolean;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
disableControls: boolean;
|
||||
deleting: string | null;
|
||||
@@ -38,6 +39,7 @@ export type AuthFileCardProps = {
|
||||
onOpenPrefixProxyEditor: (name: string) => void;
|
||||
onDelete: (name: string) => void;
|
||||
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
|
||||
onToggleSelect: (name: string) => void;
|
||||
};
|
||||
|
||||
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||
@@ -50,6 +52,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
file,
|
||||
selected,
|
||||
resolvedTheme,
|
||||
disableControls,
|
||||
deleting,
|
||||
@@ -62,7 +65,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
onDownload,
|
||||
onOpenPrefixProxyEditor,
|
||||
onDelete,
|
||||
onToggleStatus
|
||||
onToggleStatus,
|
||||
onToggleSelect
|
||||
} = props;
|
||||
|
||||
const fileStats = resolveAuthFileStats(file, keyStats);
|
||||
@@ -92,11 +96,20 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.fileCard} ${providerCardClass} ${file.disabled ? styles.fileCardDisabled : ''}`}
|
||||
className={`${styles.fileCard} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
|
||||
>
|
||||
<div className={styles.fileCardLayout}>
|
||||
<div className={styles.fileCardMain}>
|
||||
<div className={styles.cardHeader}>
|
||||
{!isRuntimeOnly && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.selectionCheckbox}
|
||||
checked={selected}
|
||||
onChange={() => onToggleSelect(file.name)}
|
||||
aria-label={t('auth_files.batch_select_all')}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={styles.typeBadge}
|
||||
style={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState, type ChangeEvent, type RefObject } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authFilesApi } from '@/services/api';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
@@ -15,6 +15,8 @@ type DeleteAllOptions = {
|
||||
|
||||
export type UseAuthFilesDataResult = {
|
||||
files: AuthFileItem[];
|
||||
selectedFiles: Set<string>;
|
||||
selectionCount: number;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
uploading: boolean;
|
||||
@@ -29,6 +31,11 @@ export type UseAuthFilesDataResult = {
|
||||
handleDeleteAll: (options: DeleteAllOptions) => void;
|
||||
handleDownload: (name: string) => Promise<void>;
|
||||
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
|
||||
toggleSelect: (name: string) => void;
|
||||
selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
|
||||
deselectAll: () => void;
|
||||
batchSetStatus: (names: string[], enabled: boolean) => Promise<void>;
|
||||
batchDelete: (names: string[]) => void;
|
||||
};
|
||||
|
||||
export type UseAuthFilesDataOptions = {
|
||||
@@ -47,8 +54,50 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const selectionCount = selectedFiles.size;
|
||||
|
||||
const toggleSelect = useCallback((name: string) => {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAllVisible = useCallback((visibleFiles: AuthFileItem[]) => {
|
||||
const nextSelected = visibleFiles
|
||||
.filter((file) => !isRuntimeOnlyAuthFile(file))
|
||||
.map((file) => file.name);
|
||||
setSelectedFiles(new Set(nextSelected));
|
||||
}, []);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
setSelectedFiles(new Set());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
const existingNames = new Set(files.map((file) => file.name));
|
||||
setSelectedFiles((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (existingNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [files, selectedFiles.size]);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -153,6 +202,12 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
await authFilesApi.deleteFile(name);
|
||||
showNotification(t('auth_files.delete_success'), 'success');
|
||||
setFiles((prev) => prev.filter((item) => item.name !== name));
|
||||
setSelectedFiles((prev) => {
|
||||
if (!prev.has(name)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||
@@ -186,6 +241,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
await authFilesApi.deleteAll();
|
||||
showNotification(t('auth_files.delete_all_success'), 'success');
|
||||
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||||
deselectAll();
|
||||
} else {
|
||||
const filesToDelete = files.filter(
|
||||
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
|
||||
@@ -212,6 +268,20 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
}
|
||||
|
||||
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const deletedSet = new Set(deletedNames);
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
if (failed === 0) {
|
||||
showNotification(
|
||||
@@ -235,7 +305,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
}
|
||||
});
|
||||
},
|
||||
[files, showConfirmation, showNotification, t]
|
||||
[deselectAll, files, showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
@@ -299,8 +369,133 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
[showNotification, t]
|
||||
);
|
||||
|
||||
const batchSetStatus = useCallback(
|
||||
async (names: string[], enabled: boolean) => {
|
||||
const uniqueNames = Array.from(new Set(names));
|
||||
if (uniqueNames.length === 0) return;
|
||||
|
||||
const targetNames = new Set(uniqueNames);
|
||||
const nextDisabled = !enabled;
|
||||
|
||||
setFiles((prev) =>
|
||||
prev.map((file) =>
|
||||
targetNames.has(file.name) ? { ...file, disabled: nextDisabled } : file
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
uniqueNames.map((name) => authFilesApi.setStatus(name, nextDisabled))
|
||||
);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const failedNames = new Set<string>();
|
||||
const confirmedDisabled = new Map<string, boolean>();
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const name = uniqueNames[index];
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++;
|
||||
confirmedDisabled.set(name, result.value.disabled);
|
||||
} else {
|
||||
failCount++;
|
||||
failedNames.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
setFiles((prev) =>
|
||||
prev.map((file) => {
|
||||
if (failedNames.has(file.name)) {
|
||||
return { ...file, disabled: !nextDisabled };
|
||||
}
|
||||
if (confirmedDisabled.has(file.name)) {
|
||||
return { ...file, disabled: confirmedDisabled.get(file.name) };
|
||||
}
|
||||
return file;
|
||||
})
|
||||
);
|
||||
|
||||
if (failCount === 0) {
|
||||
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
|
||||
} else {
|
||||
showNotification(
|
||||
t('auth_files.batch_status_partial', { success: successCount, failed: failCount }),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
deselectAll();
|
||||
},
|
||||
[deselectAll, showNotification, t]
|
||||
);
|
||||
|
||||
const batchDelete = useCallback(
|
||||
(names: string[]) => {
|
||||
const uniqueNames = Array.from(new Set(names));
|
||||
if (uniqueNames.length === 0) return;
|
||||
|
||||
showConfirmation({
|
||||
title: t('auth_files.batch_delete_title'),
|
||||
message: t('auth_files.batch_delete_confirm', { count: uniqueNames.length }),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
const results = await Promise.allSettled(
|
||||
uniqueNames.map((name) => authFilesApi.deleteFile(name))
|
||||
);
|
||||
|
||||
const deleted: string[] = [];
|
||||
let failCount = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
deleted.push(uniqueNames[index]);
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (deleted.length > 0) {
|
||||
const deletedSet = new Set(deleted);
|
||||
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
|
||||
}
|
||||
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const deletedSet = new Set(deleted);
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
if (failCount === 0) {
|
||||
showNotification(`${t('auth_files.delete_all_success')} (${deleted.length})`, 'success');
|
||||
} else {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_partial', {
|
||||
success: deleted.length,
|
||||
failed: failCount,
|
||||
type: t('auth_files.filter_all')
|
||||
}),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
return {
|
||||
files,
|
||||
selectedFiles,
|
||||
selectionCount,
|
||||
loading,
|
||||
error,
|
||||
uploading,
|
||||
@@ -314,6 +509,11 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
handleDelete,
|
||||
handleDeleteAll,
|
||||
handleDownload,
|
||||
handleStatusToggle
|
||||
handleStatusToggle,
|
||||
toggleSelect,
|
||||
selectAllVisible,
|
||||
deselectAll,
|
||||
batchSetStatus,
|
||||
batchDelete
|
||||
};
|
||||
}
|
||||
|
||||
@@ -425,6 +425,15 @@
|
||||
"status_toggle_label": "Enabled",
|
||||
"status_enabled_success": "\"{{name}}\" enabled",
|
||||
"status_disabled_success": "\"{{name}}\" disabled",
|
||||
"batch_status_success": "{{count}} files updated successfully",
|
||||
"batch_status_partial": "{{success}} updated, {{failed}} failed",
|
||||
"batch_delete_title": "Delete Selected Files",
|
||||
"batch_delete_confirm": "Are you sure you want to delete {{count}} files?",
|
||||
"batch_selected": "{{count}} selected",
|
||||
"batch_select_all": "Select All",
|
||||
"batch_deselect": "Deselect",
|
||||
"batch_enable": "Enable",
|
||||
"batch_disable": "Disable",
|
||||
"prefix_proxy_button": "Edit Auth Fields",
|
||||
"auth_field_editor_title": "Edit Auth Fields - {{name}}",
|
||||
"prefix_proxy_loading": "Loading auth file...",
|
||||
|
||||
@@ -425,6 +425,15 @@
|
||||
"status_toggle_label": "Включено",
|
||||
"status_enabled_success": "\"{{name}}\" включён",
|
||||
"status_disabled_success": "\"{{name}}\" отключён",
|
||||
"batch_status_success": "{{count}} файлов обновлено",
|
||||
"batch_status_partial": "{{success}} обновлено, {{failed}} не удалось",
|
||||
"batch_delete_title": "Удалить выбранные файлы",
|
||||
"batch_delete_confirm": "Удалить {{count}} файлов?",
|
||||
"batch_selected": "{{count}} выбрано",
|
||||
"batch_select_all": "Выбрать все",
|
||||
"batch_deselect": "Отменить",
|
||||
"batch_enable": "Включить",
|
||||
"batch_disable": "Отключить",
|
||||
"prefix_proxy_button": "Редактировать поля файла авторизации",
|
||||
"auth_field_editor_title": "Редактировать поля файла авторизации - {{name}}",
|
||||
"prefix_proxy_loading": "Загрузка файла авторизации...",
|
||||
|
||||
@@ -425,6 +425,15 @@
|
||||
"status_toggle_label": "启用",
|
||||
"status_enabled_success": "已启用 \"{{name}}\"",
|
||||
"status_disabled_success": "已停用 \"{{name}}\"",
|
||||
"batch_status_success": "已成功更新 {{count}} 个文件",
|
||||
"batch_status_partial": "成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"batch_delete_title": "删除选中文件",
|
||||
"batch_delete_confirm": "确定要删除 {{count}} 个文件吗?",
|
||||
"batch_selected": "已选 {{count}} 项",
|
||||
"batch_select_all": "全选",
|
||||
"batch_deselect": "取消选择",
|
||||
"batch_enable": "启用",
|
||||
"batch_disable": "禁用",
|
||||
"prefix_proxy_button": "编辑认证文件字段",
|
||||
"auth_field_editor_title": "编辑认证文件字段 - {{name}}",
|
||||
"prefix_proxy_loading": "正在加载认证文件...",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
padding-bottom: calc(var(--auth-files-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
@@ -497,6 +498,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.fileCardSelected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px var(--primary-color);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.fileCardDisabled {
|
||||
opacity: 0.6;
|
||||
|
||||
@@ -528,6 +538,15 @@
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.selectionCheckbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.typeBadge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
@@ -878,6 +897,65 @@
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.batchActionContainer {
|
||||
position: fixed;
|
||||
left: var(--content-center-x, 50%);
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
width: min(960px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.batchActionBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
padding: 10px 12px;
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 84%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.batchActionLeft,
|
||||
.batchActionRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.batchActionRight {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.batchSelectionText {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.batchActionContainer {
|
||||
width: calc(100vw - 16px);
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.batchActionBar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.batchActionLeft,
|
||||
.batchActionRight {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
|
||||
import { useCallback, 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 { useInterval } from '@/hooks/useInterval';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
clampCardPageSize,
|
||||
getTypeColor,
|
||||
getTypeLabel,
|
||||
isRuntimeOnlyAuthFile,
|
||||
normalizeProviderKey,
|
||||
type QuotaProviderType,
|
||||
type ResolvedTheme
|
||||
@@ -54,10 +56,13 @@ export function AuthFilesPage() {
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||||
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { keyStats, usageDetails, loadKeyStats } = useAuthFilesStats();
|
||||
const {
|
||||
files,
|
||||
selectedFiles,
|
||||
selectionCount,
|
||||
loading,
|
||||
error,
|
||||
uploading,
|
||||
@@ -71,7 +76,12 @@ export function AuthFilesPage() {
|
||||
handleDelete,
|
||||
handleDeleteAll,
|
||||
handleDownload,
|
||||
handleStatusToggle
|
||||
handleStatusToggle,
|
||||
toggleSelect,
|
||||
selectAllVisible,
|
||||
deselectAll,
|
||||
batchSetStatus,
|
||||
batchDelete
|
||||
} = useAuthFilesData({ refreshKeyStats: loadKeyStats });
|
||||
|
||||
const statusBarCache = useAuthFilesStatusBarCache(files, usageDetails);
|
||||
@@ -240,6 +250,11 @@ export function AuthFilesPage() {
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const pageItems = filtered.slice(start, start + pageSize);
|
||||
const selectablePageItems = useMemo(
|
||||
() => pageItems.filter((file) => !isRuntimeOnlyAuthFile(file)),
|
||||
[pageItems]
|
||||
);
|
||||
const selectedNames = useMemo(() => Array.from(selectedFiles), [selectedFiles]);
|
||||
|
||||
const showDetails = (file: AuthFileItem) => {
|
||||
setSelectedFile(file);
|
||||
@@ -289,6 +304,33 @@ export function AuthFilesPage() {
|
||||
[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');
|
||||
};
|
||||
}, [selectionCount]);
|
||||
|
||||
const renderFilterTags = () => (
|
||||
<div className={styles.filterTags}>
|
||||
{existingTypes.map((type) => {
|
||||
@@ -419,6 +461,7 @@ export function AuthFilesPage() {
|
||||
<AuthFileCard
|
||||
key={file.name}
|
||||
file={file}
|
||||
selected={selectedFiles.has(file.name)}
|
||||
resolvedTheme={resolvedTheme}
|
||||
disableControls={disableControls}
|
||||
deleting={deleting}
|
||||
@@ -432,6 +475,7 @@ export function AuthFilesPage() {
|
||||
onOpenPrefixProxyEditor={openPrefixProxyEditor}
|
||||
onDelete={handleDelete}
|
||||
onToggleStatus={handleStatusToggle}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -520,6 +564,57 @@ export function AuthFilesPage() {
|
||||
onSave={handlePrefixProxySave}
|
||||
onChange={handlePrefixProxyChange}
|
||||
/>
|
||||
|
||||
{selectionCount > 0 && 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_all')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={deselectAll}>
|
||||
{t('auth_files.batch_deselect')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.batchActionRight}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => batchSetStatus(selectedNames, true)}
|
||||
disabled={disableControls || selectedNames.length === 0}
|
||||
>
|
||||
{t('auth_files.batch_enable')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => batchSetStatus(selectedNames, false)}
|
||||
disabled={disableControls || selectedNames.length === 0}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user