feat: account management overhaul + 7 issue resolutions (#147)
- Add smart JSON export/import with schema validation and deduplication - Add per-account proxy configurations (Issue #79) - Add advanced account sorting engine (Issues #126 & #99) - Add compact horizontal layout, AI credits, inline model visibility (Issues #145 & #134) - Add desktop push notifications for low quota (Issue #53) - Fix Arch Linux AUR process detection bug (Issue #117) - Default theme set to Dark Mode (it's better) - UI Polishing: Improved sort dropdown and symmetrical dialog layouts - Dev Mode: Resolved port conflicts and single-instance locks - Updated README and README.zh-CN with new features
This commit is contained in:
37 files changed
+1989
-455
No files matched your search
@@ -17,8 +17,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide-react';
|
||||
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';
|
||||
@@ -32,6 +34,9 @@ import {
|
||||
getQuotaStatus,
|
||||
type QuotaStatus,
|
||||
} from '@/utils/quota-display';
|
||||
import { useState } from 'react';
|
||||
import { useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||
import { isValidProxyUrl } from '@/utils/url';
|
||||
|
||||
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
||||
|
||||
@@ -81,7 +86,9 @@ function isGeminiProFamilyModel(modelName: string): boolean {
|
||||
return normalizedModelName.includes('gemini-3.1-pro');
|
||||
}
|
||||
|
||||
function mergeGeminiProQuotaEntries(entries: ModelQuotaEntry[]): Record<string, CloudQuotaModelInfo> {
|
||||
function mergeGeminiProQuotaEntries(
|
||||
entries: ModelQuotaEntry[],
|
||||
): Record<string, CloudQuotaModelInfo> {
|
||||
const mergedModels: Record<string, CloudQuotaModelInfo> = {};
|
||||
const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName));
|
||||
const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName));
|
||||
@@ -96,9 +103,18 @@ function mergeGeminiProQuotaEntries(entries: ModelQuotaEntry[]): Record<string,
|
||||
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;
|
||||
}
|
||||
@@ -148,13 +164,16 @@ export function CloudAccountCard({
|
||||
isSwitching,
|
||||
}: CloudAccountCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { config, saveConfig } = useAppConfig();
|
||||
const {
|
||||
enabled: providerGroupingsEnabled,
|
||||
getAccountStats,
|
||||
isProviderCollapsed,
|
||||
toggleProviderCollapse,
|
||||
} = useProviderGrouping();
|
||||
const setAccountProxy = useSetAccountProxy();
|
||||
const [proxyUrl, setProxyUrl] = useState(account.proxy_url || '');
|
||||
const [proxySaved, setProxySaved] = useState(false);
|
||||
|
||||
const getQuotaTextColorClass = (percentage: number) => {
|
||||
const quotaStatus = getQuotaStatus(percentage);
|
||||
@@ -184,6 +203,8 @@ export function CloudAccountCard({
|
||||
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[];
|
||||
@@ -265,7 +286,9 @@ export function CloudAccountCard({
|
||||
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
|
||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}>
|
||||
<span
|
||||
className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
|
||||
>
|
||||
{formatQuotaLabel(providerStats.overallPercentage)}
|
||||
</span>
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
@@ -278,25 +301,40 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{providerStats.providers.map((statsByProvider) => (
|
||||
<ProviderGroup
|
||||
key={statsByProvider.providerKey}
|
||||
stats={statsByProvider}
|
||||
isCollapsed={isProviderCollapsed(account.id, statsByProvider.providerKey)}
|
||||
onToggleCollapse={() => toggleProviderCollapse(account.id, statsByProvider.providerKey)}
|
||||
getQuotaTextColorClass={getQuotaTextColorClass}
|
||||
getQuotaBarColorClass={getQuotaBarColorClass}
|
||||
formatQuotaLabel={formatQuotaLabel}
|
||||
formatResetTimeLabel={formatResetTimeLabelText}
|
||||
formatResetTimeTitle={formatResetTimeTitleText}
|
||||
leftLabel={t('cloud.card.left')}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{providerStats.providers.map((group) => (
|
||||
<ProviderGroup
|
||||
key={group.providerKey}
|
||||
stats={group}
|
||||
isCollapsed={isProviderCollapsed(account.id, group.providerKey)}
|
||||
onToggleCollapse={() => toggleProviderCollapse(account.id, group.providerKey)}
|
||||
getQuotaTextColorClass={getQuotaTextColorClass}
|
||||
getQuotaBarColorClass={getQuotaBarColorClass}
|
||||
formatQuotaLabel={formatQuotaLabel}
|
||||
formatResetTimeLabel={formatResetTimeLabelText}
|
||||
formatResetTimeTitle={formatResetTimeTitleText}
|
||||
leftLabel={t('cloud.card.left')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
emptyQuotaState
|
||||
);
|
||||
|
||||
const aiCredits = account.quota?.ai_credits;
|
||||
|
||||
const formatCreditsExpiry = (expiryDate: string) => {
|
||||
if (!expiryDate) return '';
|
||||
try {
|
||||
const date = new Date(expiryDate);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return expiryDate;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
|
||||
@@ -330,7 +368,71 @@ export function CloudAccountCard({
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
||||
|
||||
{aiCredits && aiCredits.credits > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-amber-500">
|
||||
<span>${aiCredits.credits.toFixed(2)}</span>
|
||||
{aiCredits.expiryDate && (
|
||||
<span className="text-muted-foreground opacity-70">
|
||||
·{' '}
|
||||
{t('cloud.card.creditsExpiry', {
|
||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allModelEntries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
{(() => {
|
||||
const hiddenCount = allModelEntries.filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||
).length;
|
||||
return hiddenCount > 0 ? (
|
||||
<EyeOff className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
);
|
||||
})()}
|
||||
<span className="sr-only">{t('cloud.card.modelVisibility')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
||||
<div className="max-h-64 overflow-auto px-2 py-1">
|
||||
{allModelEntries.map(([modelName]) => {
|
||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={modelName}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) => {
|
||||
if (config) {
|
||||
const newVisibility = { ...config.model_visibility };
|
||||
newVisibility[modelName] = checked as boolean;
|
||||
saveConfig({ ...config, model_visibility: newVisibility });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-xs" title={modelName}>
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
@@ -440,7 +542,288 @@ export function CloudAccountCard({
|
||||
{t('cloud.card.used')}{' '}
|
||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={proxyUrl}
|
||||
onChange={(e) => {
|
||||
setProxyUrl(e.target.value);
|
||||
setProxySaved(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const trimmed = proxyUrl.trim();
|
||||
if (trimmed && !isValidProxyUrl(trimmed)) {
|
||||
setProxyUrl(account.proxy_url || '');
|
||||
return;
|
||||
}
|
||||
if (trimmed !== (account.proxy_url || '')) {
|
||||
setAccountProxy.mutate({
|
||||
accountId: account.id,
|
||||
proxyUrl: trimmed || null,
|
||||
});
|
||||
setProxySaved(true);
|
||||
setTimeout(() => setProxySaved(false), 2000);
|
||||
}
|
||||
}}
|
||||
placeholder={t('cloud.card.proxyPlaceholder')}
|
||||
className="h-6 w-40 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{proxySaved && (
|
||||
<span className="text-[10px] text-green-500">{t('cloud.card.proxySaved')}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 formatCreditsExpiry = (expiryDate: string) => {
|
||||
if (!expiryDate) return '';
|
||||
try {
|
||||
const date = new Date(expiryDate);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return expiryDate;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200">
|
||||
{account.avatar_url ? (
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
alt={account.name || ''}
|
||||
className="bg-muted h-7 w-7 rounded-full border"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary/10 text-primary flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</span>
|
||||
<Badge
|
||||
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{account.provider.toUpperCase()}
|
||||
</Badge>
|
||||
{account.is_active && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="shrink-0 bg-green-500 text-[10px] hover:bg-green-600"
|
||||
>
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||
<span className="truncate">{account.email}</span>
|
||||
|
||||
{aiCredits && aiCredits.credits > 0 && (
|
||||
<span className="shrink-0 text-amber-500">
|
||||
${aiCredits.credits.toFixed(2)}
|
||||
{aiCredits.expiryDate && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
·{' '}
|
||||
{t('cloud.card.creditsExpiry', {
|
||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{compactModels.length > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
{compactModels.map(([modelName, info]) => (
|
||||
<TooltipProvider key={modelName}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-muted h-1.5 w-12 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{formatModelDisplayName(modelName)}: {info.percentage}%
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{allModelEntries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
||||
{(() => {
|
||||
const hiddenCount = allModelEntries.filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||
).length;
|
||||
return hiddenCount > 0 ? (
|
||||
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
);
|
||||
})()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
||||
<div className="max-h-64 overflow-auto px-2 py-1">
|
||||
{allModelEntries.map(([modelName]) => {
|
||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={modelName}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) => {
|
||||
if (config) {
|
||||
const newVisibility = { ...config.model_visibility };
|
||||
newVisibility[modelName] = checked as boolean;
|
||||
saveConfig({ ...config, model_visibility: newVisibility });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-xs" title={modelName}>
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{account.is_active ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
className="h-7 text-xs text-green-600 opacity-100"
|
||||
>
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
{t('cloud.card.active')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onSwitch(account.id)}
|
||||
disabled={isSwitching}
|
||||
className="h-7 cursor-pointer text-xs"
|
||||
>
|
||||
{isSwitching ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{t('cloud.card.use')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.useAccount')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.refresh')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.identityProfile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(account.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,16 +9,23 @@ import {
|
||||
useForcePollCloudMonitor,
|
||||
useSyncLocalAccount,
|
||||
startAuthFlow,
|
||||
useExportCloudAccounts,
|
||||
useImportCloudAccounts,
|
||||
} from '@/hooks/useCloudAccounts';
|
||||
import { CloudAccountCard } from '@/components/CloudAccountCard';
|
||||
import { CloudAccountCard, CompactCloudAccountCard } from '@/components/CloudAccountCard';
|
||||
import { IdentityProfileDialog } from '@/components/IdentityProfileDialog';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
Cloud,
|
||||
Zap,
|
||||
RefreshCcw,
|
||||
@@ -31,6 +38,12 @@ import {
|
||||
List,
|
||||
Columns2,
|
||||
Columns3,
|
||||
SortAsc,
|
||||
LayoutList,
|
||||
Upload,
|
||||
FileDown,
|
||||
Check,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
@@ -49,21 +62,30 @@ import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es';
|
||||
import {
|
||||
clampQuotaPercentage,
|
||||
getQuotaStatus,
|
||||
roundQuotaPercentage,
|
||||
getAccountSortValue,
|
||||
type QuotaStatus,
|
||||
} from '@/utils/quota-display';
|
||||
|
||||
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list';
|
||||
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact';
|
||||
|
||||
const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
|
||||
auto: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3',
|
||||
'2-col': 'grid gap-4 grid-cols-2',
|
||||
'3-col': 'grid gap-4 grid-cols-3',
|
||||
list: 'grid gap-4 grid-cols-1',
|
||||
compact: 'flex flex-col gap-2',
|
||||
};
|
||||
|
||||
const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
@@ -103,6 +125,43 @@ export function CloudAccountList() {
|
||||
}
|
||||
};
|
||||
|
||||
const sortOptions = [
|
||||
'recently-used',
|
||||
'quota-overall',
|
||||
'quota-claude',
|
||||
'quota-pro3',
|
||||
'quota-flash',
|
||||
] as const;
|
||||
type SortOption = (typeof sortOptions)[number];
|
||||
const currentSort: SortOption = (config?.account_sort as SortOption) || 'recently-used';
|
||||
const sortI18nKeys: Record<SortOption, string> = {
|
||||
'recently-used': 'cloud.sort.recentlyUsed',
|
||||
'quota-overall': 'cloud.sort.quotaOverall',
|
||||
'quota-claude': 'cloud.sort.quotaClaude',
|
||||
'quota-pro3': 'cloud.sort.quotaPro3',
|
||||
'quota-flash': 'cloud.sort.quotaFlash',
|
||||
};
|
||||
|
||||
const sortedAccounts = useMemo(() => {
|
||||
if (!accounts || accounts.length === 0) return [];
|
||||
|
||||
const activeAccounts = accounts.filter((a) => a.is_active);
|
||||
const otherAccounts = accounts.filter((a) => !a.is_active);
|
||||
|
||||
const sortedActive = [...activeAccounts].sort((a, b) => {
|
||||
return (b.last_used ?? 0) - (a.last_used ?? 0);
|
||||
});
|
||||
|
||||
const sortedOthers = [...otherAccounts].sort((a, b) => {
|
||||
if (currentSort === 'recently-used') {
|
||||
return (b.last_used ?? 0) - (a.last_used ?? 0);
|
||||
}
|
||||
return getAccountSortValue(b, currentSort) - getAccountSortValue(a, currentSort);
|
||||
});
|
||||
|
||||
return [...sortedActive, ...sortedOthers];
|
||||
}, [accounts, currentSort]);
|
||||
|
||||
// Calculate global quota across all accounts
|
||||
const overallQuotaPercentage = useMemo(() => {
|
||||
if (!accounts || accounts.length === 0) {
|
||||
@@ -137,6 +196,16 @@ export function CloudAccountList() {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [authCode, setAuthCode] = useState('');
|
||||
const [identityAccount, setIdentityAccount] = useState<CloudAccount | null>(null);
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [importStrategy, setImportStrategy] = useState<'merge' | 'overwrite' | 'skip-existing'>(
|
||||
'merge',
|
||||
);
|
||||
const [importFileContent, setImportFileContent] = useState<string | null>(null);
|
||||
const [importFileName, setImportFileName] = useState<string>('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const exportMutation = useExportCloudAccounts();
|
||||
const importMutation = useImportCloudAccounts();
|
||||
const totalAccounts = size(accounts);
|
||||
const activeAccounts = filter(accounts, (account) => account.is_active).length;
|
||||
const rateLimitedAccounts = filter(
|
||||
@@ -144,29 +213,32 @@ export function CloudAccountList() {
|
||||
(account) => account.status === 'rate_limited',
|
||||
).length;
|
||||
|
||||
const submitAuthCode = useCallback((incomingAuthCode?: string) => {
|
||||
const codeToUse = incomingAuthCode || authCode;
|
||||
if (!codeToUse) {
|
||||
return;
|
||||
}
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
const submitAuthCode = useCallback(
|
||||
(incomingAuthCode?: string) => {
|
||||
const codeToUse = incomingAuthCode || authCode;
|
||||
if (!codeToUse) {
|
||||
return;
|
||||
}
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [addMutation, authCode, t, toast]);
|
||||
);
|
||||
},
|
||||
[addMutation, authCode, t, toast],
|
||||
);
|
||||
// Listen for Google Auth Code
|
||||
useEffect(() => {
|
||||
if (window.electron?.onGoogleAuthCode) {
|
||||
@@ -204,6 +276,7 @@ export function CloudAccountList() {
|
||||
}, [error, errorUpdatedAt, isError, t, toast]);
|
||||
|
||||
const handleRefresh = (id: string) => {
|
||||
console.log(`[Renderer] Triggering refresh for: ${id}`);
|
||||
refreshMutation.mutate(
|
||||
{ accountId: id },
|
||||
{
|
||||
@@ -315,13 +388,119 @@ export function CloudAccountList() {
|
||||
await startAuthFlow();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t('cloud.toast.startAuthFailed'), // Need to add this key or just use generic error
|
||||
title: t('cloud.toast.startAuthFailed'),
|
||||
description: String(e),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (stripTokens: boolean) => {
|
||||
let url: string | null = null;
|
||||
try {
|
||||
const jsonContent: string = await exportMutation.mutateAsync({ stripTokens });
|
||||
const blob = new Blob([jsonContent], { type: 'application/json' });
|
||||
url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cloud-accounts-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setIsExportDialogOpen(false);
|
||||
toast({ title: t('cloud.exportImport.exportSuccess') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: getLocalizedErrorMessage(error, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: 'File size exceeds 5MB limit',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setImportFileName(file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const content = event.target?.result as string;
|
||||
JSON.parse(content);
|
||||
setImportFileContent(content);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: 'Invalid JSON file format',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setImportFileName('');
|
||||
setImportFileContent(null);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: 'Failed to read file',
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
if (e.target) {
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
if (!importFileContent) return;
|
||||
importMutation.mutate(
|
||||
{ jsonContent: importFileContent, strategy: importStrategy },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setIsImportDialogOpen(false);
|
||||
setImportFileContent(null);
|
||||
setImportFileName('');
|
||||
setImportStrategy('merge');
|
||||
toast({
|
||||
title: t('cloud.exportImport.importSuccess', {
|
||||
imported: result.imported,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
});
|
||||
if (result.errors.length > 0) {
|
||||
toast({
|
||||
title: t('cloud.exportImport.importErrors', { count: result.errors.length }),
|
||||
description: result.errors.slice(0, 3).join('\n'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Batch Selection Handlers
|
||||
const setSelectionState = (id: string, selected: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
@@ -343,26 +522,54 @@ export function CloudAccountList() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSelectedAccounts = () => {
|
||||
selectedIds.forEach((id) => {
|
||||
refreshMutation.mutate({ accountId: id });
|
||||
});
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: `Triggered for ${selectedIds.size} accounts.`,
|
||||
});
|
||||
const refreshSelectedAccounts = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => refreshMutation.mutateAsync({ accountId: id })),
|
||||
);
|
||||
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: `Successfully refreshed ${successful} accounts.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Refresh completed with issues`,
|
||||
description: `Refreshed ${successful} accounts, ${failed} failed.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const deleteSelectedAccounts = () => {
|
||||
const deleteSelectedAccounts = async () => {
|
||||
if (confirm(t('cloud.batch.confirmDelete', { count: selectedIds.size }))) {
|
||||
selectedIds.forEach((id) => {
|
||||
deleteMutation.mutate({ accountId: id });
|
||||
});
|
||||
toast({
|
||||
title: t('cloud.toast.deleted'),
|
||||
description: `Deleted ${selectedIds.size} accounts.`,
|
||||
});
|
||||
const ids = Array.from(selectedIds);
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => deleteMutation.mutateAsync({ accountId: id })),
|
||||
);
|
||||
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
title: t('cloud.toast.deleted'),
|
||||
description: `Successfully deleted ${successful} accounts.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Deletion completed with issues`,
|
||||
description: `Deleted ${successful} accounts, ${failed} failed.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
@@ -501,6 +708,117 @@ export function CloudAccountList() {
|
||||
{t('cloud.syncFromIDE')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isExportDialogOpen} onOpenChange={setIsExportDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="cursor-pointer">
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
{t('cloud.exportImport.export')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.exportImport.exportTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.exportImport.exportDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex w-full flex-col gap-2 sm:flex-row sm:justify-center sm:space-x-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleExport(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
className="w-full cursor-pointer sm:flex-1"
|
||||
>
|
||||
{exportMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cloud.exportImport.includeTokens')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleExport(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
className="w-full cursor-pointer sm:flex-1"
|
||||
>
|
||||
{exportMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cloud.exportImport.stripTokens')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isImportDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsImportDialogOpen(open);
|
||||
if (!open) {
|
||||
setImportFileContent(null);
|
||||
setImportFileName('');
|
||||
setImportStrategy('merge');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="cursor-pointer">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('cloud.exportImport.import')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.exportImport.importTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.exportImport.importDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t('cloud.exportImport.selectFile')}</Label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImportFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{importFileName || t('cloud.exportImport.selectFile')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t('cloud.exportImport.importStrategy')}</Label>
|
||||
<Select
|
||||
value={importStrategy}
|
||||
onValueChange={(v: 'merge' | 'overwrite' | 'skip-existing') =>
|
||||
setImportStrategy(v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="merge">{t('cloud.exportImport.strategyMerge')}</SelectItem>
|
||||
<SelectItem value="overwrite">
|
||||
{t('cloud.exportImport.strategyOverwrite')}
|
||||
</SelectItem>
|
||||
<SelectItem value="skip-existing">
|
||||
{t('cloud.exportImport.strategySkip')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!importFileContent || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{importMutation.isPending
|
||||
? t('cloud.exportImport.importing')
|
||||
: t('cloud.exportImport.import')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer">
|
||||
@@ -508,7 +826,7 @@ export function CloudAccountList() {
|
||||
{t('cloud.addAccount')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||
@@ -543,7 +861,35 @@ export function CloudAccountList() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Layout Selector */}
|
||||
{/* Sort + Layout Selector */}
|
||||
<div className="flex items-center gap-1 rounded-md border p-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer">
|
||||
<SortAsc className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56" side="bottom" sideOffset={8}>
|
||||
{sortOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option}
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, account_sort: option });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentSort === option && <Check className="mr-2 h-4 w-4" />}
|
||||
<span className={currentSort === option ? '' : 'ml-6'}>
|
||||
{t(sortI18nKeys[option])}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1 rounded-md border p-1">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
@@ -598,34 +944,67 @@ export function CloudAccountList() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('cloud.layout.list')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={gridLayout === 'compact' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer"
|
||||
onClick={() => updateGridLayout('compact')}
|
||||
>
|
||||
<LayoutList className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('cloud.layout.compact')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={GRID_LAYOUT_CLASSES[gridLayout]}>
|
||||
{accounts?.map((account) => (
|
||||
<CloudAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
onManageIdentity={handleManageIdentity}
|
||||
isSelected={selectedIds.has(account.id)}
|
||||
onToggleSelection={setSelectionState}
|
||||
isRefreshing={
|
||||
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
|
||||
}
|
||||
isDeleting={
|
||||
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
|
||||
}
|
||||
isSwitching={
|
||||
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{sortedAccounts.map((account) =>
|
||||
gridLayout === 'compact' ? (
|
||||
<CompactCloudAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
onManageIdentity={handleManageIdentity}
|
||||
isRefreshing={
|
||||
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
|
||||
}
|
||||
isDeleting={
|
||||
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
|
||||
}
|
||||
isSwitching={
|
||||
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CloudAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
onManageIdentity={handleManageIdentity}
|
||||
isSelected={selectedIds.has(account.id)}
|
||||
onToggleSelection={setSelectionState}
|
||||
isRefreshing={
|
||||
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
|
||||
}
|
||||
isDeleting={
|
||||
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
|
||||
}
|
||||
isSwitching={
|
||||
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{accounts?.length === 0 && (
|
||||
{sortedAccounts.length === 0 && (
|
||||
<div className="text-muted-foreground bg-muted/20 col-span-full rounded-lg border border-dashed py-14 text-center">
|
||||
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
||||
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
|
||||
|
||||
@@ -76,10 +76,12 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`font-mono text-xs font-bold ${getQuotaTextColorClass(avgPercentage)}`}>
|
||||
<span
|
||||
className={`font-mono text-xs font-bold ${getQuotaTextColorClass(avgPercentage)}`}
|
||||
>
|
||||
{formatQuotaLabel(avgPercentage)}
|
||||
</span>
|
||||
{avgPercentage > 0 && (
|
||||
{Number.isFinite(avgPercentage) && avgPercentage > 0 && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{t('settings.providerGroupings.avgLabel')}
|
||||
</span>
|
||||
|
||||
@@ -48,21 +48,38 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
|
||||
}
|
||||
>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => {
|
||||
if (typeof collisionPadding === 'number') {
|
||||
if (collisionPadding < 0) {
|
||||
throw new Error('collisionPadding must be a non-negative number');
|
||||
}
|
||||
} else if (typeof collisionPadding === 'object' && collisionPadding !== null) {
|
||||
for (const key of Object.keys(collisionPadding)) {
|
||||
if ((collisionPadding as any)[key] < 0) {
|
||||
throw new Error(`collisionPadding.${key} must be a non-negative number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
|
||||
Reference in new issue
Block a user