refactor(accounts): decompose CloudAccountList into focused components

- Extract AccountsHeader, AccountsToolbar, AddAccountDialog, ExportImportDialogs, BatchActionBar, AccountsGrid
- Slim CloudAccountList from 1159 to 164 lines (orchestrator pattern)
- Improve visual hierarchy: stat cards with color-tinted borders, toolbar separators, better empty state
- All existing functionality preserved
This commit is contained in:
Gemini Nexus Bot committed 2026-05-02 00:27:59 -03:00
1 parent 7d2977b665
commit bf5a42c03e
8 files changed
+1719 -1416

No files matched your search

+305 -257
View File
@@ -235,54 +235,51 @@ export function CloudAccountCard({
const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => { const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => {
if (models.length === 0) return null; if (models.length === 0) return null;
return ( return (
<div key={title} className="space-y-1"> <div key={title} className="mt-1 space-y-1">
<div className="flex items-center gap-1.5 px-2 py-1"> <h4 className="text-muted-foreground/60 mb-1.5 px-1 text-[10px] font-bold tracking-widest uppercase">
<span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase"> {title}
{title} </h4>
</span> <div className="flex flex-col gap-1">
<div className="h-[1px] flex-1 bg-white/[0.04]" /> {models.map(([modelName, info]) => (
</div> <div
{models.map(([modelName, info]) => ( key={modelName}
<div className="group/item grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-1.5 py-1 text-[13px] transition-colors hover:bg-white/[0.03]"
key={modelName}
className="group/item grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-[13px] transition-all hover:bg-white/[0.03]"
>
<span
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
title={modelName}
> >
{formatModelDisplayName(modelName)}
</span>
<div className="flex flex-col items-end gap-0.5">
<span <span
className="text-muted-foreground text-[9px] leading-none opacity-80" className="text-muted-foreground group-hover/item:text-foreground truncate font-medium transition-colors"
title={formatResetTimeTitleText(info.resetTime)} title={modelName}
> >
{formatResetTimeLabelText(info.resetTime)} {formatModelDisplayName(modelName)}
</span> </span>
<div className="flex items-baseline gap-1"> <div className="flex items-center gap-2">
<span <span
className={`font-mono text-xs leading-none font-bold ${getQuotaTextColorClass(info.percentage)}`} className="text-muted-foreground/70 min-w-[50px] text-right text-[9px]"
title={formatResetTimeTitleText(info.resetTime)}
>
{formatResetTimeLabelText(info.resetTime)}
</span>
<span
className={`w-9 text-right font-mono text-xs font-bold ${getQuotaTextColorClass(info.percentage)}`}
> >
{info.percentage}% {info.percentage}%
</span> </span>
<div className="bg-muted h-1 w-16 overflow-hidden rounded-full"> <div className="h-1.5 w-12 overflow-hidden rounded-full bg-white/[0.04]">
<div <div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`} className={`h-full rounded-full transition-all duration-500 ease-out ${getQuotaBarColorClass(info.percentage)}`}
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }} style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
/> />
</div> </div>
</div> </div>
</div> </div>
</div> ))}
))} </div>
</div> </div>
); );
}; };
const emptyQuotaState = ( const emptyQuotaState = (
<div className="text-muted-foreground flex flex-col items-center justify-center py-4"> <div className="text-muted-foreground flex flex-col items-center justify-center py-6">
<Box className="mb-2 h-8 w-8 opacity-20" /> <Box className="mb-2 h-6 w-6 opacity-20" />
<span className="text-xs">{t('cloud.card.noQuota')}</span> <span className="text-xs">{t('cloud.card.noQuota')}</span>
</div> </div>
); );
@@ -290,18 +287,20 @@ export function CloudAccountCard({
const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null; const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null;
const providerGroupedQuotaSection = const providerGroupedQuotaSection =
providerStats && providerStats.visibleModels > 0 ? ( providerStats && providerStats.visibleModels > 0 ? (
<> <div className="space-y-4">
<div className="flex items-center justify-between rounded-lg bg-white/[0.03] px-3 py-1.5 text-xs"> <div className="flex items-center justify-between rounded-lg border border-white/[0.04] bg-white/[0.02] px-3 py-2">
<span className="font-medium">{t('settings.providerGroupings.overall')}</span> <span className="text-muted-foreground text-xs font-medium">
{t('settings.providerGroupings.overall')}
</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`} className={`font-mono text-xs font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
> >
{formatQuotaLabel(providerStats.overallPercentage)} {formatQuotaLabel(providerStats.overallPercentage)}
</span> </span>
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full"> <div className="h-1.5 w-16 overflow-hidden rounded-full bg-white/[0.04]">
<div <div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(providerStats.overallPercentage)}`} className={`h-full rounded-full transition-all duration-500 ease-out ${getQuotaBarColorClass(providerStats.overallPercentage)}`}
style={{ style={{
width: `${clampQuotaPercentage(providerStats.overallPercentage)}%`, width: `${clampQuotaPercentage(providerStats.overallPercentage)}%`,
}} }}
@@ -310,7 +309,7 @@ export function CloudAccountCard({
</div> </div>
</div> </div>
<div className="mt-3 space-y-2"> <div className="space-y-3">
{providerStats.providers.map((group) => ( {providerStats.providers.map((group) => (
<ProviderGroup <ProviderGroup
key={group.providerKey} key={group.providerKey}
@@ -326,7 +325,7 @@ export function CloudAccountCard({
/> />
))} ))}
</div> </div>
</> </div>
) : ( ) : (
emptyQuotaState emptyQuotaState
); );
@@ -343,17 +342,17 @@ export function CloudAccountCard({
return ( return (
<Card <Card
className={`group flex h-full flex-col overflow-hidden rounded-xl border border-white/[0.06] bg-card transition-all duration-200 hover:border-white/[0.12] ${isSelected ? 'ring-accent/30 border-accent/40 ring-2' : ''}`} className={`group bg-card relative flex h-full flex-col overflow-hidden rounded-xl border transition-all duration-300 hover:shadow-md ${isSelected ? 'border-primary/50 ring-primary/50 ring-1' : 'border-white/[0.06] hover:border-white/[0.15]'}`}
> >
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2"> <CardHeader className="flex flex-row items-start gap-4 space-y-0 p-5 pb-4">
{onToggleSelection && ( {onToggleSelection && (
<div <div
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} bg-background/90 rounded-full p-2 transition-opacity`} className={`absolute top-3 left-3 z-10 transition-opacity duration-200 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
> >
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={(checked) => onToggleSelection(account.id, checked as boolean)} onCheckedChange={(checked) => onToggleSelection(account.id, checked as boolean)}
className="h-5 w-5 border-2" className="bg-background/80 data-[state=checked]:bg-primary h-4 w-4 backdrop-blur-sm"
/> />
</div> </div>
)} )}
@@ -362,188 +361,193 @@ export function CloudAccountCard({
<img <img
src={account.avatar_url} src={account.avatar_url}
alt={account.name || ''} alt={account.name || ''}
className="bg-muted h-10 w-10 rounded-full border" className="h-11 w-11 shrink-0 rounded-full border border-white/[0.08] bg-white/[0.02] object-cover"
/> />
) : ( ) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/[0.06] text-muted-foreground"> <div className="text-muted-foreground flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-lg font-medium">
{account.name?.[0]?.toUpperCase() || 'A'} {account.name?.[0]?.toUpperCase() || 'A'}
</div> </div>
)} )}
<div className="flex-1 overflow-hidden">
<CardTitle className="truncate text-base font-semibold"> <div className="flex flex-1 flex-col gap-0.5 overflow-hidden pt-0.5">
<CardTitle className="truncate text-base leading-tight font-semibold">
{account.name || t('cloud.card.unknown')} {account.name || t('cloud.card.unknown')}
</CardTitle> </CardTitle>
<CardDescription className="truncate text-xs">{account.email}</CardDescription> <CardDescription className="text-muted-foreground/80 truncate text-xs font-medium">
{account.email}
</CardDescription>
{shouldShowAiCredits && aiCredits && ( <div className="mt-1 flex flex-wrap items-center gap-1.5">
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-blue-500">
<span>
{t('cloud.card.aiCreditsValue', {
amount: formatAiCreditsAmount(aiCredits.credits),
})}
</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">
<MoreVertical className="h-4 w-4" />
<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>
</CardHeader>
<CardContent className="flex-1 pb-2">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge <Badge
variant={ variant={
account.status === 'rate_limited' || account.status === 'expired' account.status === 'rate_limited' || account.status === 'expired'
? 'destructive' ? 'destructive'
: 'outline' : 'outline'
} }
className="text-xs" className="h-4 border-white/[0.1] bg-white/[0.02] px-1.5 py-0 text-[9px] uppercase"
> >
{account.provider.toUpperCase()} {account.provider}
</Badge> </Badge>
{account.is_active && ( {account.is_active && (
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-xs hover:bg-emerald-500/20"> <Badge
variant="default"
className="h-4 border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0 text-[9px] text-emerald-400 uppercase hover:bg-emerald-500/20"
>
{t('cloud.card.active')} {t('cloud.card.active')}
</Badge> </Badge>
)} )}
{validationBlockedStatusLabel && ( {hasHighTier && (
<span className="text-destructive text-xs font-medium"> <Badge
{validationBlockedStatusLabel} variant="secondary"
</span> className="h-4 animate-pulse border-blue-500/20 bg-blue-500/10 px-1.5 py-0 text-[9px] text-blue-400 uppercase"
>
{t('cloud.card.gemini3Ready')}
</Badge>
)} )}
</div> </div>
{hasHighTier && ( {validationBlockedStatusLabel && (
<Badge <span className="text-destructive mt-1 text-xs font-medium">
variant="secondary" {validationBlockedStatusLabel}
className="animate-pulse border-blue-500/20 bg-blue-500/10 text-[10px] text-blue-500" </span>
>
{t('cloud.card.gemini3Ready')}
</Badge>
)} )}
{account.is_active ? ( {shouldShowAiCredits && aiCredits && (
<Button variant="ghost" size="sm" disabled className="text-emerald-400 opacity-100"> <div className="mt-2 flex w-fit items-center gap-1.5 rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-1">
<Power className="mr-1 h-3 w-3" /> <span className="text-[10px] font-semibold text-blue-400">
{t('cloud.card.active')} {t('cloud.card.aiCreditsValue', {
</Button> amount: formatAiCreditsAmount(aiCredits.credits),
) : ( })}
<Button </span>
variant="secondary" {aiCredits.expiryDate && (
size="sm" <>
onClick={() => onSwitch(account.id)} <span className="h-2.5 w-[1px] bg-blue-500/30"></span>
disabled={isSwitching} <span className="text-[9px] font-medium text-blue-400/80">
className="cursor-pointer" {t('cloud.card.creditsExpiry', {
> date: formatCreditsExpiry(aiCredits.expiryDate),
{isSwitching ? ( })}
<RefreshCw className="h-3 w-3 animate-spin" /> </span>
) : ( </>
<Power className="mr-1 h-3 w-3" />
)} )}
{t('cloud.card.use')} </div>
</Button>
)} )}
</div> </div>
<div className="space-y-2"> <div className="flex shrink-0 items-center gap-1">
{allModelEntries.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground/70 hover:text-foreground h-7 w-7"
>
{(() => {
const hiddenCount = allModelEntries.filter(
([modelName]) => config?.model_visibility?.[modelName] === false,
).length;
return hiddenCount > 0 ? (
<EyeOff className="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 className="text-xs">
{t('cloud.card.modelVisibility')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-[300px] overflow-y-auto p-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.5 rounded-sm px-2 py-1.5"
>
<Checkbox
checked={isVisible}
onCheckedChange={(checked) => {
if (config) {
const newVisibility = { ...config.model_visibility };
newVisibility[modelName] = checked as boolean;
saveConfig({ ...config, model_visibility: newVisibility });
}
}}
className="h-3.5 w-3.5"
/>
<span className="truncate text-xs font-medium" title={modelName}>
{formatModelDisplayName(modelName)}
</span>
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground/70 hover:text-foreground h-7 w-7"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => onSwitch(account.id)}
disabled={isSwitching}
className="cursor-pointer"
>
<Power className="mr-2 h-4 w-4" />
<span className="font-medium">{t('cloud.card.useAccount')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onRefresh(account.id)}
disabled={isRefreshing}
className="cursor-pointer"
>
<RefreshCw className="mr-2 h-4 w-4" />
<span>{t('cloud.card.refresh')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onManageIdentity(account.id)}
className="cursor-pointer"
>
<Fingerprint className="mr-2 h-4 w-4" />
<span>{t('cloud.card.identityProfile')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(account.id)}
className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer"
disabled={isDeleting}
>
<Trash className="mr-2 h-4 w-4" />
<span>{t('cloud.card.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex-1 px-5 pb-5">
<div className="space-y-4">
{providerGroupingsEnabled ? ( {providerGroupingsEnabled ? (
providerGroupedQuotaSection providerGroupedQuotaSection
) : hasVisibleQuotaModels ? ( ) : hasVisibleQuotaModels ? (
<div className="space-y-3"> <div className="space-y-3">
{renderQuotaModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)} {renderQuotaModelGroup('Google Gemini', geminiModels)}
<div className="pt-1" /> {claudeModels.length > 0 && renderQuotaModelGroup('Anthropic Claude', claudeModels)}
{renderQuotaModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
</div> </div>
) : ( ) : (
emptyQuotaState emptyQuotaState
@@ -551,11 +555,36 @@ export function CloudAccountCard({
</div> </div>
</CardContent> </CardContent>
<CardFooter className="justify-center border-t border-white/[0.04] p-2 px-4 text-xs text-muted-foreground"> <CardFooter className="flex items-center justify-between border-t border-white/[0.04] bg-white/[0.01] px-5 py-3">
<span> <span className="text-muted-foreground/70 text-[11px] font-medium">
{t('cloud.card.used')}{' '} {t('cloud.card.used')}{' '}
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })} {formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
</span> </span>
{account.is_active ? (
<span className="flex items-center gap-1.5 text-xs font-semibold text-emerald-400">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
</span>
Active
</span>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => onSwitch(account.id)}
disabled={isSwitching}
className="hover:bg-primary/10 hover:text-primary h-7 cursor-pointer px-3 text-xs font-medium transition-colors"
>
{isSwitching ? (
<RefreshCw className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Power className="mr-1.5 h-3 w-3" />
)}
{t('cloud.card.use')}
</Button>
)}
</CardFooter> </CardFooter>
</Card> </Card>
); );
@@ -613,22 +642,22 @@ export function CompactCloudAccountCard({
); );
return ( return (
<div className="group flex items-center gap-3 rounded-xl border border-white/[0.06] bg-card px-3 py-2 transition-all duration-200 hover:border-white/[0.12]"> <div className="group bg-card flex items-center gap-3 rounded-xl border border-white/[0.04] px-4 py-3 transition-all duration-200 hover:border-white/[0.1] hover:shadow-sm">
{account.avatar_url ? ( {account.avatar_url ? (
<img <img
src={account.avatar_url} src={account.avatar_url}
alt={account.name || ''} alt={account.name || ''}
className="bg-muted h-7 w-7 rounded-full border" className="h-8 w-8 shrink-0 rounded-full border border-white/[0.08] bg-white/[0.02] object-cover"
/> />
) : ( ) : (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-white/[0.06] text-xs font-semibold text-muted-foreground"> <div className="text-muted-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-xs font-semibold">
{account.name?.[0]?.toUpperCase() || 'A'} {account.name?.[0]?.toUpperCase() || 'A'}
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="mb-0.5 flex items-center gap-2">
<span className="truncate text-[13px] font-semibold"> <span className="text-foreground truncate text-[13px] leading-none font-semibold">
{account.name || t('cloud.card.unknown')} {account.name || t('cloud.card.unknown')}
</span> </span>
<Badge <Badge
@@ -637,63 +666,62 @@ export function CompactCloudAccountCard({
? 'destructive' ? 'destructive'
: 'outline' : 'outline'
} }
className="shrink-0 text-[10px]" className="h-4 shrink-0 border-white/[0.1] bg-white/[0.02] px-1.5 py-0 text-[9px] uppercase"
> >
{account.provider.toUpperCase()} {account.provider}
</Badge> </Badge>
{account.is_active && ( {account.is_active && (
<Badge <Badge
variant="default" variant="default"
className="shrink-0 bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-[10px] hover:bg-emerald-500/20" className="h-4 shrink-0 border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0 text-[9px] text-emerald-400 uppercase"
> >
{t('cloud.card.active')} {t('cloud.card.active')}
</Badge> </Badge>
)} )}
</div> </div>
<div className="text-muted-foreground flex items-center gap-3 text-xs"> <div className="text-muted-foreground/80 flex items-center gap-3 text-[11px] font-medium">
<span className="truncate">{account.email}</span> <span className="truncate">{account.email}</span>
{validationBlockedStatusLabel && ( {validationBlockedStatusLabel && (
<span className="text-destructive shrink-0 font-medium"> <span className="text-destructive shrink-0">{validationBlockedStatusLabel}</span>
{validationBlockedStatusLabel}
</span>
)} )}
{shouldShowAiCredits && aiCredits && ( {shouldShowAiCredits && aiCredits && (
<span className="shrink-0 text-blue-500"> <span className="flex shrink-0 items-center gap-1.5 text-blue-400">
{t('cloud.card.aiCreditsValue', { <span>
amount: formatAiCreditsAmount(aiCredits.credits), {t('cloud.card.aiCreditsValue', {
})} amount: formatAiCreditsAmount(aiCredits.credits),
})}
</span>
{aiCredits.expiryDate && ( {aiCredits.expiryDate && (
<span className="text-muted-foreground"> <>
{' '} <span className="h-2.5 w-[1px] bg-blue-500/30"></span>
·{' '} <span className="text-blue-400/70">
{t('cloud.card.creditsExpiry', { {t('cloud.card.creditsExpiry', {
date: formatCreditsExpiry(aiCredits.expiryDate), date: formatCreditsExpiry(aiCredits.expiryDate),
})} })}
</span> </span>
</>
)} )}
</span> </span>
)} )}
</div> </div>
{compactModels.length > 0 && ( {compactModels.length > 0 && (
<div className="mt-1 flex items-center gap-1"> <div className="mt-2 flex items-center gap-1.5">
{compactModels.map(([modelName, info]) => ( {compactModels.map(([modelName, info]) => (
<TooltipProvider key={modelName}> <TooltipProvider key={modelName}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="bg-muted h-1.5 w-12 overflow-hidden rounded-full"> <div className="h-1.5 w-10 overflow-hidden rounded-full bg-white/[0.04]">
<div <div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`} className={`h-full rounded-full transition-all duration-300 ease-out ${getQuotaBarColorClass(info.percentage)}`}
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }} style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
/> />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent side="bottom" className="text-xs font-medium">
<p className="text-xs"> {formatModelDisplayName(modelName)}: {info.percentage}%
{formatModelDisplayName(modelName)}: {info.percentage}%
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -706,29 +734,36 @@ export function CompactCloudAccountCard({
{allModelEntries.length > 0 && ( {allModelEntries.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full"> <Button
variant="ghost"
size="icon"
className="text-muted-foreground/70 hover:text-foreground h-8 w-8"
>
{(() => { {(() => {
const hiddenCount = allModelEntries.filter( const hiddenCount = allModelEntries.filter(
([modelName]) => config?.model_visibility?.[modelName] === false, ([modelName]) => config?.model_visibility?.[modelName] === false,
).length; ).length;
return hiddenCount > 0 ? ( return hiddenCount > 0 ? (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" /> <EyeOff className="h-4 w-4" />
) : ( ) : (
<Eye className="h-3.5 w-3.5" /> <Eye className="h-4 w-4" />
); );
})()} })()}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="end"> <DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel> <DropdownMenuLabel className="text-xs">
<div className="max-h-64 overflow-auto px-2 py-1"> {t('cloud.card.modelVisibility')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-[250px] overflow-y-auto p-1">
{allModelEntries.map(([modelName]) => { {allModelEntries.map(([modelName]) => {
const isVisible = config?.model_visibility?.[modelName] !== false; const isVisible = config?.model_visibility?.[modelName] !== false;
return ( return (
<DropdownMenuItem <DropdownMenuItem
key={modelName} key={modelName}
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
className="flex cursor-pointer items-center gap-2" className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5"
> >
<Checkbox <Checkbox
checked={isVisible} checked={isVisible}
@@ -739,8 +774,9 @@ export function CompactCloudAccountCard({
saveConfig({ ...config, model_visibility: newVisibility }); saveConfig({ ...config, model_visibility: newVisibility });
} }
}} }}
className="h-3.5 w-3.5"
/> />
<span className="truncate text-xs" title={modelName}> <span className="truncate text-xs font-medium" title={modelName}>
{formatModelDisplayName(modelName)} {formatModelDisplayName(modelName)}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
@@ -752,27 +788,27 @@ export function CompactCloudAccountCard({
)} )}
{account.is_active ? ( {account.is_active ? (
<div className="flex h-8 items-center px-2">
<span className="flex items-center gap-1.5 text-[11px] font-bold text-emerald-400">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
</span>
Active
</span>
</div>
) : (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
disabled
className="h-7 text-xs text-emerald-400 opacity-100"
>
<Power className="mr-1 h-3 w-3" />
{t('cloud.card.active')}
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => onSwitch(account.id)} onClick={() => onSwitch(account.id)}
disabled={isSwitching} disabled={isSwitching}
className="h-7 cursor-pointer text-xs" className="hover:bg-primary/10 hover:text-primary h-8 cursor-pointer px-3 text-xs font-medium transition-colors"
> >
{isSwitching ? ( {isSwitching ? (
<RefreshCw className="h-3 w-3 animate-spin" /> <RefreshCw className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Power className="mr-1 h-3 w-3" /> <Power className="mr-1.5 h-3.5 w-3.5" />
)} )}
{t('cloud.card.use')} {t('cloud.card.use')}
</Button> </Button>
@@ -780,35 +816,47 @@ export function CompactCloudAccountCard({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full"> <Button
<MoreVertical className="h-3.5 w-3.5" /> variant="ghost"
size="icon"
className="text-muted-foreground/70 hover:text-foreground h-8 w-8"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Menu</span> <span className="sr-only">Menu</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel> <DropdownMenuItem
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}> onClick={() => onSwitch(account.id)}
disabled={isSwitching}
className="cursor-pointer"
>
<Power className="mr-2 h-4 w-4" /> <Power className="mr-2 h-4 w-4" />
{t('cloud.card.useAccount')} <span className="font-medium">{t('cloud.card.useAccount')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuItem
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}> onClick={() => onRefresh(account.id)}
disabled={isRefreshing}
className="cursor-pointer"
>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{t('cloud.card.refresh')} <span>{t('cloud.card.refresh')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuItem
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}> onClick={() => onManageIdentity(account.id)}
className="cursor-pointer"
>
<Fingerprint className="mr-2 h-4 w-4" /> <Fingerprint className="mr-2 h-4 w-4" />
{t('cloud.card.identityProfile')} <span>{t('cloud.card.identityProfile')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => onDelete(account.id)} onClick={() => onDelete(account.id)}
className="text-destructive focus:text-destructive" className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer"
disabled={isDeleting} disabled={isDeleting}
> >
<Trash className="mr-2 h-4 w-4" /> <Trash className="mr-2 h-4 w-4" />
{t('cloud.card.delete')} <span>{t('cloud.card.delete')}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
File diff suppressed because it is too large. Load diff
+95
View File
@@ -0,0 +1,95 @@
import { useTranslation } from 'react-i18next';
import { Cloud } from 'lucide-react';
import { CloudAccountCard, CompactCloudAccountCard } from '@/components/CloudAccountCard';
import { type GridLayout } from '@/components/CloudAccountList';
import type { CloudAccount } from '@/types/cloudAccount';
export const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
auto: 'grid gap-3 md:grid-cols-2 xl:grid-cols-3',
'2-col': 'grid gap-3 grid-cols-2',
'3-col': 'grid gap-3 grid-cols-3',
list: 'grid gap-3 grid-cols-1',
compact: 'flex flex-col gap-2',
};
interface AccountsGridProps {
accounts: CloudAccount[];
gridLayout: GridLayout;
selectedIds: Set<string>;
onToggleSelection: (id: string, selected: boolean) => void;
onRefresh: (id: string) => void;
onDelete: (id: string) => void;
onSwitch: (id: string) => void;
onManageIdentity: (id: string) => void;
refreshingAccountId?: string;
deletingAccountId?: string;
switchingAccountId?: string;
isRefreshPending: boolean;
isDeletePending: boolean;
isSwitchPending: boolean;
}
export function AccountsGrid(props: AccountsGridProps) {
const { t } = useTranslation();
const {
accounts,
gridLayout,
selectedIds,
onToggleSelection,
onRefresh,
onDelete,
onSwitch,
onManageIdentity,
refreshingAccountId,
deletingAccountId,
switchingAccountId,
isRefreshPending,
isDeletePending,
isSwitchPending,
} = props;
return (
<div className={GRID_LAYOUT_CLASSES[gridLayout]}>
{accounts.map((account) =>
gridLayout === 'compact' ? (
<CompactCloudAccountCard
key={account.id}
account={account}
onRefresh={onRefresh}
onDelete={onDelete}
onSwitch={onSwitch}
onManageIdentity={onManageIdentity}
isRefreshing={isRefreshPending && refreshingAccountId === account.id}
isDeleting={isDeletePending && deletingAccountId === account.id}
isSwitching={isSwitchPending && switchingAccountId === account.id}
/>
) : (
<CloudAccountCard
key={account.id}
account={account}
onRefresh={onRefresh}
onDelete={onDelete}
onSwitch={onSwitch}
onManageIdentity={onManageIdentity}
isSelected={selectedIds.has(account.id)}
onToggleSelection={onToggleSelection}
isRefreshing={isRefreshPending && refreshingAccountId === account.id}
isDeleting={isDeletePending && deletingAccountId === account.id}
isSwitching={isSwitchPending && switchingAccountId === account.id}
/>
),
)}
{accounts.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center rounded-xl border border-dashed border-white/[0.08] bg-white/[0.01] py-20 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/[0.04]">
<Cloud className="text-muted-foreground/40 h-8 w-8" />
</div>
<div className="mt-4 text-sm font-medium">{t('cloud.list.noAccounts')}</div>
<p className="text-muted-foreground mt-1 max-w-xs text-xs leading-relaxed">
{t('cloud.description')}
</p>
</div>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useTranslation } from 'react-i18next';
import { clampQuotaPercentage, getQuotaStatus, type QuotaStatus } from '@/utils/quota-display';
interface AccountsHeaderProps {
totalAccounts: number;
activeAccounts: number;
rateLimitedAccounts: number;
overallQuotaPercentage: number | null;
overallQuotaStatus: QuotaStatus | null;
}
const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'bg-emerald-500',
medium: 'bg-amber-500',
low: 'bg-rose-500',
};
const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'text-emerald-400',
medium: 'text-amber-400',
low: 'text-rose-400',
};
export function AccountsHeader({
totalAccounts,
activeAccounts,
rateLimitedAccounts,
overallQuotaPercentage,
overallQuotaStatus,
}: AccountsHeaderProps) {
const { t } = useTranslation();
const quotaStatus =
overallQuotaStatus ??
(overallQuotaPercentage === null ? null : getQuotaStatus(overallQuotaPercentage));
return (
<div className="mb-8">
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
{/* Title Area */}
<div className="flex flex-col gap-1.5">
<h2 className="text-foreground text-2xl font-semibold tracking-tight">
{t('cloud.title')}
</h2>
<p className="text-muted-foreground max-w-xl text-sm leading-relaxed">
{t('cloud.description')}
</p>
</div>
{/* Stats Row */}
<div className="flex flex-wrap items-center gap-3">
{/* Total & Active combined stat */}
<div className="flex min-w-[120px] flex-col justify-center rounded-xl border border-white/[0.04] bg-white/[0.01] px-4 py-3">
<div className="flex items-baseline gap-2">
<span className="text-foreground text-2xl font-bold tracking-tight">
{totalAccounts}
</span>
<span className="text-sm font-medium text-emerald-400">{activeAccounts} active</span>
</div>
<span className="text-muted-foreground/70 mt-0.5 text-xs font-medium tracking-wider uppercase">
{t('cloud.card.actions')}
</span>
</div>
{/* Rate Limited stat (only if > 0) */}
{rateLimitedAccounts > 0 && (
<div className="flex min-w-[100px] flex-col justify-center rounded-xl border border-rose-500/10 bg-rose-500/[0.02] px-4 py-3">
<span className="text-2xl font-bold tracking-tight text-rose-400">
{rateLimitedAccounts}
</span>
<span className="mt-0.5 text-xs font-medium tracking-wider text-rose-400/60 uppercase">
{t('cloud.card.rateLimited')}
</span>
</div>
)}
{/* Global Quota */}
{overallQuotaPercentage !== null && quotaStatus && (
<div className="flex min-w-[140px] flex-col justify-center rounded-xl border border-white/[0.04] bg-white/[0.01] px-4 py-3">
<div className="flex items-baseline gap-1">
<span
className={`text-2xl font-bold tracking-tight ${GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus]}`}
>
{overallQuotaPercentage}%
</span>
</div>
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-white/[0.04]">
<div
className={`h-full rounded-full transition-all duration-700 ease-in-out ${GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus]}`}
style={{ width: `${clampQuotaPercentage(overallQuotaPercentage)}%` }}
/>
</div>
<span className="text-muted-foreground/70 mt-1.5 text-xs font-medium tracking-wider uppercase">
{t('cloud.globalQuota')}
</span>
</div>
)}
</div>
</div>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
import { useTranslation } from 'react-i18next';
import {
Check,
CheckSquare,
Columns2,
Columns3,
Download,
FileDown,
LayoutGrid,
LayoutList,
List,
MoreHorizontal,
Plus,
RefreshCcw,
SortAsc,
Upload,
Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface AccountsToolbarProps {
autoSwitchEnabled: boolean | undefined;
isSettingsLoading: boolean;
onToggleAutoSwitch: (checked: boolean) => void;
isAutoSwitchPending: boolean;
selectedCount: number;
totalCount: number;
onToggleSelectAll: () => void;
onForcePoll: () => void;
isForcePollPending: boolean;
onSyncLocal: () => void;
isSyncPending: boolean;
onOpenExportDialog: () => void;
onOpenImportDialog: () => void;
onOpenAddDialog: () => void;
currentSort: string;
sortOptions: readonly string[];
sortI18nKeys: Record<string, string>;
onSortChange: (sort: string) => void;
gridLayout: string;
onLayoutChange: (layout: string) => void;
}
export function AccountsToolbar(props: AccountsToolbarProps) {
const { t } = useTranslation();
const {
autoSwitchEnabled,
isSettingsLoading,
onToggleAutoSwitch,
isAutoSwitchPending,
selectedCount,
totalCount,
onToggleSelectAll,
onForcePoll,
isForcePollPending,
onSyncLocal,
isSyncPending,
onOpenExportDialog,
onOpenImportDialog,
onOpenAddDialog,
currentSort,
sortOptions,
sortI18nKeys,
onSortChange,
gridLayout,
onLayoutChange,
} = props;
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* Primary Actions Group */}
<div className="flex items-center gap-3">
<Button onClick={onOpenAddDialog} className="cursor-pointer shadow-sm">
<Plus className="mr-2 h-4 w-4" />
{t('cloud.addAccount')}
</Button>
<Button
variant="outline"
onClick={onToggleSelectAll}
title={t('cloud.batch.selectAll')}
className={`cursor-pointer transition-colors ${selectedCount > 0 && selectedCount === totalCount ? 'border-primary/50 bg-primary/5 text-primary' : ''}`}
>
<CheckSquare
className={`mr-2 h-4 w-4 ${selectedCount > 0 && selectedCount === totalCount ? 'text-primary' : 'opacity-70'}`}
/>
{selectedCount > 0 ? `${selectedCount} Selected` : t('cloud.batch.selectAll')}
</Button>
</div>
{/* Secondary Actions & View Options */}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
{/* Layout Toggles */}
<div className="hidden items-center gap-1 rounded-lg border border-white/[0.08] bg-white/[0.02] p-1 sm:flex">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={gridLayout === 'auto' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7 cursor-pointer rounded-md"
onClick={() => onLayoutChange('auto')}
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.auto')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={gridLayout === '2-col' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7 cursor-pointer rounded-md"
onClick={() => onLayoutChange('2-col')}
>
<Columns2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.twoCol')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={gridLayout === '3-col' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7 cursor-pointer rounded-md"
onClick={() => onLayoutChange('3-col')}
>
<Columns3 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.threeCol')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={gridLayout === 'list' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7 cursor-pointer rounded-md"
onClick={() => onLayoutChange('list')}
>
<List className="h-3.5 w-3.5" />
</Button>
</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 rounded-md"
onClick={() => onLayoutChange('compact')}
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.compact')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Sort Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="cursor-pointer gap-2 bg-white/[0.02]">
<SortAsc className="h-4 w-4 opacity-70" />
<span className="hidden text-xs sm:inline-block">{t(sortI18nKeys[currentSort])}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-muted-foreground text-xs tracking-wider uppercase">
Sort By
</DropdownMenuLabel>
<DropdownMenuSeparator />
{sortOptions.map((option) => (
<DropdownMenuItem
key={option}
className="cursor-pointer"
onClick={() => onSortChange(option)}
>
{currentSort === option ? (
<Check className="mr-2 h-4 w-4" />
) : (
<div className="mr-2 h-4 w-4" />
)}
{t(sortI18nKeys[option])}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* More Options Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="cursor-pointer bg-white/[0.02]">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* Auto-Switch Toggle embedded in menu */}
<div className="flex items-center justify-between px-3 py-2.5">
<div className="flex items-center gap-2">
<Zap
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
/>
<Label htmlFor="auto-switch" className="cursor-pointer text-sm font-medium">
{t('cloud.autoSwitch')}
</Label>
</div>
<Switch
id="auto-switch"
checked={!!autoSwitchEnabled}
onCheckedChange={onToggleAutoSwitch}
disabled={isSettingsLoading || isAutoSwitchPending}
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-muted-foreground text-xs tracking-wider uppercase">
Actions
</DropdownMenuLabel>
<DropdownMenuItem
onClick={onForcePoll}
disabled={isForcePollPending}
className="cursor-pointer"
>
<RefreshCcw className={`mr-2 h-4 w-4 ${isForcePollPending ? 'animate-spin' : ''}`} />
{t('cloud.checkQuota')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onSyncLocal}
disabled={isSyncPending}
className="cursor-pointer"
>
<Download className={`mr-2 h-4 w-4 ${isSyncPending ? 'animate-bounce' : ''}`} />
{t('cloud.syncFromIDE')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-muted-foreground text-xs tracking-wider uppercase">
Data
</DropdownMenuLabel>
<DropdownMenuItem onClick={onOpenExportDialog} className="cursor-pointer">
<FileDown className="mr-2 h-4 w-4" />
{t('cloud.exportImport.export')}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenImportDialog} className="cursor-pointer">
<Upload className="mr-2 h-4 w-4" />
{t('cloud.exportImport.import')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
@@ -0,0 +1,115 @@
import type React from 'react';
import { useTranslation } from 'react-i18next';
import { Cloud, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface AddAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
authCode: string;
onAuthCodeChange: (code: string) => void;
selectedOAuthClientKey: string;
onOAuthClientChange: (key: string) => void;
oauthClients: Array<{ key: string; label: string; is_active: boolean }>;
isOAuthClientsLoading: boolean;
isSetActiveOAuthClientPending: boolean;
onOpenGoogleAuth: () => void;
onSubmitAuthCode: () => void;
isAddPending: boolean;
}
export function AddAccountDialog(props: AddAccountDialogProps) {
const { t } = useTranslation();
const {
open,
onOpenChange,
authCode,
onAuthCodeChange,
selectedOAuthClientKey,
onOAuthClientChange,
oauthClients,
isOAuthClientsLoading,
isSetActiveOAuthClientPending,
onOpenGoogleAuth,
onSubmitAuthCode,
isAddPending,
} = props;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<span />
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="oauth-client-select">{t('cloud.authDialog.oauthClient')}</Label>
<Select
value={selectedOAuthClientKey || undefined}
onValueChange={onOAuthClientChange}
disabled={isOAuthClientsLoading || isSetActiveOAuthClientPending}
>
<SelectTrigger id="oauth-client-select">
<SelectValue placeholder={t('cloud.authDialog.oauthClientPlaceholder')} />
</SelectTrigger>
<SelectContent>
{oauthClients.map((client) => (
<SelectItem key={client.key} value={client.key}>
{client.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Button variant="outline" className="col-span-4" onClick={onOpenGoogleAuth}>
<Cloud className="mr-2 h-4 w-4" />
{t('cloud.authDialog.openLogin')}
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="code">{t('cloud.authDialog.authCode')}</Label>
<Input
id="code"
placeholder={t('cloud.authDialog.placeholder')}
value={authCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onAuthCodeChange(e.target.value)
}
/>
<p className="text-muted-foreground text-xs">{t('cloud.authDialog.instruction')}</p>
</div>
</div>
<DialogFooter>
<Button onClick={onSubmitAuthCode} disabled={isAddPending || !authCode}>
{isAddPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cloud.authDialog.verify')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next';
import { RefreshCw, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface BatchActionBarProps {
selectedCount: number;
onClearSelection: () => void;
onRefreshSelected: () => void;
onDeleteSelected: () => void;
}
export function BatchActionBar({
selectedCount,
onClearSelection,
onRefreshSelected,
onDeleteSelected,
}: BatchActionBarProps) {
const { t } = useTranslation();
if (selectedCount <= 0) return null;
return (
<div className="bg-card animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border border-white/[0.1] px-6 py-2 shadow-2xl shadow-black/30">
<div className="flex items-center gap-2 border-r pr-4">
<span className="text-sm font-semibold">
{t('cloud.batch.selected', { count: selectedCount })}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full"
onClick={onClearSelection}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={onRefreshSelected}>
<RefreshCw className="mr-2 h-3 w-3" />
{t('cloud.batch.refresh')}
</Button>
<Button variant="destructive" size="sm" onClick={onDeleteSelected}>
<Trash2 className="mr-2 h-3 w-3" />
{t('cloud.batch.delete')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,147 @@
import type React from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface ExportImportDialogsProps {
isExportDialogOpen: boolean;
onExportDialogChange: (open: boolean) => void;
onExport: (stripTokens: boolean) => void;
isExportPending: boolean;
isImportDialogOpen: boolean;
onImportDialogChange: (open: boolean) => void;
importStrategy: 'merge' | 'overwrite' | 'skip-existing';
onImportStrategyChange: (strategy: 'merge' | 'overwrite' | 'skip-existing') => void;
importFileName: string;
onImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
importFileContent: string | null;
onImport: () => void;
isImportPending: boolean;
fileInputRef: React.RefObject<HTMLInputElement | null>;
}
export function ExportImportDialogs(props: ExportImportDialogsProps) {
const { t } = useTranslation();
const {
isExportDialogOpen,
onExportDialogChange,
onExport,
isExportPending,
isImportDialogOpen,
onImportDialogChange,
importStrategy,
onImportStrategyChange,
importFileName,
onImportFileSelect,
importFileContent,
onImport,
isImportPending,
fileInputRef,
} = props;
return (
<>
<Dialog open={isExportDialogOpen} onOpenChange={onExportDialogChange}>
<DialogTrigger asChild>
<span />
</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={() => onExport(false)}
disabled={isExportPending}
className="w-full cursor-pointer sm:flex-1"
>
{isExportPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cloud.exportImport.includeTokens')}
</Button>
<Button
onClick={() => onExport(true)}
disabled={isExportPending}
className="w-full cursor-pointer sm:flex-1"
>
{isExportPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cloud.exportImport.stripTokens')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isImportDialogOpen} onOpenChange={onImportDialogChange}>
<DialogTrigger asChild>
<span />
</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={onImportFileSelect}
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={onImportStrategyChange}>
<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={onImport} disabled={!importFileContent || isImportPending}>
{isImportPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isImportPending ? t('cloud.exportImport.importing') : t('cloud.exportImport.import')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}