feat(ui): migrate cliproxy page to design system Config archetype

Wrap master-detail layout in PageShell + PageHeader + ConfigLayout.
PageHeader carries the brand strip (Zap icon + CLIProxy title +
account-management description) and refresh button. The existing
sidebar (Quick Setup CTA, sectioned providers list, variants list,
ProxyStatusWidget, footer connection summary) becomes ConfigLayout's
left rail; ProviderEditor and EmptyProviderState become the form pane.

Pure layout migration: every sub-flow still mounts correctly --
QuickSetupWizard dialog, AddAccountDialog (URL action=auth deep-link),
AccountSafetyWarningCard for gemini/agy, provider selection persisted
to localStorage and URL deep-link, all bulk pause/resume/solo/delete
mutations untouched. ProviderEditor (which contains the form + raw JSON
internally) remains as a single 'form' slot.
This commit is contained in:
Tam Nhu Tran
2026-04-25 13:13:14 -04:00
parent 5e3e554693
commit 9a2c09357a
+224 -218
View File
@@ -11,6 +11,8 @@ import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Check, X, RefreshCw, Sparkles, Zap, GitBranch, Trash2 } from 'lucide-react'; import { Check, X, RefreshCw, Sparkles, Zap, GitBranch, Trash2 } from 'lucide-react';
import { PageShell, PageHeader } from '@/components/page-shell';
import { ConfigLayout } from '@/components/config-layout';
import { QuickSetupWizard } from '@/components/quick-setup-wizard'; import { QuickSetupWizard } from '@/components/quick-setup-wizard';
import { AddAccountDialog } from '@/components/account/add-account-dialog'; import { AddAccountDialog } from '@/components/account/add-account-dialog';
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card'; import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
@@ -352,234 +354,238 @@ export function CliproxyPage() {
setSelectedProvider(null); setSelectedProvider(null);
}; };
return ( const sidebar = (
<div className="flex h-full min-h-0 overflow-hidden"> <div className="flex h-full flex-col bg-muted/30">
{/* Left Sidebar */} {/* Header inside the rail: Quick Setup CTA */}
<div className="w-80 border-r flex flex-col bg-muted/30"> <div className="border-b bg-background p-3">
{/* Header */} <Button
<div className="p-4 border-b bg-background"> variant="default"
<div className="flex items-center justify-between mb-1"> size="sm"
<div className="flex items-center gap-2"> className="w-full gap-2"
<Zap className="w-5 h-5 text-primary" /> onClick={() => setWizardOpen(true)}
<h1 className="font-semibold">{updateCheck?.backendLabel ?? 'CLIProxy'}</h1> >
</div> <Sparkles className="w-4 h-4" />
<Button {t('cliproxyPage.quickSetup')}
variant="ghost" </Button>
size="icon" </div>
className="h-8 w-8"
onClick={handleRefresh} {/* Providers List */}
disabled={isFetching} <ScrollArea className="flex-1">
> <div className="p-2">
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} /> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
</Button> {t('cliproxyPage.providers')}
</div> </div>
<p className="text-xs text-muted-foreground mb-3"> {authLoading ? (
{t('cliproxyPage.accountManagement')} <div className="space-y-2 px-2">
</p> {[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
<Button ))}
variant="default"
size="sm"
className="w-full gap-2"
onClick={() => setWizardOpen(true)}
>
<Sparkles className="w-4 h-4" />
{t('cliproxyPage.quickSetup')}
</Button>
</div>
{/* Providers List */}
<ScrollArea className="flex-1">
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
{t('cliproxyPage.providers')}
</div> </div>
{authLoading ? ( ) : (
<div className="space-y-2 px-2"> <div className="space-y-4">
{[1, 2, 3, 4].map((i) => ( {providerSections.map((section) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" /> <div key={section.id} className="space-y-1">
))} <div className="px-3">
</div> <div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
) : ( {t(section.labelKey)}
<div className="space-y-4">
{providerSections.map((section) => (
<div key={section.id} className="space-y-1">
<div className="px-3">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t(section.labelKey)}
</div>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
{t(section.hintKey)}
</p>
</div>
<div className="space-y-1">
{section.items.map((status) => (
<ProviderSidebarItem
key={status.provider}
status={status}
isSelected={effectiveProvider === status.provider}
onSelect={() => handleSelectProvider(status.provider)}
/>
))}
</div> </div>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
{t(section.hintKey)}
</p>
</div> </div>
<div className="space-y-1">
{section.items.map((status) => (
<ProviderSidebarItem
key={status.provider}
status={status}
isSelected={effectiveProvider === status.provider}
onSelect={() => handleSelectProvider(status.provider)}
/>
))}
</div>
</div>
))}
</div>
)}
{/* Variants Section */}
{variants.length > 0 && (
<>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5">
<GitBranch className="w-3 h-3" />
{t('cliproxyPage.variants')}
</div>
<div className="space-y-1">
{variants.map((variant) => (
<VariantSidebarItem
key={variant.name}
variant={variant}
parentAuth={providers.find((p) => p.provider === variant.provider)}
isSelected={selectedVariant === variant.name}
onSelect={() => handleSelectVariant(variant.name)}
onDelete={() => deleteMutation.mutate(variant.name)}
isDeleting={deleteMutation.isPending}
/>
))} ))}
</div> </div>
)} </>
)}
{/* Variants Section */}
{variants.length > 0 && (
<>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5">
<GitBranch className="w-3 h-3" />
{t('cliproxyPage.variants')}
</div>
<div className="space-y-1">
{variants.map((variant) => (
<VariantSidebarItem
key={variant.name}
variant={variant}
parentAuth={providers.find((p) => p.provider === variant.provider)}
isSelected={selectedVariant === variant.name}
onSelect={() => handleSelectVariant(variant.name)}
onDelete={() => deleteMutation.mutate(variant.name)}
isDeleting={deleteMutation.isPending}
/>
))}
</div>
</>
)}
</div>
</ScrollArea>
{/* Proxy Status Widget */}
<div className="p-3 border-t">
<ProxyStatusWidget />
</div> </div>
</ScrollArea>
{/* Footer Stats */} {/* Proxy Status Widget */}
<div className="p-3 border-t bg-background text-xs text-muted-foreground"> <div className="border-t p-3">
<div className="flex items-center justify-between"> <ProxyStatusWidget />
<span>{t('cliproxyPage.providerCount', { count: providers.length })}</span>
<span className="flex items-center gap-1">
<Check className="w-3 h-3 text-green-600" />
{t('cliproxyPage.connectedCount', {
count: providers.filter((p) => p.authenticated).length,
})}
</span>
</div>
</div>
</div> </div>
{/* Right Panel */} {/* Footer Stats */}
<div className="flex-1 flex min-w-0 flex-col overflow-hidden bg-background"> <div className="border-t bg-background p-3 text-xs text-muted-foreground">
{selectedVariantData && parentAuthForVariant ? ( <div className="flex items-center justify-between">
<> <span>{t('cliproxyPage.providerCount', { count: providers.length })}</span>
<ProviderEditor <span className="flex items-center gap-1">
provider={selectedVariantData.name} <Check className="w-3 h-3 text-green-600" />
displayName={t('cliproxyPage.variantDisplay', { {t('cliproxyPage.connectedCount', {
name: selectedVariantData.name, count: providers.filter((p) => p.authenticated).length,
})}
</span>
</div>
</div>
</div>
);
const detail = (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-background">
{selectedVariantData && parentAuthForVariant ? (
<>
<ProviderEditor
provider={selectedVariantData.name}
displayName={t('cliproxyPage.variantDisplay', {
name: selectedVariantData.name,
provider: selectedVariantData.provider,
})}
authStatus={parentAuthForVariant}
catalog={catalogs[selectedVariantData.provider]}
routing={routingHints[selectedVariantData.provider]}
logoProvider={selectedVariantData.provider}
baseProvider={selectedVariantData.provider}
defaultTarget={selectedVariantData.target}
isRemoteMode={isRemoteMode}
port={selectedVariantData.port}
topNotice={
showAccountSafetyWarning ? (
<AccountSafetyWarningCard compact showProxySettingsLink />
) : undefined
}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedVariantData.provider, provider: selectedVariantData.provider,
})} displayName: parentAuthForVariant.displayName,
authStatus={parentAuthForVariant} isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
catalog={catalogs[selectedVariantData.provider]} })
routing={routingHints[selectedVariantData.provider]} }
logoProvider={selectedVariantData.provider} onSetDefault={(accountId) =>
baseProvider={selectedVariantData.provider} setDefaultMutation.mutate({
defaultTarget={selectedVariantData.target} provider: selectedVariantData.provider,
isRemoteMode={isRemoteMode} accountId,
port={selectedVariantData.port} })
topNotice={ }
showAccountSafetyWarning ? ( onRemoveAccount={(accountId) =>
<AccountSafetyWarningCard compact showProxySettingsLink /> removeMutation.mutate({
) : undefined provider: selectedVariantData.provider,
} accountId,
onAddAccount={() => })
setAddAccountProvider({ }
provider: selectedVariantData.provider, onPauseToggle={(accountId, paused) =>
displayName: parentAuthForVariant.displayName, handlePauseToggle(selectedVariantData.provider, accountId, paused)
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0, }
}) onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
} onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)}
onSetDefault={(accountId) => onBulkResume={(accountIds) =>
setDefaultMutation.mutate({ handleBulkResume(selectedVariantData.provider, accountIds)
provider: selectedVariantData.provider, }
accountId, isRemovingAccount={removeMutation.isPending}
}) isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
} isSoloingAccount={soloMutation.isPending}
onRemoveAccount={(accountId) => isBulkPausing={bulkPauseMutation.isPending}
removeMutation.mutate({ isBulkResuming={bulkResumeMutation.isPending}
provider: selectedVariantData.provider, />
accountId, </>
}) ) : selectedStatus ? (
} <>
onPauseToggle={(accountId, paused) => <ProviderEditor
handlePauseToggle(selectedVariantData.provider, accountId, paused) provider={selectedStatus.provider}
} displayName={selectedStatus.displayName}
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)} authStatus={selectedStatus}
onBulkPause={(accountIds) => catalog={catalogs[selectedStatus.provider]}
handleBulkPause(selectedVariantData.provider, accountIds) routing={routingHints[selectedStatus.provider]}
} isRemoteMode={isRemoteMode}
onBulkResume={(accountIds) => topNotice={
handleBulkResume(selectedVariantData.provider, accountIds) showAccountSafetyWarning ? (
} <AccountSafetyWarningCard compact showProxySettingsLink />
isRemovingAccount={removeMutation.isPending} ) : undefined
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} }
isSoloingAccount={soloMutation.isPending} onAddAccount={() =>
isBulkPausing={bulkPauseMutation.isPending} setAddAccountProvider({
isBulkResuming={bulkResumeMutation.isPending} provider: selectedStatus.provider,
/> displayName: selectedStatus.displayName,
</> isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
) : selectedStatus ? ( })
<> }
<ProviderEditor onSetDefault={(accountId) =>
provider={selectedStatus.provider} setDefaultMutation.mutate({
displayName={selectedStatus.displayName} provider: selectedStatus.provider,
authStatus={selectedStatus} accountId,
catalog={catalogs[selectedStatus.provider]} })
routing={routingHints[selectedStatus.provider]} }
isRemoteMode={isRemoteMode} onRemoveAccount={(accountId) =>
topNotice={ removeMutation.mutate({
showAccountSafetyWarning ? ( provider: selectedStatus.provider,
<AccountSafetyWarningCard compact showProxySettingsLink /> accountId,
) : undefined })
} }
onAddAccount={() => onPauseToggle={(accountId, paused) =>
setAddAccountProvider({ handlePauseToggle(selectedStatus.provider, accountId, paused)
provider: selectedStatus.provider, }
displayName: selectedStatus.displayName, onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0, onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
}) onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
} isRemovingAccount={removeMutation.isPending}
onSetDefault={(accountId) => isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
setDefaultMutation.mutate({ isSoloingAccount={soloMutation.isPending}
provider: selectedStatus.provider, isBulkPausing={bulkPauseMutation.isPending}
accountId, isBulkResuming={bulkResumeMutation.isPending}
}) />
} </>
onRemoveAccount={(accountId) => ) : (
removeMutation.mutate({ <EmptyProviderState onSetup={() => setWizardOpen(true)} />
provider: selectedStatus.provider, )}
accountId, </div>
}) );
}
onPauseToggle={(accountId, paused) => return (
handlePauseToggle(selectedStatus.provider, accountId, paused) <PageShell>
} <PageHeader
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)} title={
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)} <span className="flex items-center gap-2">
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)} <Zap className="w-5 h-5 text-primary" />
isRemovingAccount={removeMutation.isPending} {updateCheck?.backendLabel ?? 'CLIProxy'}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} </span>
isSoloingAccount={soloMutation.isPending} }
isBulkPausing={bulkPauseMutation.isPending} description={t('cliproxyPage.accountManagement')}
isBulkResuming={bulkResumeMutation.isPending} actions={
/> <Button
</> variant="ghost"
) : ( size="icon"
<EmptyProviderState onSetup={() => setWizardOpen(true)} /> className="h-8 w-8"
)} onClick={handleRefresh}
</div> disabled={isFetching}
aria-label="Refresh"
>
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button>
}
/>
<ConfigLayout left={sidebar} form={detail} />
{/* Dialogs */} {/* Dialogs */}
<QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} /> <QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
@@ -595,6 +601,6 @@ export function CliproxyPage() {
} }
isFirstAccount={addAccountProvider?.isFirstAccount || false} isFirstAccount={addAccountProvider?.isFirstAccount || false}
/> />
</div> </PageShell>
); );
} }