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:
1 parent
7d2977b665
commit
bf5a42c03e
8 files changed
+1719
-1416
No files matched your search
+305
-257
@@ -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>
|
||||||
|
|||||||
+634
-1159
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user