diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index 452d56a..5561275 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -391,7 +391,10 @@ export function PageTransition({ } > {render(layer.location)} diff --git a/src/components/common/PageTransitionLayer.ts b/src/components/common/PageTransitionLayer.ts index 98f5898..0ab66c3 100644 --- a/src/components/common/PageTransitionLayer.ts +++ b/src/components/common/PageTransitionLayer.ts @@ -5,15 +5,16 @@ export type LayerStatus = 'current' | 'exiting' | 'stacked'; export type PageTransitionLayerContextValue = { status: LayerStatus; isCurrentLayer: boolean; + isAnimating: boolean; }; export const PageTransitionLayerContext = createContext(null); export const PAGE_TRANSITION_LAYER_CONTEXT_VALUES: Record = { - current: { status: 'current', isCurrentLayer: true }, - stacked: { status: 'stacked', isCurrentLayer: false }, - exiting: { status: 'exiting', isCurrentLayer: false }, + current: { status: 'current', isCurrentLayer: true, isAnimating: false }, + stacked: { status: 'stacked', isCurrentLayer: false, isAnimating: false }, + exiting: { status: 'exiting', isCurrentLayer: false, isAnimating: false }, }; export function usePageTransitionLayer() { diff --git a/src/components/providers/ClaudeSection/ClaudeSection.tsx b/src/components/providers/ClaudeSection/ClaudeSection.tsx index abb4a07..20aa817 100644 --- a/src/components/providers/ClaudeSection/ClaudeSection.tsx +++ b/src/components/providers/ClaudeSection/ClaudeSection.tsx @@ -91,8 +91,8 @@ export function ClaudeSection({ keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.claude_empty_title')} emptyDescription={t('ai_providers.claude_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(_, index) => onEdit(index)} + onDelete={(_, index) => onDelete(index)} actionsDisabled={actionsDisabled} getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)} renderExtraActions={(item, index) => ( diff --git a/src/components/providers/CodexSection/CodexSection.tsx b/src/components/providers/CodexSection/CodexSection.tsx index 1245357..7db1150 100644 --- a/src/components/providers/CodexSection/CodexSection.tsx +++ b/src/components/providers/CodexSection/CodexSection.tsx @@ -91,8 +91,8 @@ export function CodexSection({ keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.codex_empty_title')} emptyDescription={t('ai_providers.codex_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(_, index) => onEdit(index)} + onDelete={(_, index) => onDelete(index)} actionsDisabled={actionsDisabled} getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)} renderExtraActions={(item, index) => ( diff --git a/src/components/providers/GeminiSection/GeminiSection.tsx b/src/components/providers/GeminiSection/GeminiSection.tsx index a551311..48b141e 100644 --- a/src/components/providers/GeminiSection/GeminiSection.tsx +++ b/src/components/providers/GeminiSection/GeminiSection.tsx @@ -91,8 +91,8 @@ export function GeminiSection({ keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.gemini_empty_title')} emptyDescription={t('ai_providers.gemini_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(_, index) => onEdit(index)} + onDelete={(_, index) => onDelete(index)} actionsDisabled={actionsDisabled} getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)} renderExtraActions={(item, index) => ( diff --git a/src/components/providers/OpenAISection/OpenAISection.tsx b/src/components/providers/OpenAISection/OpenAISection.tsx index 6b9315d..7b7c766 100644 --- a/src/components/providers/OpenAISection/OpenAISection.tsx +++ b/src/components/providers/OpenAISection/OpenAISection.tsx @@ -1,25 +1,41 @@ -import { Fragment, useMemo } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; +import { EmptyState } from '@/components/ui/EmptyState'; import { IconCheck, IconX } from '@/components/ui/icons'; import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import type { OpenAIProviderConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; +import { + calculateStatusBarData, + type KeyStats, +} from '@/utils/usage'; import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; -import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; +import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'; import { collectOpenAIProviderUsageDetails, - getOpenAIEntryKey, getOpenAIProviderKey, getOpenAIProviderStats, getStatsForIdentity, } from '../utils'; +type SortOption = 'name' | 'priority' | 'recent-success'; +type SortDirection = 'asc' | 'desc'; + +interface FloatingToolbarStyle { + left: number; + top: number; + width: number; + visible: boolean; +} + +const EMPTY_STATUS_BAR = calculateStatusBarData([]); + interface OpenAISectionProps { configs: OpenAIProviderConfig[]; keyStats: KeyStats; @@ -34,6 +50,11 @@ interface OpenAISectionProps { onDelete: (index: number) => void; } +interface IndexedOpenAIProvider { + config: OpenAIProviderConfig; + originalIndex: number; +} + export function OpenAISection({ configs, keyStats, @@ -48,7 +69,152 @@ export function OpenAISection({ onDelete, }: OpenAISectionProps) { const { t } = useTranslation(); + const pageTransitionLayer = usePageTransitionLayer(); + const isTransitionAnimating = pageTransitionLayer?.isAnimating ?? false; const actionsDisabled = disableControls || loading || isSwitching; + const [sortOption, setSortOption] = useState('priority'); + const [sortDirection, setSortDirection] = useState('asc'); + const [selectedModels, setSelectedModels] = useState>(new Set()); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [dropdownLayout, setDropdownLayout] = useState({ openAbove: false, maxHeight: 300 }); + const [floatingToolbarStyle, setFloatingToolbarStyle] = useState({ + left: 0, + top: 0, + width: 0, + visible: false, + }); + const sectionRef = useRef(null); + const topToolbarAnchorRef = useRef(null); + const topDropdownRef = useRef(null); + const floatingDropdownRef = useRef(null); + + const shouldRenderFloatingToolbar = !isTransitionAnimating && floatingToolbarStyle.visible; + + useEffect(() => { + if (isTransitionAnimating) { + return; + } + + const updateFloatingToolbar = () => { + const section = sectionRef.current; + const anchor = topToolbarAnchorRef.current; + + if (!section || !anchor) { + return; + } + + const sectionRect = section.getBoundingClientRect(); + const anchorRect = anchor.getBoundingClientRect(); + const rootStyles = getComputedStyle(document.documentElement); + const fixedTop = Number.parseFloat(rootStyles.getPropertyValue('--header-height')) || 64; + const toolbarHeight = anchorRect.height; + const isMobile = window.innerWidth <= 768; + const shouldShow = !isMobile && anchorRect.top <= fixedTop && sectionRect.bottom > fixedTop + toolbarHeight; + + setFloatingToolbarStyle((prev) => { + const next = { + left: sectionRect.left, + top: fixedTop, + width: sectionRect.width, + visible: shouldShow, + }; + + if ( + prev.left === next.left && + prev.top === next.top && + prev.width === next.width && + prev.visible === next.visible + ) { + return prev; + } + + return next; + }); + }; + + updateFloatingToolbar(); + window.addEventListener('resize', updateFloatingToolbar); + window.addEventListener('scroll', updateFloatingToolbar, true); + + return () => { + window.removeEventListener('resize', updateFloatingToolbar); + window.removeEventListener('scroll', updateFloatingToolbar, true); + }; + }, [configs.length, isDropdownOpen, isTransitionAnimating, selectedModels, sortDirection, sortOption]); + + useEffect(() => { + if (!isDropdownOpen) { + return; + } + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + const clickedTop = topDropdownRef.current?.contains(target); + const clickedFloating = floatingDropdownRef.current?.contains(target); + + if (!clickedTop && !clickedFloating) { + setIsDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isDropdownOpen]); + + useEffect(() => { + if (!isDropdownOpen) { + return; + } + + const updateDropdownLayout = () => { + const wrapper = floatingToolbarStyle.visible + ? floatingDropdownRef.current + : topDropdownRef.current; + + if (!wrapper) { + return; + } + + const rect = wrapper.getBoundingClientRect(); + const viewportPadding = 12; + const dropdownGap = 4; + const preferredMaxHeight = 300; + const minimumMaxHeight = 120; + const availableBelow = Math.max(0, window.innerHeight - rect.bottom - viewportPadding - dropdownGap); + const availableAbove = Math.max(0, rect.top - viewportPadding - dropdownGap); + const openAbove = availableBelow < preferredMaxHeight && availableAbove > availableBelow; + const availableSpace = openAbove ? availableAbove : availableBelow; + const maxHeight = Math.max(minimumMaxHeight, Math.min(preferredMaxHeight, availableSpace)); + + setDropdownLayout((prev) => { + if (prev.openAbove === openAbove && prev.maxHeight === maxHeight) { + return prev; + } + + return { openAbove, maxHeight }; + }); + }; + + updateDropdownLayout(); + window.addEventListener('resize', updateDropdownLayout); + window.addEventListener('scroll', updateDropdownLayout, true); + + return () => { + window.removeEventListener('resize', updateDropdownLayout); + window.removeEventListener('scroll', updateDropdownLayout, true); + }; + }, [floatingToolbarStyle.visible, isDropdownOpen]); + + const allModelNames = useMemo(() => { + const modelSet = new Set(); + configs.forEach((provider) => { + provider.models?.forEach((model) => { + if (model.name) { + modelSet.add(model.name); + } + }); + }); + return Array.from(modelSet).sort(); + }, [configs]); const statusBarCache = useMemo(() => { const cache = new Map>(); @@ -70,144 +236,395 @@ export function OpenAISection({ return cache; }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); + const sortedConfigs = useMemo(() => { + const indexed = configs.map((config, originalIndex) => ({ config, originalIndex })); + const filtered = indexed.filter(({ config }) => { + if (selectedModels.size === 0) return true; + return config.models?.some((model) => selectedModels.has(model.name)); + }); + + const sorted = [...filtered]; + const direction = sortDirection === 'desc' ? -1 : 1; + const providerStats = + sortOption === 'recent-success' + ? new Map( + sorted.map(({ config }) => [ + config, + getOpenAIProviderStats(config, keyStats), + ]) + ) + : null; + + switch (sortOption) { + case 'name': + sorted.sort((a, b) => direction * a.config.name.localeCompare(b.config.name)); + break; + case 'priority': + sorted.sort((a, b) => { + const priorityA = a.config.priority ?? Number.MAX_SAFE_INTEGER; + const priorityB = b.config.priority ?? Number.MAX_SAFE_INTEGER; + const priorityDiff = priorityA - priorityB; + + if (priorityDiff !== 0) { + return direction * priorityDiff; + } + + return direction * a.config.name.localeCompare(b.config.name); + }); + break; + case 'recent-success': + sorted.sort((a, b) => { + const successDiff = + (providerStats?.get(a.config)?.success ?? 0) - (providerStats?.get(b.config)?.success ?? 0); + + if (successDiff !== 0) { + return direction * successDiff; + } + + return direction * a.config.name.localeCompare(b.config.name); + }); + break; + default: + break; + } + + return sorted; + }, [configs, sortOption, sortDirection, keyStats, selectedModels]); + + const toggleModelSelection = (modelName: string) => { + setSelectedModels((prev) => { + const next = new Set(prev); + if (next.has(modelName)) { + next.delete(modelName); + } else { + next.add(modelName); + } + return next; + }); + }; + + const clearAllModels = () => { + setSelectedModels(new Set()); + }; + + const handleSortOptionChange = (value: SortOption) => { + setSortOption(value); + }; + + const toggleSortDirection = () => { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + }; + + const toggleDropdown = () => setIsDropdownOpen((prev) => !prev); + + const renderSortControls = () => ( +
+ + +
+ ); + + const renderToolbar = (isFloating = false) => { + const isActiveToolbar = isFloating === shouldRenderFloatingToolbar; + const dropdownClassName = + dropdownLayout.openAbove ? `${styles.modelDropdownList} ${styles.modelDropdownListAbove}` : styles.modelDropdownList; + + return ( +
+
+ + + {isActiveToolbar && isDropdownOpen && ( +
+
+ + {selectedModels.size > 0 && ( + + )} +
+ {allModelNames.map((name) => ( + + ))} +
+ )} +
+ {selectedModels.size > 0 && ( + + )} + {renderSortControls()} + +
+ ); + }; + + const renderStaticTitle = () => ( + + + {t('ai_providers.openai_title')} + + ); + + const renderProviderCard = ({ config: provider, originalIndex }: IndexedOpenAIProvider) => { + const stats = getOpenAIProviderStats(provider, keyStats); + const headerEntries = Object.entries(provider.headers || {}); + const apiKeyEntries = provider.apiKeyEntries || []; + const statusData = statusBarCache.get(getOpenAIProviderKey(provider, originalIndex)) || EMPTY_STATUS_BAR; + + return ( +
+
+
{provider.name}
+ {provider.priority !== undefined && ( +
+ {t('common.priority')}: + {provider.priority} +
+ )} + {provider.prefix && ( +
+ {t('common.prefix')}: + {provider.prefix} +
+ )} +
+ {t('common.base_url')}: + {provider.baseUrl} +
+ {headerEntries.length > 0 && ( +
+ {headerEntries.map(([key, value]) => ( + + {key}: {value} + + ))} +
+ )} + {apiKeyEntries.length > 0 && ( +
+
+ {t('ai_providers.openai_keys_count')}: {apiKeyEntries.length} +
+
+ {apiKeyEntries.map((entry, entryIndex) => { + const entryStats = getStatsForIdentity( + { authIndex: entry.authIndex, apiKey: entry.apiKey }, + keyStats + ); + return ( +
+ {entryIndex + 1} + {maskApiKey(entry.apiKey)} + {entry.proxyUrl && {entry.proxyUrl}} +
+ + {entryStats.success} + + + {entryStats.failure} + +
+
+ ); + })} +
+
+ )} +
+ {t('ai_providers.openai_models_count')}: + {provider.models?.length || 0} +
+ {provider.models?.length ? ( +
+ {provider.models.map((model) => ( + + {model.name} + {model.alias && model.alias !== model.name && ( + {model.alias} + )} + + ))} +
+ ) : null} + {provider.testModel && ( +
+ {t('ai_providers.openai_test_model')}: + {provider.testModel} +
+ )} +
+ + {t('stats.success')}: {stats.success} + + + {t('stats.failure')}: {stats.failure} + +
+ +
+
+ + +
+
+ ); + }; + return ( <> - - + + {renderToolbar(false)} + + } + > + {loading && sortedConfigs.length === 0 ? ( +
{t('common.loading')}
+ ) : configs.length > 0 && sortedConfigs.length === 0 ? ( + + {t('ai_providers.model_search_clear')} + + )} /> - {t('ai_providers.openai_title')} - - } - extra={ - - } - > - - items={configs} - loading={loading} - keyField={(item, index) => getOpenAIProviderKey(item, index)} - emptyTitle={t('ai_providers.openai_empty_title')} - emptyDescription={t('ai_providers.openai_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} - actionsDisabled={actionsDisabled} - renderContent={(item, index) => { - const stats = getOpenAIProviderStats(item, keyStats); - const headerEntries = Object.entries(item.headers || {}); - const apiKeyEntries = item.apiKeyEntries || []; - const statusData = - statusBarCache.get(getOpenAIProviderKey(item, index)) || calculateStatusBarData([]); - - return ( - -
{item.name}
- {item.priority !== undefined && ( -
- {t('common.priority')}: - {item.priority} -
- )} - {item.prefix && ( -
- {t('common.prefix')}: - {item.prefix} -
- )} -
- {t('common.base_url')}: - {item.baseUrl} -
- {headerEntries.length > 0 && ( -
- {headerEntries.map(([key, value]) => ( - - {key}: {value} - - ))} -
- )} - {apiKeyEntries.length > 0 && ( -
-
- {t('ai_providers.openai_keys_count')}: {apiKeyEntries.length} -
-
- {apiKeyEntries.map((entry, entryIndex) => { - const entryStats = getStatsForIdentity( - { authIndex: entry.authIndex, apiKey: entry.apiKey }, - keyStats - ); - return ( -
- {entryIndex + 1} - {maskApiKey(entry.apiKey)} - {entry.proxyUrl && ( - {entry.proxyUrl} - )} -
- - {entryStats.success} - - - {entryStats.failure} - -
-
- ); - })} -
-
- )} -
- {t('ai_providers.openai_models_count')}: - {item.models?.length || 0} -
- {item.models?.length ? ( -
- {item.models.map((model) => ( - - {model.name} - {model.alias && model.alias !== model.name && ( - {model.alias} - )} - - ))} -
- ) : null} - {item.testModel && ( -
- Test Model: - {item.testModel} -
- )} -
- - {t('stats.success')}: {stats.success} - - - {t('stats.failure')}: {stats.failure} - -
- -
- ); - }} - /> -
+ ) : sortedConfigs.length === 0 ? ( + + ) : ( +
{sortedConfigs.map(renderProviderCard)}
+ )} +
+ + {typeof document !== 'undefined' && shouldRenderFloatingToolbar + ? createPortal( +
+
+
{renderStaticTitle()}
+ {renderToolbar(true)} +
+
, + document.body + ) + : null} ); } diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 9d1a1f7..c6fd8cf 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -8,14 +8,18 @@ interface ProviderListProps { loading: boolean; keyField: (item: T, index: number) => string; renderContent: (item: T, index: number) => ReactNode; - onEdit: (index: number) => void; - onDelete: (index: number) => void; + onEdit: (item: T, index: number) => void; + onDelete: (item: T, index: number) => void; emptyTitle: string; emptyDescription: string; deleteLabel?: string; actionsDisabled?: boolean; getRowDisabled?: (item: T, index: number) => boolean; renderExtraActions?: (item: T, index: number) => ReactNode; + listClassName?: string; + rowClassName?: string; + metaClassName?: string; + actionsClassName?: string; } export function ProviderList({ @@ -31,6 +35,10 @@ export function ProviderList({ actionsDisabled = false, getRowDisabled, renderExtraActions, + listClassName, + rowClassName, + metaClassName, + actionsClassName, }: ProviderListProps) { const { t } = useTranslation(); @@ -43,21 +51,21 @@ export function ProviderList({ } return ( -
+
{items.map((item, index) => { const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false; return (
-
{renderContent(item, index)}
-
+
{renderContent(item, index)}
+