diff --git a/src/components/CloudAccountCard.tsx b/src/components/CloudAccountCard.tsx index d8ec707..91aa9e7 100644 --- a/src/components/CloudAccountCard.tsx +++ b/src/components/CloudAccountCard.tsx @@ -1,866 +1,866 @@ -import { CloudAccount, CloudQuotaModelInfo } from '@/types/cloudAccount'; -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; - -import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint, Eye, EyeOff } from 'lucide-react'; -import { formatDistanceToNow } from 'date-fns'; -import { Badge } from '@/components/ui/badge'; -import { useTranslation } from 'react-i18next'; -import { useAppConfig } from '@/hooks/useAppConfig'; -import { useProviderGrouping } from '@/hooks/useProviderGrouping'; -import { ProviderGroup } from '@/components/ProviderGroup'; -import { - clampQuotaPercentage, - formatAiCreditsAmount, - formatResetTimeLabel, - formatResetTimeTitle, - getQuotaStatus, - type QuotaStatus, -} from '@/utils/quota-display'; -import { getValidationBlockedStatusLabel } from '@/components/accountValidationStatus'; - -type ModelQuotaEntry = [string, CloudQuotaModelInfo]; - -const GEMINI_LEGACY_MODEL_PATTERN = /gemini-[12](\.|$|-)/i; -const GEMINI_PRO_COMBINED_MODEL_ID = 'gemini-3.1-pro-low/high'; - -const MODEL_DISPLAY_REPLACEMENTS: Array<[string, string]> = [ - [GEMINI_PRO_COMBINED_MODEL_ID, 'Gemini 3.1 Pro (Low/High)'], - ['gemini-3.1-pro-preview', 'Gemini 3.1 Pro Preview'], - ['gemini-3-pro-image', 'Gemini 3 Pro Image'], - ['gemini-3.1-pro', 'Gemini 3.1 Pro'], - ['gemini-3-pro', 'Gemini 3 Pro'], - ['gemini-3-flash', 'Gemini 3 Flash'], - ['claude-sonnet-4-6-thinking', 'Claude 4.6 Sonnet (Thinking)'], - ['claude-sonnet-4-6', 'Claude 4.6 Sonnet'], - ['claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)'], - ['claude-sonnet-4-5', 'Claude 4.5 Sonnet'], - ['claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)'], - ['claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)'], - ['claude-3-5-sonnet', 'Claude 3.5 Sonnet'], -]; - -const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record = { - high: 'text-emerald-400', - medium: 'text-amber-400', - low: 'text-rose-400', -}; - -const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record = { - high: 'bg-emerald-500', - medium: 'bg-amber-500', - low: 'bg-rose-500', -}; - -function isGeminiProLowModel(modelName: string): boolean { - const normalizedModelName = modelName.toLowerCase(); - return normalizedModelName.includes('gemini-3.1-pro-low'); -} - -function isGeminiProHighModel(modelName: string): boolean { - const normalizedModelName = modelName.toLowerCase(); - return normalizedModelName.includes('gemini-3.1-pro-high'); -} - -function formatCreditsExpiry(expiryDate: string): string { - if (!expiryDate) { - return ''; - } - - try { - const date = new Date(expiryDate); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - } catch { - return expiryDate; - } -} - -function isGeminiProFamilyModel(modelName: string): boolean { - const normalizedModelName = modelName.toLowerCase(); - return normalizedModelName.includes('gemini-3.1-pro'); -} - -function mergeGeminiProQuotaEntries( - entries: ModelQuotaEntry[], -): Record { - const mergedModels: Record = {}; - const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName)); - const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName)); - const proLowModelInfo = entries.find(([modelName]) => isGeminiProLowModel(modelName))?.[1]; - - for (const [modelName, modelInfo] of entries) { - if (isGeminiProLowModel(modelName) && hasProHighModel) { - continue; - } - - if (isGeminiProHighModel(modelName) && hasProLowModel) { - const mergedPercentage = proLowModelInfo - ? Math.min(modelInfo.percentage, proLowModelInfo.percentage) - : modelInfo.percentage; - - mergedModels[GEMINI_PRO_COMBINED_MODEL_ID] = { - ...modelInfo, - ...proLowModelInfo, - percentage: mergedPercentage, - display_name: 'Gemini 3.1 Pro', - resetTime: - modelInfo.resetTime && proLowModelInfo?.resetTime - ? modelInfo.resetTime < proLowModelInfo.resetTime - ? modelInfo.resetTime - : proLowModelInfo.resetTime - : modelInfo.resetTime || proLowModelInfo?.resetTime || '', - }; - continue; - } - - mergedModels[modelName] = modelInfo; - } - - return mergedModels; -} - -function formatModelDisplayName(modelName: string): string { - let displayName = modelName.replace('models/', ''); - for (const [source, target] of MODEL_DISPLAY_REPLACEMENTS) { - displayName = displayName.replace(source, target); - } - - return displayName - .replace(/-/g, ' ') - .split(' ') - .map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word)) - .join(' '); -} - -interface CloudAccountCardProps { - account: CloudAccount; - onRefresh: (id: string) => void; - onDelete: (id: string) => void; - onSwitch: (id: string) => void; - onManageIdentity: (id: string) => void; - isSelected?: boolean; - onToggleSelection?: (id: string, selected: boolean) => void; - isRefreshing?: boolean; - isDeleting?: boolean; - isSwitching?: boolean; -} - -export function CloudAccountCard({ - account, - onRefresh, - onDelete, - onSwitch, - onManageIdentity, - isSelected = false, - onToggleSelection, - isRefreshing, - isDeleting, - isSwitching, -}: CloudAccountCardProps) { - const { t } = useTranslation(); - const { config, saveConfig } = useAppConfig(); - const { - enabled: providerGroupingsEnabled, - getAccountStats, - isProviderCollapsed, - toggleProviderCollapse, - } = useProviderGrouping(); - - const getQuotaTextColorClass = (percentage: number) => { - const quotaStatus = getQuotaStatus(percentage); - return QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus]; - }; - - const getQuotaBarColorClass = (percentage: number) => { - const quotaStatus = getQuotaStatus(percentage); - return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus]; - }; - - const formatQuotaLabel = (percentage: number) => { - if (percentage === 0) { - return t('cloud.card.rateLimitedQuota'); - } - return `${percentage}%`; - }; - - const formatResetTimeLabelText = (resetTime?: string) => { - return formatResetTimeLabel(resetTime, { - prefix: t('cloud.card.resetPrefix'), - unknown: t('cloud.card.resetUnknown'), - }); - }; - - const formatResetTimeTitleText = (resetTime?: string) => { - return formatResetTimeTitle(resetTime, t('cloud.card.resetTime')); - }; - - const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[]; - - const visibleModelEntries = Object.entries(account.quota?.models || {}).filter( - ([modelName]) => config?.model_visibility?.[modelName] !== false, - ) as ModelQuotaEntry[]; - - const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries); - - const geminiModels = Object.entries(mergedModelQuotas) - .filter(([name]) => name.includes('gemini') && !GEMINI_LEGACY_MODEL_PATTERN.test(name)) - .sort((a, b) => b[1].percentage - a[1].percentage); - - const claudeModels = Object.entries(mergedModelQuotas) - .filter(([name]) => name.includes('claude')) - .sort((a, b) => b[1].percentage - a[1].percentage); - - const hasHighTier = geminiModels.some( - ([name, info]) => isGeminiProFamilyModel(name) && info.percentage > 50, - ); - const hasVisibleQuotaModels = geminiModels.length > 0 || claudeModels.length > 0; - - const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => { - if (models.length === 0) return null; - return ( -
-

- {title} -

-
- {models.map(([modelName, info]) => ( -
- - {formatModelDisplayName(modelName)} - -
- - {formatResetTimeLabelText(info.resetTime)} - - - {info.percentage}% - -
-
-
-
-
- ))} -
-
- ); - }; - - const emptyQuotaState = ( -
- - {t('cloud.card.noQuota')} -
- ); - - const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null; - const providerGroupedQuotaSection = - providerStats && providerStats.visibleModels > 0 ? ( -
-
- - {t('settings.providerGroupings.overall')} - -
- - {formatQuotaLabel(providerStats.overallPercentage)} - -
-
-
-
-
- -
- {providerStats.providers.map((group) => ( - toggleProviderCollapse(account.id, group.providerKey)} - getQuotaTextColorClass={getQuotaTextColorClass} - getQuotaBarColorClass={getQuotaBarColorClass} - formatQuotaLabel={formatQuotaLabel} - formatResetTimeLabel={formatResetTimeLabelText} - formatResetTimeTitle={formatResetTimeTitleText} - leftLabel={t('cloud.card.left')} - /> - ))} -
-
- ) : ( - emptyQuotaState - ); - - const aiCredits = account.quota?.ai_credits; - const shouldShowAiCredits = - !!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0; - - const validationBlockedStatusLabel = getValidationBlockedStatusLabel( - account.status, - account.status_reason, - t, - ); - - return ( - - - {onToggleSelection && ( -
- onToggleSelection(account.id, checked as boolean)} - className="bg-background/80 data-[state=checked]:bg-primary h-4 w-4 backdrop-blur-sm" - /> -
- )} - - {account.avatar_url ? ( - {account.name - ) : ( -
- {account.name?.[0]?.toUpperCase() || 'A'} -
- )} - -
- - {account.name || t('cloud.card.unknown')} - - - {account.email} - - -
- - {account.provider} - - {account.is_active && ( - - {t('cloud.card.active')} - - )} - {hasHighTier && ( - - {t('cloud.card.gemini3Ready')} - - )} -
- - {validationBlockedStatusLabel && ( - - {validationBlockedStatusLabel} - - )} - - {shouldShowAiCredits && aiCredits && ( -
- - {t('cloud.card.aiCreditsValue', { - amount: formatAiCreditsAmount(aiCredits.credits), - })} - - {aiCredits.expiryDate && ( - <> - - - {t('cloud.card.creditsExpiry', { - date: formatCreditsExpiry(aiCredits.expiryDate), - })} - - - )} -
- )} -
- -
- {allModelEntries.length > 0 && ( - - - - - - - {t('cloud.card.modelVisibility')} - - -
- {allModelEntries.map(([modelName]) => { - const isVisible = config?.model_visibility?.[modelName] !== false; - return ( - e.preventDefault()} - className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5" - > - { - if (config) { - const newVisibility = { ...config.model_visibility }; - newVisibility[modelName] = checked as boolean; - saveConfig({ ...config, model_visibility: newVisibility }); - } - }} - className="h-3.5 w-3.5" - /> - - {formatModelDisplayName(modelName)} - - - ); - })} -
-
-
- )} - - - - - - - onSwitch(account.id)} - disabled={isSwitching} - className="cursor-pointer" - > - - {t('cloud.card.useAccount')} - - onRefresh(account.id)} - disabled={isRefreshing} - className="cursor-pointer" - > - - {t('cloud.card.refresh')} - - onManageIdentity(account.id)} - className="cursor-pointer" - > - - {t('cloud.card.identityProfile')} - - - onDelete(account.id)} - className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer" - disabled={isDeleting} - > - - {t('cloud.card.delete')} - - - -
-
- - -
- {providerGroupingsEnabled ? ( - providerGroupedQuotaSection - ) : hasVisibleQuotaModels ? ( -
- {renderQuotaModelGroup('Google Gemini', geminiModels)} - {claudeModels.length > 0 && renderQuotaModelGroup('Anthropic Claude', claudeModels)} -
- ) : ( - emptyQuotaState - )} -
-
- - - - {t('cloud.card.used')}{' '} - {formatDistanceToNow(account.last_used * 1000, { addSuffix: true })} - - - {account.is_active ? ( - - - - - - Active - - ) : ( - - )} - -
- ); -} - -interface CompactCloudAccountCardProps { - account: CloudAccount; - onRefresh: (id: string) => void; - onDelete: (id: string) => void; - onSwitch: (id: string) => void; - onManageIdentity: (id: string) => void; - isRefreshing?: boolean; - isDeleting?: boolean; - isSwitching?: boolean; -} - -export function CompactCloudAccountCard({ - account, - onRefresh, - onDelete, - onSwitch, - onManageIdentity, - isRefreshing, - isDeleting, - isSwitching, -}: CompactCloudAccountCardProps) { - const { t } = useTranslation(); - const { config, saveConfig } = useAppConfig(); - - const getQuotaBarColorClass = (percentage: number) => { - const quotaStatus = getQuotaStatus(percentage); - return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus]; - }; - - const visibleModelEntries = Object.entries(account.quota?.models || {}).filter( - ([modelName]) => config?.model_visibility?.[modelName] !== false, - ) as ModelQuotaEntry[]; - - const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[]; - - const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries); - - const compactModels = Object.entries(mergedModelQuotas).sort( - (a, b) => b[1].percentage - a[1].percentage, - ); - - const aiCredits = account.quota?.ai_credits; - const shouldShowAiCredits = - !!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0; - - const validationBlockedStatusLabel = getValidationBlockedStatusLabel( - account.status, - account.status_reason, - t, - ); - - return ( -
- {account.avatar_url ? ( - {account.name - ) : ( -
- {account.name?.[0]?.toUpperCase() || 'A'} -
- )} - -
-
- - {account.name || t('cloud.card.unknown')} - - - {account.provider} - - {account.is_active && ( - - {t('cloud.card.active')} - - )} -
- -
- {account.email} - {validationBlockedStatusLabel && ( - {validationBlockedStatusLabel} - )} - - {shouldShowAiCredits && aiCredits && ( - - - {t('cloud.card.aiCreditsValue', { - amount: formatAiCreditsAmount(aiCredits.credits), - })} - - {aiCredits.expiryDate && ( - <> - - - {t('cloud.card.creditsExpiry', { - date: formatCreditsExpiry(aiCredits.expiryDate), - })} - - - )} - - )} -
- - {compactModels.length > 0 && ( -
- {compactModels.map(([modelName, info]) => ( - - - -
-
-
- - - {formatModelDisplayName(modelName)}: {info.percentage}% - - - - ))} -
- )} -
- -
- {allModelEntries.length > 0 && ( - - - - - - - {t('cloud.card.modelVisibility')} - - -
- {allModelEntries.map(([modelName]) => { - const isVisible = config?.model_visibility?.[modelName] !== false; - return ( - e.preventDefault()} - className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5" - > - { - if (config) { - const newVisibility = { ...config.model_visibility }; - newVisibility[modelName] = checked as boolean; - saveConfig({ ...config, model_visibility: newVisibility }); - } - }} - className="h-3.5 w-3.5" - /> - - {formatModelDisplayName(modelName)} - - - ); - })} -
-
-
- )} - - {account.is_active ? ( -
- - - - - - Active - -
- ) : ( - - )} - - - - - - - onSwitch(account.id)} - disabled={isSwitching} - className="cursor-pointer" - > - - {t('cloud.card.useAccount')} - - onRefresh(account.id)} - disabled={isRefreshing} - className="cursor-pointer" - > - - {t('cloud.card.refresh')} - - onManageIdentity(account.id)} - className="cursor-pointer" - > - - {t('cloud.card.identityProfile')} - - - onDelete(account.id)} - className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer" - disabled={isDeleting} - > - - {t('cloud.card.delete')} - - - -
-
- ); -} +import { CloudAccount, CloudQuotaModelInfo } from '@/types/cloudAccount'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint, Eye, EyeOff } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { Badge } from '@/components/ui/badge'; +import { useTranslation } from 'react-i18next'; +import { useAppConfig } from '@/hooks/useAppConfig'; +import { useProviderGrouping } from '@/hooks/useProviderGrouping'; +import { ProviderGroup } from '@/components/ProviderGroup'; +import { + clampQuotaPercentage, + formatAiCreditsAmount, + formatResetTimeLabel, + formatResetTimeTitle, + getQuotaStatus, + type QuotaStatus, +} from '@/utils/quota-display'; +import { getValidationBlockedStatusLabel } from '@/components/accountValidationStatus'; + +type ModelQuotaEntry = [string, CloudQuotaModelInfo]; + +const GEMINI_LEGACY_MODEL_PATTERN = /gemini-[12](\.|$|-)/i; +const GEMINI_PRO_COMBINED_MODEL_ID = 'gemini-3.1-pro-low/high'; + +const MODEL_DISPLAY_REPLACEMENTS: Array<[string, string]> = [ + [GEMINI_PRO_COMBINED_MODEL_ID, 'Gemini 3.1 Pro (Low/High)'], + ['gemini-3.1-pro-preview', 'Gemini 3.1 Pro Preview'], + ['gemini-3-pro-image', 'Gemini 3 Pro Image'], + ['gemini-3.1-pro', 'Gemini 3.1 Pro'], + ['gemini-3-pro', 'Gemini 3 Pro'], + ['gemini-3-flash', 'Gemini 3 Flash'], + ['claude-sonnet-4-6-thinking', 'Claude 4.6 Sonnet (Thinking)'], + ['claude-sonnet-4-6', 'Claude 4.6 Sonnet'], + ['claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)'], + ['claude-sonnet-4-5', 'Claude 4.5 Sonnet'], + ['claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)'], + ['claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)'], + ['claude-3-5-sonnet', 'Claude 3.5 Sonnet'], +]; + +const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record = { + high: 'text-emerald-400', + medium: 'text-amber-400', + low: 'text-rose-400', +}; + +const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record = { + high: 'bg-emerald-500', + medium: 'bg-amber-500', + low: 'bg-rose-500', +}; + +function isGeminiProLowModel(modelName: string): boolean { + const normalizedModelName = modelName.toLowerCase(); + return normalizedModelName.includes('gemini-3.1-pro-low'); +} + +function isGeminiProHighModel(modelName: string): boolean { + const normalizedModelName = modelName.toLowerCase(); + return normalizedModelName.includes('gemini-3.1-pro-high'); +} + +function formatCreditsExpiry(expiryDate: string): string { + if (!expiryDate) { + return ''; + } + + try { + const date = new Date(expiryDate); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } catch { + return expiryDate; + } +} + +function isGeminiProFamilyModel(modelName: string): boolean { + const normalizedModelName = modelName.toLowerCase(); + return normalizedModelName.includes('gemini-3.1-pro'); +} + +function mergeGeminiProQuotaEntries( + entries: ModelQuotaEntry[], +): Record { + const mergedModels: Record = {}; + const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName)); + const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName)); + const proLowModelInfo = entries.find(([modelName]) => isGeminiProLowModel(modelName))?.[1]; + + for (const [modelName, modelInfo] of entries) { + if (isGeminiProLowModel(modelName) && hasProHighModel) { + continue; + } + + if (isGeminiProHighModel(modelName) && hasProLowModel) { + const mergedPercentage = proLowModelInfo + ? Math.min(modelInfo.percentage, proLowModelInfo.percentage) + : modelInfo.percentage; + + mergedModels[GEMINI_PRO_COMBINED_MODEL_ID] = { + ...modelInfo, + ...proLowModelInfo, + percentage: mergedPercentage, + display_name: 'Gemini 3.1 Pro', + resetTime: + modelInfo.resetTime && proLowModelInfo?.resetTime + ? modelInfo.resetTime < proLowModelInfo.resetTime + ? modelInfo.resetTime + : proLowModelInfo.resetTime + : modelInfo.resetTime || proLowModelInfo?.resetTime || '', + }; + continue; + } + + mergedModels[modelName] = modelInfo; + } + + return mergedModels; +} + +function formatModelDisplayName(modelName: string): string { + let displayName = modelName.replace('models/', ''); + for (const [source, target] of MODEL_DISPLAY_REPLACEMENTS) { + displayName = displayName.replace(source, target); + } + + return displayName + .replace(/-/g, ' ') + .split(' ') + .map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word)) + .join(' '); +} + +interface CloudAccountCardProps { + account: CloudAccount; + onRefresh: (id: string) => void; + onDelete: (id: string) => void; + onSwitch: (id: string) => void; + onManageIdentity: (id: string) => void; + isSelected?: boolean; + onToggleSelection?: (id: string, selected: boolean) => void; + isRefreshing?: boolean; + isDeleting?: boolean; + isSwitching?: boolean; +} + +export function CloudAccountCard({ + account, + onRefresh, + onDelete, + onSwitch, + onManageIdentity, + isSelected = false, + onToggleSelection, + isRefreshing, + isDeleting, + isSwitching, +}: CloudAccountCardProps) { + const { t } = useTranslation(); + const { config, saveConfig } = useAppConfig(); + const { + enabled: providerGroupingsEnabled, + getAccountStats, + isProviderCollapsed, + toggleProviderCollapse, + } = useProviderGrouping(); + + const getQuotaTextColorClass = (percentage: number) => { + const quotaStatus = getQuotaStatus(percentage); + return QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus]; + }; + + const getQuotaBarColorClass = (percentage: number) => { + const quotaStatus = getQuotaStatus(percentage); + return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus]; + }; + + const formatQuotaLabel = (percentage: number) => { + if (percentage === 0) { + return t('cloud.card.rateLimitedQuota'); + } + return `${percentage}%`; + }; + + const formatResetTimeLabelText = (resetTime?: string) => { + return formatResetTimeLabel(resetTime, { + prefix: t('cloud.card.resetPrefix'), + unknown: t('cloud.card.resetUnknown'), + }); + }; + + const formatResetTimeTitleText = (resetTime?: string) => { + return formatResetTimeTitle(resetTime, t('cloud.card.resetTime')); + }; + + const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[]; + + const visibleModelEntries = Object.entries(account.quota?.models || {}).filter( + ([modelName]) => config?.model_visibility?.[modelName] !== false, + ) as ModelQuotaEntry[]; + + const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries); + + const geminiModels = Object.entries(mergedModelQuotas) + .filter(([name]) => name.includes('gemini') && !GEMINI_LEGACY_MODEL_PATTERN.test(name)) + .sort((a, b) => b[1].percentage - a[1].percentage); + + const claudeModels = Object.entries(mergedModelQuotas) + .filter(([name]) => name.includes('claude')) + .sort((a, b) => b[1].percentage - a[1].percentage); + + const hasHighTier = geminiModels.some( + ([name, info]) => isGeminiProFamilyModel(name) && info.percentage > 50, + ); + const hasVisibleQuotaModels = geminiModels.length > 0 || claudeModels.length > 0; + + const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => { + if (models.length === 0) return null; + return ( +
+

+ {title} +

+
+ {models.map(([modelName, info]) => ( +
+ + {formatModelDisplayName(modelName)} + +
+ + {formatResetTimeLabelText(info.resetTime)} + + + {info.percentage}% + +
+
+
+
+
+ ))} +
+
+ ); + }; + + const emptyQuotaState = ( +
+ + {t('cloud.card.noQuota')} +
+ ); + + const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null; + const providerGroupedQuotaSection = + providerStats && providerStats.visibleModels > 0 ? ( +
+
+ + {t('settings.providerGroupings.overall')} + +
+ + {formatQuotaLabel(providerStats.overallPercentage)} + +
+
+
+
+
+ +
+ {providerStats.providers.map((group) => ( + toggleProviderCollapse(account.id, group.providerKey)} + getQuotaTextColorClass={getQuotaTextColorClass} + getQuotaBarColorClass={getQuotaBarColorClass} + formatQuotaLabel={formatQuotaLabel} + formatResetTimeLabel={formatResetTimeLabelText} + formatResetTimeTitle={formatResetTimeTitleText} + leftLabel={t('cloud.card.left')} + /> + ))} +
+
+ ) : ( + emptyQuotaState + ); + + const aiCredits = account.quota?.ai_credits; + const shouldShowAiCredits = + !!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0; + + const validationBlockedStatusLabel = getValidationBlockedStatusLabel( + account.status, + account.status_reason, + t, + ); + + return ( + + + {onToggleSelection && ( +
+ onToggleSelection(account.id, checked as boolean)} + className="bg-background/80 data-[state=checked]:bg-primary h-4 w-4 backdrop-blur-sm" + /> +
+ )} + + {account.avatar_url ? ( + {account.name + ) : ( +
+ {account.name?.[0]?.toUpperCase() || 'A'} +
+ )} + +
+ + {account.name || t('cloud.card.unknown')} + + + {account.email} + + +
+ + {account.provider} + + {account.is_active && ( + + {t('cloud.card.active')} + + )} + {hasHighTier && ( + + {t('cloud.card.gemini3Ready')} + + )} +
+ + {validationBlockedStatusLabel && ( + + {validationBlockedStatusLabel} + + )} + + {shouldShowAiCredits && aiCredits && ( +
+ + {t('cloud.card.aiCreditsValue', { + amount: formatAiCreditsAmount(aiCredits.credits), + })} + + {aiCredits.expiryDate && ( + <> + + + {t('cloud.card.creditsExpiry', { + date: formatCreditsExpiry(aiCredits.expiryDate), + })} + + + )} +
+ )} +
+ +
+ {allModelEntries.length > 0 && ( + + + + + + + {t('cloud.card.modelVisibility')} + + +
+ {allModelEntries.map(([modelName]) => { + const isVisible = config?.model_visibility?.[modelName] !== false; + return ( + e.preventDefault()} + className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5" + > + { + if (config) { + const newVisibility = { ...config.model_visibility }; + newVisibility[modelName] = checked as boolean; + saveConfig({ ...config, model_visibility: newVisibility }); + } + }} + className="h-3.5 w-3.5" + /> + + {formatModelDisplayName(modelName)} + + + ); + })} +
+
+
+ )} + + + + + + + onSwitch(account.id)} + disabled={isSwitching} + className="cursor-pointer" + > + + {t('cloud.card.useAccount')} + + onRefresh(account.id)} + disabled={isRefreshing} + className="cursor-pointer" + > + + {t('cloud.card.refresh')} + + onManageIdentity(account.id)} + className="cursor-pointer" + > + + {t('cloud.card.identityProfile')} + + + onDelete(account.id)} + className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer" + disabled={isDeleting} + > + + {t('cloud.card.delete')} + + + +
+
+ + +
+ {providerGroupingsEnabled ? ( + providerGroupedQuotaSection + ) : hasVisibleQuotaModels ? ( +
+ {renderQuotaModelGroup('Google Gemini', geminiModels)} + {claudeModels.length > 0 && renderQuotaModelGroup('Anthropic Claude', claudeModels)} +
+ ) : ( + emptyQuotaState + )} +
+
+ + + + {t('cloud.card.used')}{' '} + {formatDistanceToNow(account.last_used * 1000, { addSuffix: true })} + + + {account.is_active ? ( + + + + + + Active + + ) : ( + + )} + +
+ ); +} + +interface CompactCloudAccountCardProps { + account: CloudAccount; + onRefresh: (id: string) => void; + onDelete: (id: string) => void; + onSwitch: (id: string) => void; + onManageIdentity: (id: string) => void; + isRefreshing?: boolean; + isDeleting?: boolean; + isSwitching?: boolean; +} + +export function CompactCloudAccountCard({ + account, + onRefresh, + onDelete, + onSwitch, + onManageIdentity, + isRefreshing, + isDeleting, + isSwitching, +}: CompactCloudAccountCardProps) { + const { t } = useTranslation(); + const { config, saveConfig } = useAppConfig(); + + const getQuotaBarColorClass = (percentage: number) => { + const quotaStatus = getQuotaStatus(percentage); + return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus]; + }; + + const visibleModelEntries = Object.entries(account.quota?.models || {}).filter( + ([modelName]) => config?.model_visibility?.[modelName] !== false, + ) as ModelQuotaEntry[]; + + const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[]; + + const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries); + + const compactModels = Object.entries(mergedModelQuotas).sort( + (a, b) => b[1].percentage - a[1].percentage, + ); + + const aiCredits = account.quota?.ai_credits; + const shouldShowAiCredits = + !!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0; + + const validationBlockedStatusLabel = getValidationBlockedStatusLabel( + account.status, + account.status_reason, + t, + ); + + return ( +
+ {account.avatar_url ? ( + {account.name + ) : ( +
+ {account.name?.[0]?.toUpperCase() || 'A'} +
+ )} + +
+
+ + {account.name || t('cloud.card.unknown')} + + + {account.provider} + + {account.is_active && ( + + {t('cloud.card.active')} + + )} +
+ +
+ {account.email} + {validationBlockedStatusLabel && ( + {validationBlockedStatusLabel} + )} + + {shouldShowAiCredits && aiCredits && ( + + + {t('cloud.card.aiCreditsValue', { + amount: formatAiCreditsAmount(aiCredits.credits), + })} + + {aiCredits.expiryDate && ( + <> + + + {t('cloud.card.creditsExpiry', { + date: formatCreditsExpiry(aiCredits.expiryDate), + })} + + + )} + + )} +
+ + {compactModels.length > 0 && ( +
+ {compactModels.map(([modelName, info]) => ( + + + +
+
+
+ + + {formatModelDisplayName(modelName)}: {info.percentage}% + + + + ))} +
+ )} +
+ +
+ {allModelEntries.length > 0 && ( + + + + + + + {t('cloud.card.modelVisibility')} + + +
+ {allModelEntries.map(([modelName]) => { + const isVisible = config?.model_visibility?.[modelName] !== false; + return ( + e.preventDefault()} + className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5" + > + { + if (config) { + const newVisibility = { ...config.model_visibility }; + newVisibility[modelName] = checked as boolean; + saveConfig({ ...config, model_visibility: newVisibility }); + } + }} + className="h-3.5 w-3.5" + /> + + {formatModelDisplayName(modelName)} + + + ); + })} +
+
+
+ )} + + {account.is_active ? ( +
+ + + + + + Active + +
+ ) : ( + + )} + + + + + + + onSwitch(account.id)} + disabled={isSwitching} + className="cursor-pointer" + > + + {t('cloud.card.useAccount')} + + onRefresh(account.id)} + disabled={isRefreshing} + className="cursor-pointer" + > + + {t('cloud.card.refresh')} + + onManageIdentity(account.id)} + className="cursor-pointer" + > + + {t('cloud.card.identityProfile')} + + + onDelete(account.id)} + className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer" + disabled={isDeleting} + > + + {t('cloud.card.delete')} + + + +
+
+ ); +} diff --git a/src/components/accounts/AccountsToolbar.tsx b/src/components/accounts/AccountsToolbar.tsx index 0d3afd7..04c8385 100644 --- a/src/components/accounts/AccountsToolbar.tsx +++ b/src/components/accounts/AccountsToolbar.tsx @@ -112,6 +112,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) { size="icon" className="h-7 w-7 cursor-pointer rounded-md" onClick={() => onLayoutChange('auto')} + aria-label={t('cloud.layout.auto')} + aria-pressed={gridLayout === 'auto'} > @@ -125,6 +127,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) { size="icon" className="h-7 w-7 cursor-pointer rounded-md" onClick={() => onLayoutChange('2-col')} + aria-label={t('cloud.layout.twoCol')} + aria-pressed={gridLayout === '2-col'} > @@ -138,6 +142,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) { size="icon" className="h-7 w-7 cursor-pointer rounded-md" onClick={() => onLayoutChange('3-col')} + aria-label={t('cloud.layout.threeCol')} + aria-pressed={gridLayout === '3-col'} > @@ -151,6 +157,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) { size="icon" className="h-7 w-7 cursor-pointer rounded-md" onClick={() => onLayoutChange('list')} + aria-label={t('cloud.layout.list')} + aria-pressed={gridLayout === 'list'} > @@ -164,6 +172,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) { size="icon" className="h-7 w-7 cursor-pointer rounded-md" onClick={() => onLayoutChange('compact')} + aria-label={t('cloud.layout.compact')} + aria-pressed={gridLayout === 'compact'} > @@ -206,7 +216,12 @@ export function AccountsToolbar(props: AccountsToolbarProps) { {/* More Options Dropdown */} - diff --git a/src/components/accounts/BatchActionBar.tsx b/src/components/accounts/BatchActionBar.tsx index ece4780..d3e7e80 100644 --- a/src/components/accounts/BatchActionBar.tsx +++ b/src/components/accounts/BatchActionBar.tsx @@ -29,6 +29,7 @@ export function BatchActionBar({ size="icon" className="h-6 w-6 rounded-full" onClick={onClearSelection} + aria-label={t('cloud.batch.clear')} > diff --git a/src/components/usage/ModelPricingDialog.tsx b/src/components/usage/ModelPricingDialog.tsx index e9d2343..5ba5a33 100644 --- a/src/components/usage/ModelPricingDialog.tsx +++ b/src/components/usage/ModelPricingDialog.tsx @@ -258,6 +258,7 @@ export const ModelPricingDialog: React.FC = ({ className="text-muted-foreground hover:text-foreground h-6 w-6" onClick={() => handleReset(model)} title="Reset to default" + aria-label={`Reset ${model} pricing to default`} > diff --git a/src/routes/proxy.tsx b/src/routes/proxy.tsx index 88c7169..81879bd 100644 --- a/src/routes/proxy.tsx +++ b/src/routes/proxy.tsx @@ -405,6 +405,8 @@ print(response.choices[0].message.content)`; className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent" onClick={() => setShowKey(!showKey)} title={showKey ? t('proxy.config.hide_key') : t('proxy.config.show_key')} + aria-label={showKey ? t('proxy.config.hide_key') : t('proxy.config.show_key')} + aria-pressed={showKey} > {showKey ? ( @@ -537,6 +539,7 @@ print(response.choices[0].message.content)`; variant="ghost" size="icon" className="h-7 w-7 text-blue-300 hover:bg-blue-500/10" + aria-label={t('proxy.copy')} onClick={() => copyToClipboard(`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`) } @@ -906,6 +909,7 @@ print(response.choices[0].message.content)`; variant="ghost" size="icon" className="h-8 w-8" + aria-label={t('proxy.copy')} onClick={(e) => { e.stopPropagation(); copyToClipboard(getCurlExample(activeModelTab)); @@ -938,6 +942,7 @@ print(response.choices[0].message.content)`; variant="ghost" size="icon" className="h-8 w-8" + aria-label={t('proxy.copy')} onClick={(e) => { e.stopPropagation(); copyToClipboard(getPythonExample(activeModelTab)); diff --git a/src/routes/usage.tsx b/src/routes/usage.tsx index d372146..c821563 100644 --- a/src/routes/usage.tsx +++ b/src/routes/usage.tsx @@ -524,6 +524,7 @@ function UsagePage() { size="icon" className="ml-2 h-5 w-5 hover:bg-white/10" onClick={() => setIsPricingDialogOpen(true)} + aria-label="Model pricing settings" >