diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 8070467..68e90fa 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -200,7 +200,7 @@ "active_users": "{{count}} active users", "active_users_label": "Active users: {{count}}", "image_suffix": "PIC", - "image_response_time": "Image response: {{time}}", + "image_response_time": "Image: {{time}}", "time_range": "Time Range:", "range_today": "Today", "range_7d": "7 Days", @@ -697,6 +697,7 @@ "cache_ratio": "Cache Ratio", "cache_cumulative_ratio": "Cumulative Cache Ratio", "auto_update": "Auto update", + "auto_update_off": "Auto update off", "granularity_hour": "Hourly", "granularity_day": "Daily" }, diff --git a/web/src/i18n/zh.json b/web/src/i18n/zh.json index 9a12dec..5b80906 100644 --- a/web/src/i18n/zh.json +++ b/web/src/i18n/zh.json @@ -200,7 +200,7 @@ "active_users": "{{count}} 活跃用户", "active_users_label": "活跃用户: {{count}}", "image_suffix": "PIC", - "image_response_time": "图片响应时间: {{time}}", + "image_response_time": "图片: {{time}}", "time_range": "时间范围:", "range_today": "今天", "range_7d": "近 7 天", @@ -700,6 +700,7 @@ "cache_ratio": "缓存比例", "cache_cumulative_ratio": "缓存累计比例", "auto_update": "自动更新", + "auto_update_off": "关闭自动更新", "granularity_hour": "按小时", "granularity_day": "按天" }, diff --git a/web/src/pages/admin/AccountsPageContent.tsx b/web/src/pages/admin/AccountsPageContent.tsx index d5f8009..3624643 100644 --- a/web/src/pages/admin/AccountsPageContent.tsx +++ b/web/src/pages/admin/AccountsPageContent.tsx @@ -1,11 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; import { keepPreviousData, useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; -import { AlertDialog, Button, Dropdown, EmptyState, Input, Label, ListBox, Select, Spinner, TextField as HeroTextField } from '@heroui/react'; +import { AlertDialog, Button, EmptyState, Input, Label, ListBox, Select, Spinner, TextField as HeroTextField } from '@heroui/react'; import { Plus, - RefreshCw, - ChevronDown, Search, Download, Upload, @@ -24,11 +22,13 @@ import { import { useCrudMutation } from '../../shared/hooks/useCrudMutation'; import { useDebouncedValue } from '../../shared/hooks/useDebouncedValue'; import { usePagination } from '../../shared/hooks/usePagination'; +import { ADMIN_AUTO_REFRESH_OPTIONS, usePersistentAutoRefresh } from '../../shared/hooks/usePersistentAutoRefresh'; import { queryKeys } from '../../shared/queryKeys'; import { PAGE_SIZE_OPTIONS, FETCH_ALL_PARAMS } from '../../shared/constants'; import { getTotalPages } from '../../shared/utils/pagination'; import { TablePaginationFooter } from '../../shared/components/TablePaginationFooter'; import { DialogTriggerShim } from '../../shared/components/DialogTriggerShim'; +import { AutoRefreshControl } from '../../shared/components/AutoRefreshControl'; import { CreateAccountModal } from './accounts/CreateAccountModal'; import { EditAccountModal } from './accounts/EditAccountModal'; import { AccountTypeFilterSelect } from './accounts/AccountTypeFilterSelect'; @@ -52,7 +52,6 @@ import { AccountSelectionStore, AccountTableRow, AccountsTableLoadingRow, - AutoRefreshCountdownLabel, TableSelectionCheckbox, UNGROUPED_GROUP_FILTER, columnAlignClass, @@ -66,6 +65,8 @@ import { type AccountUsageWindowCache, } from './accounts/AccountPageSupport'; +const ACCOUNT_AUTO_REFRESH_STORAGE_KEY = 'airgate.admin.accounts.auto_refresh'; + export default function AccountsPageContent() { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -131,10 +132,9 @@ export default function AccountsPageContent() { const [proxyFilter, setProxyFilter] = useState(''); // 自动刷新 - const AUTO_REFRESH_OPTIONS = [0, 5, 10, 15, 30]; - const [autoRefresh, setAutoRefresh] = useState(0); // 秒,0=关闭 + const [autoRefresh, setAutoRefresh] = usePersistentAutoRefresh(ACCOUNT_AUTO_REFRESH_STORAGE_KEY, 0, ADMIN_AUTO_REFRESH_OPTIONS); // 秒,0=关闭 const refreshAccounts = useCallback(() => { - queryClient.invalidateQueries({ queryKey: queryKeys.accounts() }); + queryClient.invalidateQueries({ queryKey: queryKeys.accounts() }, { cancelRefetch: false }); }, [queryClient]); const autoRefreshLabel = t('accounts.auto_refresh'); const autoRefreshOffLabel = t('accounts.auto_refresh_off'); @@ -175,7 +175,7 @@ export default function AccountsPageContent() { }, [groupFilter, keyword, page, pageSize, platformFilter, proxyFilter, selectionStore, stateFilter, typeFilter]); // 查询账号列表 - const { data, isLoading } = useQuery({ + const { data, isLoading, isFetching: isAccountsFetching } = useQuery({ queryKey: queryKeys.accounts(page, pageSize, debouncedKeyword, platformFilter, stateFilter, typeFilter, groupFilter, proxyFilter), queryFn: () => accountsApi.list({ @@ -189,6 +189,7 @@ export default function AccountsPageContent() { ungrouped: groupFilter === UNGROUPED_GROUP_FILTER ? true : undefined, proxy_id: proxyFilter ? Number(proxyFilter) : undefined, }), + meta: { globalLoading: false }, placeholderData: keepPreviousData, }); const rows = data?.list ?? []; @@ -750,49 +751,17 @@ export default function AccountsPageContent() {
- - - - - - - - { - const action = String(key); - setAutoRefresh(Number(action.replace('auto_', ''))); - }} - > - {AUTO_REFRESH_OPTIONS.map((sec) => ( - - - {sec === 0 ? t('accounts.auto_refresh_off') : `${t('accounts.auto_refresh')}${sec}s`} - {autoRefresh === sec ? : null} - - - ))} - - - +
- - {t('usage.auto_update')}} - onChange={handleAutoRefreshChange} + refreshAriaLabel={t('common.refresh', 'Refresh')} + onChange={setAutoRefresh} + onAutoRefresh={handleAutoRefresh} + onRefresh={handleManualRefresh} + isAutoRefreshing={isUsageTableRefreshing} + isRefreshing={isRefreshing} + isDisabled={!pageActive} /> @@ -940,7 +934,7 @@ export default function UsagePage() { dataVersion={pageActive ? dataUpdatedAt : undefined} emptyDescription={t('usage.empty_description', '调整筛选条件后重试')} emptyTitle={t('common.no_data')} - highlightNewRows={pageActive && autoRefresh && page === 1} + highlightNewRows={pageActive && autoRefreshEnabled && page === 1} highlightResetKey={JSON.stringify({ ...filters, page, pageSize })} isLoading={!pageActive || isLoading} page={page} diff --git a/web/src/pages/admin/accounts/AccountPageSupport.tsx b/web/src/pages/admin/accounts/AccountPageSupport.tsx index a2a9d0d..0207056 100644 --- a/web/src/pages/admin/accounts/AccountPageSupport.tsx +++ b/web/src/pages/admin/accounts/AccountPageSupport.tsx @@ -602,56 +602,6 @@ export function AccountsTableLoadingRow({ colSpan, minHeight = 220 }: { colSpan: ); } -export const AutoRefreshCountdownLabel = memo(function AutoRefreshCountdownLabel({ - autoRefresh, - label, - offLabel, - onRefresh, -}: { - autoRefresh: number; - label: string; - offLabel: string; - onRefresh: () => void; -}) { - const labelRef = useRef(null); - - useEffect(() => { - const renderCountdown = (seconds: number) => { - if (labelRef.current) { - labelRef.current.textContent = autoRefresh ? `${label}${seconds}s` : offLabel; - } - }; - - if (!autoRefresh) { - renderCountdown(0); - return undefined; - } - - let remaining = autoRefresh; - renderCountdown(remaining); - const timer = window.setInterval(() => { - remaining -= 1; - if (remaining <= 0) { - onRefresh(); - remaining = autoRefresh; - } - renderCountdown(remaining); - }, 1000); - return () => window.clearInterval(timer); - }, [autoRefresh, label, offLabel, onRefresh]); - - return ( - - {autoRefresh ? `${label}${autoRefresh}s` : offLabel} - - ); -}, (prev, next) => ( - prev.autoRefresh === next.autoRefresh - && prev.label === next.label - && prev.offLabel === next.offLabel - && prev.onRefresh === next.onRefresh -)); - // formatCountdown 把剩余毫秒格式化成 "Xd Yh"/"Xh Ym"/"Ym" 样式, // 与 sub2api 的"限流中 10h 16m 自动恢复"徽标一致。 function formatCountdown(ms: number): string { diff --git a/web/src/pages/user/UserUsageContent.tsx b/web/src/pages/user/UserUsageContent.tsx index d509fe9..a8177e7 100644 --- a/web/src/pages/user/UserUsageContent.tsx +++ b/web/src/pages/user/UserUsageContent.tsx @@ -6,11 +6,10 @@ import { usageApi } from '../../shared/api/usage'; import { apikeysApi } from '../../shared/api/apikeys'; import { queryKeys } from '../../shared/queryKeys'; import { usePagination } from '../../shared/hooks/usePagination'; -import { usePersistentBoolean } from '../../shared/hooks/usePersistentBoolean'; import { usePlatforms } from '../../shared/hooks/usePlatforms'; import { useAuth } from '../../app/providers/AuthProvider'; import { useToast } from '../../shared/ui'; -import { Activity, Hash, DollarSign, Coins, Clock, Gauge, Percent, Upload, RefreshCw } from 'lucide-react'; +import { Activity, Hash, DollarSign, Coins, Clock, Gauge, Percent, Upload } from 'lucide-react'; import type { UsageQuery } from '../../shared/types'; import { useUsageColumns, fmtNum, type UsageColumnConfig, type UsageRow } from '../../shared/columns/usageColumns'; import { getSessionAPIKey } from '../../shared/api/client'; @@ -19,10 +18,10 @@ import { UsageRecordsTable } from '../../shared/components/UsageRecordsTable'; import { UsageDateRangeFilter } from '../../shared/components/UsageDateRangeFilter'; import { UsageModelFilterInput } from '../../shared/components/UsageModelFilterInput'; import { CostValue } from '../../shared/components/CostValue'; -import { NativeSwitch } from '../../shared/components/NativeSwitch'; +import { AutoRefreshControl } from '../../shared/components/AutoRefreshControl'; import { FETCH_ALL_PARAMS } from '../../shared/constants'; +import { USER_AUTO_REFRESH_OPTIONS, usePersistentAutoRefresh } from '../../shared/hooks/usePersistentAutoRefresh'; -const USAGE_AUTO_UPDATE_INTERVAL_MS = 3_000; const USER_USAGE_AUTO_UPDATE_STORAGE_KEY = 'airgate.user.usage.auto_update'; function StatCard({ @@ -188,8 +187,10 @@ export default function UserUsageContent() { const customerScope = !!user?.api_key_id; const { page, setPage, pageSize, setPageSize } = usePagination(20, 'user.usage'); const [filters, setFilters] = useState>({}); - const [autoRefresh, setAutoRefresh] = usePersistentBoolean(USER_USAGE_AUTO_UPDATE_STORAGE_KEY, false); - const autoRefreshInterval = autoRefresh ? USAGE_AUTO_UPDATE_INTERVAL_MS : false; + const [autoRefresh, setAutoRefresh] = usePersistentAutoRefresh(USER_USAGE_AUTO_UPDATE_STORAGE_KEY, 0, USER_AUTO_REFRESH_OPTIONS); + const autoRefreshEnabled = autoRefresh > 0; + const autoRefreshLabel = `${t('usage.auto_update')} `; + const autoRefreshOffLabel = t('usage.auto_update_off', '关闭自动更新'); const handleModelChange = useCallback((model: string) => { const nextModel = model || undefined; @@ -231,10 +232,9 @@ export default function UserUsageContent() { } = useQuery({ queryKey: queryKeys.userUsage(queryParams), queryFn: ({ signal }) => usageApi.list(queryParams, { signal }), - refetchInterval: autoRefreshInterval, - refetchIntervalInBackground: false, - refetchOnReconnect: autoRefresh, - refetchOnWindowFocus: autoRefresh, + meta: { globalLoading: false }, + refetchOnReconnect: autoRefreshEnabled, + refetchOnWindowFocus: autoRefreshEnabled, placeholderData: keepPreviousData, }); @@ -242,25 +242,22 @@ export default function UserUsageContent() { const { data: stats, isFetching: isStatsFetching, refetch: refetchStats } = useQuery({ queryKey: queryKeys.userUsageStats(filters), queryFn: ({ signal }) => usageApi.userStats(filters, { signal }), - refetchInterval: autoRefreshInterval, - refetchIntervalInBackground: false, - refetchOnReconnect: autoRefresh, - refetchOnWindowFocus: autoRefresh, + meta: { globalLoading: false }, + refetchOnReconnect: false, + refetchOnWindowFocus: false, }); const isRefreshing = isUsageFetching || isStatsFetching; + const isUsageTableRefreshing = isUsageFetching; - function handleManualRefresh() { - void refetchUsage(); - void refetchStats(); - } + const handleManualRefresh = useCallback(() => { + void refetchUsage({ cancelRefetch: false }); + void refetchStats({ cancelRefetch: false }); + }, [refetchStats, refetchUsage]); - function handleAutoRefreshChange(enabled: boolean) { - setAutoRefresh(enabled); - if (enabled) { - handleManualRefresh(); - } - } + const handleAutoRefresh = useCallback(() => { + void refetchUsage({ cancelRefetch: false }); + }, [refetchUsage]); function updateFilter(key: string, value: string) { const nextValue = key === 'api_key_id' && value ? Number(value) : value || undefined; @@ -439,22 +436,18 @@ export default function UserUsageContent() { onModelChange={handleModelChange} /> - - {t('usage.auto_update')}} - onChange={handleAutoRefreshChange} + refreshAriaLabel={t('common.refresh', 'Refresh')} + onChange={setAutoRefresh} + onAutoRefresh={handleAutoRefresh} + onRefresh={handleManualRefresh} + isAutoRefreshing={isUsageTableRefreshing} + isRefreshing={isRefreshing} /> @@ -465,7 +458,7 @@ export default function UserUsageContent() { dataVersion={dataUpdatedAt} emptyDescription={t('usage.empty_description', '调整筛选条件后重试')} emptyTitle={t('common.no_data')} - highlightNewRows={autoRefresh && page === 1} + highlightNewRows={autoRefreshEnabled && page === 1} highlightResetKey={JSON.stringify({ ...filters, page, pageSize })} isLoading={isLoading} page={page} diff --git a/web/src/shared/components/AutoRefreshControl.tsx b/web/src/shared/components/AutoRefreshControl.tsx new file mode 100644 index 0000000..c40a2b9 --- /dev/null +++ b/web/src/shared/components/AutoRefreshControl.tsx @@ -0,0 +1,199 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Dropdown } from '@heroui/react'; +import { Check, ChevronDown, RefreshCw } from 'lucide-react'; +import { normalizeAutoRefresh, type AutoRefreshOptions } from '../hooks/usePersistentAutoRefresh'; + +interface AutoRefreshControlProps { + value: number; + options: AutoRefreshOptions; + label: string; + offLabel: string; + ariaLabel: string; + refreshAriaLabel: string; + onChange: (value: number) => void; + onAutoRefresh?: () => void | Promise; + onRefresh: () => void | Promise; + isRefreshing?: boolean; + isAutoRefreshing?: boolean; + isDisabled?: boolean; +} + +function useAutoRefreshCountdown({ + active, + isRefreshing, + onRefresh, + resetKey, + seconds, +}: { + active: boolean; + isRefreshing: boolean; + onRefresh: () => void | Promise; + resetKey: number; + seconds: number; +}) { + const [remainingSeconds, setRemainingSeconds] = useState(seconds); + const onRefreshRef = useRef(onRefresh); + const isRefreshingRef = useRef(isRefreshing); + + useEffect(() => { + onRefreshRef.current = onRefresh; + }, [onRefresh]); + + useEffect(() => { + isRefreshingRef.current = isRefreshing; + }, [isRefreshing]); + + useEffect(() => { + if (!active || seconds <= 0 || typeof window === 'undefined') { + setRemainingSeconds(seconds); + return undefined; + } + + const intervalMs = seconds * 1000; + let disposed = false; + let timeoutId: number | undefined; + let nextRefreshAt = Date.now() + intervalMs; + + const clearTimer = () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + timeoutId = undefined; + } + }; + + const documentHidden = () => typeof document !== 'undefined' && document.visibilityState === 'hidden'; + + const scheduleNextTick = () => { + if (disposed) return; + clearTimer(); + + if (documentHidden()) { + setRemainingSeconds(seconds); + return; + } + + const msLeft = Math.max(0, nextRefreshAt - Date.now()); + setRemainingSeconds(Math.max(1, Math.ceil(msLeft / 1000))); + timeoutId = window.setTimeout(runTick, Math.min(1000, msLeft)); + }; + + const runTick = () => { + if (disposed) return; + + const now = Date.now(); + if (now >= nextRefreshAt) { + if (isRefreshingRef.current) { + nextRefreshAt = now + 1000; + } else { + void onRefreshRef.current(); + nextRefreshAt = Date.now() + intervalMs; + } + } + + scheduleNextTick(); + }; + + const handleVisibilityChange = () => { + if (documentHidden()) { + clearTimer(); + setRemainingSeconds(seconds); + return; + } + nextRefreshAt = Date.now() + intervalMs; + scheduleNextTick(); + }; + + setRemainingSeconds(seconds); + scheduleNextTick(); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + disposed = true; + clearTimer(); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [active, resetKey, seconds]); + + return remainingSeconds; +} + +export const AutoRefreshControl = memo(function AutoRefreshControl({ + value, + options, + label, + offLabel, + ariaLabel, + refreshAriaLabel, + onChange, + onAutoRefresh, + onRefresh, + isAutoRefreshing, + isRefreshing = false, + isDisabled = false, +}: AutoRefreshControlProps) { + const enabled = value > 0; + const [manualRefreshVersion, setManualRefreshVersion] = useState(0); + const autoRefreshHandler = onAutoRefresh ?? onRefresh; + const remainingSeconds = useAutoRefreshCountdown({ + active: enabled && !isDisabled, + isRefreshing: isAutoRefreshing ?? isRefreshing, + onRefresh: autoRefreshHandler, + resetKey: manualRefreshVersion, + seconds: value, + }); + const selectedKeys = useMemo(() => new Set([`auto_${value}`]), [value]); + const currentLabel = enabled ? `${label}${remainingSeconds}s` : offLabel; + const optionLabel = (seconds: number) => (seconds === 0 ? offLabel : `${label}${seconds}s`); + const handleRefresh = useCallback(() => { + void onRefresh(); + if (enabled) { + setManualRefreshVersion((version) => version + 1); + } + }, [enabled, onRefresh]); + + return ( + <> + + + + {currentLabel} + + + + { + onChange(normalizeAutoRefresh(String(key).replace('auto_', ''), options)); + }} + > + {options.map((seconds) => { + const itemLabel = optionLabel(seconds); + return ( + + + {itemLabel} + {value === seconds ? : null} + + + ); + })} + + + + + ); +}); diff --git a/web/src/shared/components/UsageRecordsTable.tsx b/web/src/shared/components/UsageRecordsTable.tsx index e812352..a5bfd8f 100644 --- a/web/src/shared/components/UsageRecordsTable.tsx +++ b/web/src/shared/components/UsageRecordsTable.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type AnimationEvent, type CSSProperties, type ReactNode } from 'react'; import { EmptyState } from '@heroui/react'; import { Inbox } from 'lucide-react'; import type { UsageColumnConfig, UsageRow } from '../columns/usageColumns'; @@ -8,7 +8,7 @@ import { TablePaginationFooter } from './TablePaginationFooter'; const FULL_CELL_CONTENT_COLUMNS = new Set(['cost', 'tokens']); const LEFT_ALIGNED_CONTENT_COLUMNS = new Set(['model']); -const NEW_ROW_MARK_DURATION_MS = 5000; +const NEW_ROW_ANIMATION_NAME = 'ag-usage-row-new-enter'; function cx(...classes: Array) { return classes.filter(Boolean).join(' '); @@ -47,21 +47,21 @@ function useNewRowMarkers({ const rowIds = useMemo(() => rows.map((row) => String(row.id)), [rows]); const previousRowIdsRef = useRef | null>(null); const previousResetKeyRef = useRef(undefined); - const batchClearTimerRef = useRef(null); const [markedRowIds, setMarkedRowIds] = useState>(() => new Set()); - useEffect(() => () => { - if (batchClearTimerRef.current != null) { - window.clearTimeout(batchClearTimerRef.current); - } + const clearMarkedRowId = useCallback((rowId: string) => { + setMarkedRowIds((current) => { + if (!current.has(rowId)) { + return current; + } + const next = new Set(current); + next.delete(rowId); + return next; + }); }, []); useEffect(() => { const clearActiveBatch = () => { - if (batchClearTimerRef.current != null) { - window.clearTimeout(batchClearTimerRef.current); - batchClearTimerRef.current = null; - } setMarkedRowIds((current) => (current.size === 0 ? current : new Set())); }; @@ -93,33 +93,36 @@ function useNewRowMarkers({ return; } - if (batchClearTimerRef.current != null) { - window.clearTimeout(batchClearTimerRef.current); - } setMarkedRowIds(new Set(addedIds)); - batchClearTimerRef.current = window.setTimeout(() => { - batchClearTimerRef.current = null; - setMarkedRowIds(new Set()); - }, NEW_ROW_MARK_DURATION_MS); }, [dataVersion, enabled, paused, resetKey, rowIds]); - return markedRowIds; + return { clearMarkedRowId, markedRowIds }; } const UsageTableRow = memo(function UsageTableRow({ columns, isNew, + onNewAnimationEnd, row, }: { columns: UsageColumnConfig[]; isNew: boolean; + onNewAnimationEnd: (rowId: string) => void; row: UsageRow; }) { + const rowId = String(row.id); + const handleAnimationEnd = (event: AnimationEvent) => { + if (event.animationName === NEW_ROW_ANIMATION_NAME) { + onNewAnimationEnd(rowId); + } + }; + return ( {columns.map((column) => { const fullCellContent = FULL_CELL_CONTENT_COLUMNS.has(column.key); @@ -200,7 +203,7 @@ export function UsageRecordsTable({ }) as CSSProperties, [tableMinWidth, tableMobileWidthDelta], ); - const markedRowIds = useNewRowMarkers({ + const { clearMarkedRowId, markedRowIds } = useNewRowMarkers({ dataVersion, enabled: highlightNewRows, paused: isLoading || suppressHighlight, @@ -267,6 +270,7 @@ export function UsageRecordsTable({ key={row.id} columns={columns as UsageColumnConfig[]} isNew={markedRowIds.has(String(row.id))} + onNewAnimationEnd={clearMarkedRowId} row={row} /> ))} diff --git a/web/src/shared/hooks/usePersistentAutoRefresh.ts b/web/src/shared/hooks/usePersistentAutoRefresh.ts new file mode 100644 index 0000000..e53113e --- /dev/null +++ b/web/src/shared/hooks/usePersistentAutoRefresh.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react'; + +export const ADMIN_AUTO_REFRESH_OPTIONS = [0, 1, 3, 5, 15, 30] as const; +export const USER_AUTO_REFRESH_OPTIONS = [0, 5, 10, 15, 30] as const; + +export type AutoRefreshOptions = readonly number[]; +type AutoRefreshValueInput = string | number | boolean | null | undefined | ((previous: number) => unknown); +export type SetAutoRefreshValue = (value: AutoRefreshValueInput) => void; + +function defaultEnabledOption(options: AutoRefreshOptions): number { + return options.find((option) => option > 0) ?? 0; +} + +export function normalizeAutoRefresh(value: unknown, options: AutoRefreshOptions = ADMIN_AUTO_REFRESH_OPTIONS): number { + if (value === true || value === 'true') { + return defaultEnabledOption(options); + } + if (value === false || value === 'false' || value == null) { + return 0; + } + + const parsed = typeof value === 'number' ? value : Number(value); + return options.includes(parsed) ? parsed : 0; +} + +export function usePersistentAutoRefresh(key: string, defaultValue = 0, options: AutoRefreshOptions = ADMIN_AUTO_REFRESH_OPTIONS) { + const [value, setValue] = useState(() => { + const normalizedDefault = normalizeAutoRefresh(defaultValue, options); + if (typeof window === 'undefined') return normalizedDefault; + try { + const stored = window.localStorage.getItem(key); + if (stored == null) return normalizedDefault; + return normalizeAutoRefresh(stored, options); + } catch { + return normalizedDefault; + } + }); + const setNormalizedValue = useCallback((nextValue) => { + setValue((previous) => { + const resolvedValue = typeof nextValue === 'function' ? nextValue(previous) : nextValue; + return normalizeAutoRefresh(resolvedValue, options); + }); + }, [options]); + + useEffect(() => { + setValue((previous) => normalizeAutoRefresh(previous, options)); + }, [options]); + + useEffect(() => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(key, String(value)); + } catch { + // localStorage can be unavailable in restricted browser modes. + } + }, [key, value]); + + return [value, setNormalizedValue] as const; +} diff --git a/web/src/styles/heroui.css b/web/src/styles/heroui.css index 3b63fbe..9fec95d 100644 --- a/web/src/styles/heroui.css +++ b/web/src/styles/heroui.css @@ -372,9 +372,9 @@ .ag-usage-date-range .date-input-group { display: grid !important; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) 1.625rem; + grid-template-columns: max-content auto max-content minmax(0, 1fr); align-items: center; - column-gap: 0.125rem; + column-gap: 0.375rem; overflow: hidden; } @@ -396,6 +396,7 @@ flex: 0 0 1.625rem; align-items: center; justify-content: center; + justify-self: end; width: 1.625rem; min-width: 1.625rem; gap: 0;