refactor(ui): split quick-setup-wizard into setup/wizard/ directory

- extract 541-line component into step modules

- wrap accounts in useMemo for stable deps

- add barrel export in setup/index.ts
This commit is contained in:
kaitranntt
2025-12-19 19:41:51 -05:00
parent 6778c4d637
commit 81196b0ff1
11 changed files with 755 additions and 537 deletions
+26 -537
View File
@@ -1,541 +1,30 @@
/**
* Quick Setup Wizard Component
* Phase 03: Multi-account dashboard support
*
* Step-by-step wizard: Provider -> Auth -> Account -> Variant -> Success
* Quick Setup Wizard - Re-export from modular structure
* @deprecated Import from '@/components/setup/wizard' directly
*/
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Copy,
Check,
RefreshCw,
ChevronRight,
ArrowLeft,
Terminal,
User,
Sparkles,
ExternalLink,
Loader2,
} from 'lucide-react';
import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
import { applyDefaultPreset } from '@/lib/preset-utils';
import { cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { toast } from 'sonner';
/* eslint-disable react-refresh/only-export-components */
export {
QuickSetupWizard,
ProgressIndicator,
ProviderStep,
AuthStep,
AccountStep,
VariantStep,
SuccessStep,
PROVIDERS,
ALL_STEPS,
getStepProgress,
} from './setup/wizard';
interface QuickSetupWizardProps {
open: boolean;
onClose: () => void;
}
type WizardStep = 'provider' | 'auth' | 'account' | 'variant' | 'success';
const providers = [
{ id: 'gemini', name: 'Google Gemini', description: 'Gemini Pro/Flash models' },
{ id: 'codex', name: 'OpenAI Codex', description: 'GPT-4 and codex models' },
{ id: 'agy', name: 'Antigravity', description: 'Antigravity AI models' },
{ id: 'qwen', name: 'Alibaba Qwen', description: 'Qwen Code models' },
{ id: 'iflow', name: 'iFlow', description: 'iFlow AI models' },
];
export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
const [step, setStep] = useState<WizardStep>('provider');
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedAccount, setSelectedAccount] = useState<OAuthAccount | null>(null);
const [variantName, setVariantName] = useState('');
const [modelName, setModelName] = useState('');
const [copied, setCopied] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isAddingNewAccount, setIsAddingNewAccount] = useState(false); // Track explicit "add account" action
const { data: authData, refetch } = useCliproxyAuth();
const createMutation = useCreateVariant();
const startAuthMutation = useStartAuth();
const { privacyMode } = usePrivacy();
// Get auth status for selected provider
const providerAuth = authData?.authStatus.find(
(s: AuthStatus) => s.provider === selectedProvider
);
const accounts = providerAuth?.accounts || [];
// Reset on close - use timeout to avoid synchronous setState in effect
useEffect(() => {
if (!open) {
const timer = setTimeout(() => {
setStep('provider');
setSelectedProvider('');
setSelectedAccount(null);
setVariantName('');
setModelName('');
setCopied(false);
setIsAddingNewAccount(false);
}, 0);
return () => clearTimeout(timer);
}
}, [open]);
// Auto-advance from auth step when account detected
// BUT only if user didn't explicitly click "Add new account"
useEffect(() => {
if (step === 'auth' && accounts.length > 0 && !isAddingNewAccount) {
const timer = setTimeout(() => {
setStep('account');
}, 0);
return () => clearTimeout(timer);
}
}, [step, accounts, isAddingNewAccount]);
const copyCommand = async (cmd: string) => {
await navigator.clipboard.writeText(cmd);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleRefresh = async () => {
setIsRefreshing(true);
await refetch();
setIsRefreshing(false);
};
const handleStartAuth = () => {
// Check if this is the first account before auth
const isFirstAccount = (providerAuth?.accounts?.length || 0) === 0;
startAuthMutation.mutate(
{ provider: selectedProvider },
{
onSuccess: async (data) => {
// Apply default preset if this was the first account
if (isFirstAccount) {
const result = await applyDefaultPreset(selectedProvider);
if (result.success && result.presetName) {
toast.success(`Applied "${result.presetName}" preset`);
} else if (!result.success) {
toast.warning('Account added, but failed to apply default preset');
}
}
// Account created, select it and advance to variant step
if (data.account) {
setSelectedAccount(data.account as OAuthAccount);
setStep('variant');
}
refetch(); // Refresh auth status
},
}
);
};
const handleProviderSelect = (providerId: string) => {
setSelectedProvider(providerId);
const auth = authData?.authStatus.find((s: AuthStatus) => s.provider === providerId);
const provAccounts = auth?.accounts || [];
if (provAccounts.length === 0) {
// No accounts - go to auth step to add first account
setStep('auth');
} else {
// Has accounts - always show account selection (includes "Add new account" option)
setStep('account');
}
};
const handleAccountSelect = (account: OAuthAccount) => {
setSelectedAccount(account);
setStep('variant');
};
const handleCreateVariant = async () => {
if (!variantName || !selectedProvider) return;
try {
await createMutation.mutateAsync({
name: variantName,
provider: selectedProvider as 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow',
model: modelName || undefined,
account: selectedAccount?.id,
});
setStep('success');
} catch (error) {
console.error('Failed to create variant:', error);
}
};
const authCommand = `ccs ${selectedProvider} --auth --add`;
// Progress steps for indicator
const allSteps = ['provider', 'auth', 'variant', 'success'];
const getStepProgress = (s: WizardStep) => {
if (s === 'account') return 1; // Same as auth
return allSteps.indexOf(s);
};
const currentProgress = getStepProgress(step);
// Prevent accidental close when user has made progress
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
// Allow closing from success step or provider step (no progress yet)
if (step === 'success' || step === 'provider') {
onClose();
return;
}
// For other steps, require explicit close via Cancel/Back
// The X button still works, but clicking outside doesn't close
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-lg"
onPointerDownOutside={(e) => {
// Prevent closing on outside click when user has made progress
if (step !== 'success' && step !== 'provider') {
e.preventDefault();
}
}}
onEscapeKeyDown={(e) => {
// Prevent ESC close during auth or variant creation
if (startAuthMutation.isPending || createMutation.isPending) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
Quick Setup Wizard
</DialogTitle>
<DialogDescription>
{step === 'provider' && 'Select a provider to get started'}
{step === 'auth' && 'Authenticate with your provider'}
{step === 'account' && 'Select which account to use'}
{step === 'variant' && 'Create your custom variant'}
{step === 'success' && 'Setup complete!'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Step: Provider Selection */}
{step === 'provider' && (
<div className="grid gap-2">
{providers.map((p) => (
<button
key={p.id}
onClick={() => handleProviderSelect(p.id)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div>
<div className="font-medium">{p.name}</div>
<div className="text-xs text-muted-foreground">{p.description}</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
)}
{/* Step: Authentication */}
{step === 'auth' && (
<div className="space-y-4">
{/* Primary: OAuth Button */}
<div className="text-center space-y-3">
<p className="text-sm text-muted-foreground">
Authenticate with {providers.find((p) => p.id === selectedProvider)?.name} to add
an account
</p>
<Button
onClick={handleStartAuth}
disabled={startAuthMutation.isPending}
className="w-full gap-2"
size="lg"
>
{startAuthMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Authenticating...
</>
) : (
<>
<ExternalLink className="w-4 h-4" />
Authenticate in Browser
</>
)}
</Button>
{startAuthMutation.isPending && (
<p className="text-xs text-muted-foreground">
Complete the OAuth flow in your browser...
</p>
)}
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or use terminal</span>
</div>
</div>
{/* Secondary: CLI Command */}
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Terminal className="w-4 h-4" />
Run this command in your terminal:
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm">
{authCommand}
</code>
<Button variant="outline" size="icon" onClick={() => copyCommand(authCommand)}>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={() => setStep('provider')}
disabled={startAuthMutation.isPending}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button
variant="outline"
onClick={handleRefresh}
disabled={isRefreshing || startAuthMutation.isPending}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Checking...' : 'Refresh Status'}
</Button>
</div>
</div>
)}
{/* Step: Account Selection */}
{step === 'account' && (
<div className="space-y-4">
{/* Existing accounts header */}
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Select an account ({accounts.length})
</div>
<div className="grid gap-2">
{accounts.map((acc: OAuthAccount) => (
<button
key={acc.id}
type="button"
onClick={() => handleAccountSelect(acc)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div>
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
{acc.email || acc.id}
</div>
{acc.isDefault && (
<div className="text-xs text-muted-foreground">Default account</div>
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
</div>
</div>
{/* Add new account button - more prominent */}
<button
type="button"
className="w-full flex items-center gap-3 p-3 border-2 border-dashed border-primary/50 rounded-lg hover:border-primary hover:bg-primary/5 transition-colors text-left"
onClick={() => {
setIsAddingNewAccount(true);
setStep('auth');
}}
>
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<ExternalLink className="w-4 h-4 text-primary" />
</div>
<div>
<div className="font-medium text-primary">Add new account</div>
<div className="text-xs text-muted-foreground">
Authenticate with a different account
</div>
</div>
</button>
<div className="flex items-center justify-between pt-2">
<Button variant="ghost" onClick={() => setStep('provider')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</div>
</div>
)}
{/* Step: Create Variant */}
{step === 'variant' && (
<div className="space-y-4">
{selectedAccount && (
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
<User className="w-4 h-4" />
<span>
Using:{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccount.email || selectedAccount.id}
</span>
</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="variant-name">Variant Name *</Label>
<Input
id="variant-name"
value={variantName}
onChange={(e) => setVariantName(e.target.value)}
placeholder="e.g., my-gemini, g3, flash"
/>
<div className="text-xs text-muted-foreground">
Use this name to invoke: ccs {variantName || '<name>'} "prompt"
</div>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Select value={modelName} onValueChange={setModelName}>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{MODEL_CATALOGS[selectedProvider]?.models.map((m) => (
<SelectItem key={m.id} value={m.id}>
<div className="flex items-center gap-2">
<span>{m.name}</span>
{m.description && (
<span className="text-xs text-muted-foreground">- {m.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
Default: {MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Button
variant="ghost"
onClick={() => (accounts.length > 0 ? setStep('account') : setStep('provider'))}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onClose}>
Skip
</Button>
<Button
onClick={handleCreateVariant}
disabled={!variantName || createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Variant'}
</Button>
</div>
</div>
<p className="text-xs text-center text-muted-foreground">
Skip if you just wanted to add an account without creating a variant
</p>
</div>
)}
{/* Step: Success */}
{step === 'success' && (
<div className="space-y-4 text-center">
<div className="flex justify-center">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<Check className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
</div>
<div>
<div className="font-semibold text-lg">Variant Created!</div>
<div className="text-sm text-muted-foreground">
Your custom variant is ready to use
</div>
</div>
<Card>
<CardContent className="p-4 space-y-2">
<div className="text-sm text-muted-foreground">Usage:</div>
<code className="block px-3 py-2 bg-muted rounded-md font-mono text-sm">
ccs {variantName} "your prompt here"
</code>
</CardContent>
</Card>
<Button onClick={onClose} className="w-full">
Done
</Button>
</div>
)}
</div>
{/* Progress indicator */}
<div className="flex justify-center gap-1 pt-2">
{allSteps.map((s, i) => (
<div
key={s}
className={`w-2 h-2 rounded-full transition-colors ${
currentProgress >= i ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
</DialogContent>
</Dialog>
);
}
export type {
WizardStep,
QuickSetupWizardProps,
ProviderOption,
ProviderStepProps,
AuthStepProps,
AccountStepProps,
VariantStepProps,
SuccessStepProps,
ProgressIndicatorProps,
} from './setup/wizard';
+17
View File
@@ -0,0 +1,17 @@
/**
* Setup Components Barrel Export
*/
// Quick setup wizard (from subdirectory)
export { QuickSetupWizard } from './wizard';
export type {
WizardStep,
QuickSetupWizardProps,
ProviderOption,
ProviderStepProps,
AuthStepProps,
AccountStepProps,
VariantStepProps,
SuccessStepProps,
ProgressIndicatorProps,
} from './wizard';
@@ -0,0 +1,20 @@
/**
* Constants for Quick Setup Wizard
*/
import type { ProviderOption } from './types';
export const PROVIDERS: ProviderOption[] = [
{ id: 'gemini', name: 'Google Gemini', description: 'Gemini Pro/Flash models' },
{ id: 'codex', name: 'OpenAI Codex', description: 'GPT-4 and codex models' },
{ id: 'agy', name: 'Antigravity', description: 'Antigravity AI models' },
{ id: 'qwen', name: 'Alibaba Qwen', description: 'Qwen Code models' },
{ id: 'iflow', name: 'iFlow', description: 'iFlow AI models' },
];
export const ALL_STEPS = ['provider', 'auth', 'variant', 'success'];
export function getStepProgress(step: string): number {
if (step === 'account') return 1; // Same as auth
return ALL_STEPS.indexOf(step);
}
+259
View File
@@ -0,0 +1,259 @@
/**
* Quick Setup Wizard Component
* Phase 03: Multi-account dashboard support
*
* Step-by-step wizard: Provider -> Auth -> Account -> Variant -> Success
*/
/* eslint-disable react-refresh/only-export-components */
import { useState, useEffect, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Sparkles } from 'lucide-react';
import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import { applyDefaultPreset } from '@/lib/preset-utils';
import { usePrivacy } from '@/contexts/privacy-context';
import { toast } from 'sonner';
import { PROVIDERS, ALL_STEPS, getStepProgress } from './constants';
import { ProgressIndicator } from './progress-indicator';
import { ProviderStep } from './steps/provider-step';
import { AuthStep } from './steps/auth-step';
import { AccountStep } from './steps/account-step';
import { VariantStep } from './steps/variant-step';
import { SuccessStep } from './steps/success-step';
import type { WizardStep, QuickSetupWizardProps } from './types';
export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
const [step, setStep] = useState<WizardStep>('provider');
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedAccount, setSelectedAccount] = useState<OAuthAccount | null>(null);
const [variantName, setVariantName] = useState('');
const [modelName, setModelName] = useState('');
const [isRefreshing, setIsRefreshing] = useState(false);
const [isAddingNewAccount, setIsAddingNewAccount] = useState(false);
const { data: authData, refetch } = useCliproxyAuth();
const createMutation = useCreateVariant();
const startAuthMutation = useStartAuth();
const { privacyMode } = usePrivacy();
// Get auth status for selected provider
const providerAuth = authData?.authStatus.find(
(s: AuthStatus) => s.provider === selectedProvider
);
const accounts = useMemo(() => providerAuth?.accounts || [], [providerAuth?.accounts]);
// Reset on close
useEffect(() => {
if (!open) {
const timer = setTimeout(() => {
setStep('provider');
setSelectedProvider('');
setSelectedAccount(null);
setVariantName('');
setModelName('');
setIsAddingNewAccount(false);
}, 0);
return () => clearTimeout(timer);
}
}, [open]);
// Auto-advance from auth step when account detected
useEffect(() => {
if (step === 'auth' && accounts.length > 0 && !isAddingNewAccount) {
const timer = setTimeout(() => {
setStep('account');
}, 0);
return () => clearTimeout(timer);
}
}, [step, accounts, isAddingNewAccount]);
const handleRefresh = async () => {
setIsRefreshing(true);
await refetch();
setIsRefreshing(false);
};
const handleStartAuth = () => {
const isFirstAccount = (providerAuth?.accounts?.length || 0) === 0;
startAuthMutation.mutate(
{ provider: selectedProvider },
{
onSuccess: async (data) => {
if (isFirstAccount) {
const result = await applyDefaultPreset(selectedProvider);
if (result.success && result.presetName) {
toast.success(`Applied "${result.presetName}" preset`);
} else if (!result.success) {
toast.warning('Account added, but failed to apply default preset');
}
}
if (data.account) {
setSelectedAccount(data.account as OAuthAccount);
setStep('variant');
}
refetch();
},
}
);
};
const handleProviderSelect = (providerId: string) => {
setSelectedProvider(providerId);
const auth = authData?.authStatus.find((s: AuthStatus) => s.provider === providerId);
const provAccounts = auth?.accounts || [];
if (provAccounts.length === 0) {
setStep('auth');
} else {
setStep('account');
}
};
const handleAccountSelect = (account: OAuthAccount) => {
setSelectedAccount(account);
setStep('variant');
};
const handleCreateVariant = async () => {
if (!variantName || !selectedProvider) return;
try {
await createMutation.mutateAsync({
name: variantName,
provider: selectedProvider as 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow',
model: modelName || undefined,
account: selectedAccount?.id,
});
setStep('success');
} catch (error) {
console.error('Failed to create variant:', error);
}
};
const authCommand = `ccs ${selectedProvider} --auth --add`;
const currentProgress = getStepProgress(step);
// Prevent accidental close when user has made progress
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
if (step === 'success' || step === 'provider') {
onClose();
return;
}
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-lg"
onPointerDownOutside={(e) => {
if (step !== 'success' && step !== 'provider') {
e.preventDefault();
}
}}
onEscapeKeyDown={(e) => {
if (startAuthMutation.isPending || createMutation.isPending) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
Quick Setup Wizard
</DialogTitle>
<DialogDescription>
{step === 'provider' && 'Select a provider to get started'}
{step === 'auth' && 'Authenticate with your provider'}
{step === 'account' && 'Select which account to use'}
{step === 'variant' && 'Create your custom variant'}
{step === 'success' && 'Setup complete!'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{step === 'provider' && (
<ProviderStep providers={PROVIDERS} onSelect={handleProviderSelect} />
)}
{step === 'auth' && (
<AuthStep
selectedProvider={selectedProvider}
providers={PROVIDERS}
authCommand={authCommand}
isRefreshing={isRefreshing}
isPending={startAuthMutation.isPending}
onBack={() => setStep('provider')}
onStartAuth={handleStartAuth}
onRefresh={handleRefresh}
/>
)}
{step === 'account' && (
<AccountStep
accounts={accounts}
privacyMode={privacyMode}
onSelect={handleAccountSelect}
onAddNew={() => {
setIsAddingNewAccount(true);
setStep('auth');
}}
onBack={() => setStep('provider')}
/>
)}
{step === 'variant' && (
<VariantStep
selectedProvider={selectedProvider}
selectedAccount={selectedAccount}
variantName={variantName}
modelName={modelName}
isPending={createMutation.isPending}
privacyMode={privacyMode}
onVariantNameChange={setVariantName}
onModelChange={setModelName}
onBack={() => (accounts.length > 0 ? setStep('account') : setStep('provider'))}
onSkip={onClose}
onCreate={handleCreateVariant}
/>
)}
{step === 'success' && <SuccessStep variantName={variantName} onClose={onClose} />}
</div>
<ProgressIndicator currentProgress={currentProgress} allSteps={ALL_STEPS} />
</DialogContent>
</Dialog>
);
}
// Re-exports
export { ProgressIndicator } from './progress-indicator';
export { ProviderStep } from './steps/provider-step';
export { AuthStep } from './steps/auth-step';
export { AccountStep } from './steps/account-step';
export { VariantStep } from './steps/variant-step';
export { SuccessStep } from './steps/success-step';
export { PROVIDERS, ALL_STEPS, getStepProgress } from './constants';
export type {
WizardStep,
QuickSetupWizardProps,
ProviderOption,
ProviderStepProps,
AuthStepProps,
AccountStepProps,
VariantStepProps,
SuccessStepProps,
ProgressIndicatorProps,
} from './types';
@@ -0,0 +1,21 @@
/**
* Progress Indicator Component
* Shows step dots for wizard progress
*/
import type { ProgressIndicatorProps } from './types';
export function ProgressIndicator({ currentProgress, allSteps }: ProgressIndicatorProps) {
return (
<div className="flex justify-center gap-1 pt-2">
{allSteps.map((s, i) => (
<div
key={s}
className={`w-2 h-2 rounded-full transition-colors ${
currentProgress >= i ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
);
}
@@ -0,0 +1,84 @@
/**
* Account Selection Step
*/
import { Button } from '@/components/ui/button';
import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import type { AccountStepProps } from '../types';
export function AccountStep({
accounts,
privacyMode,
onSelect,
onAddNew,
onBack,
}: AccountStepProps) {
return (
<div className="space-y-4">
{/* Existing accounts header */}
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Select an account ({accounts.length})
</div>
<div className="grid gap-2">
{accounts.map((acc) => (
<button
key={acc.id}
type="button"
onClick={() => onSelect(acc)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div>
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
{acc.email || acc.id}
</div>
{acc.isDefault && (
<div className="text-xs text-muted-foreground">Default account</div>
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
</div>
</div>
{/* Add new account button - more prominent */}
<button
type="button"
className="w-full flex items-center gap-3 p-3 border-2 border-dashed border-primary/50 rounded-lg hover:border-primary hover:bg-primary/5 transition-colors text-left"
onClick={onAddNew}
>
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<ExternalLink className="w-4 h-4 text-primary" />
</div>
<div>
<div className="font-medium text-primary">Add new account</div>
<div className="text-xs text-muted-foreground">Authenticate with a different account</div>
</div>
</button>
<div className="flex items-center justify-between pt-2">
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</div>
</div>
);
}
@@ -0,0 +1,97 @@
/**
* Authentication Step
*/
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Copy, Check, RefreshCw, ArrowLeft, Terminal, ExternalLink, Loader2 } from 'lucide-react';
import type { AuthStepProps } from '../types';
export function AuthStep({
selectedProvider,
providers,
authCommand,
isRefreshing,
isPending,
onBack,
onStartAuth,
onRefresh,
}: AuthStepProps) {
const [copied, setCopied] = useState(false);
const copyCommand = async (cmd: string) => {
await navigator.clipboard.writeText(cmd);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="space-y-4">
{/* Primary: OAuth Button */}
<div className="text-center space-y-3">
<p className="text-sm text-muted-foreground">
Authenticate with {providers.find((p) => p.id === selectedProvider)?.name} to add an
account
</p>
<Button onClick={onStartAuth} disabled={isPending} className="w-full gap-2" size="lg">
{isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Authenticating...
</>
) : (
<>
<ExternalLink className="w-4 h-4" />
Authenticate in Browser
</>
)}
</Button>
{isPending && (
<p className="text-xs text-muted-foreground">
Complete the OAuth flow in your browser...
</p>
)}
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or use terminal</span>
</div>
</div>
{/* Secondary: CLI Command */}
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Terminal className="w-4 h-4" />
Run this command in your terminal:
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm">
{authCommand}
</code>
<Button variant="outline" size="icon" onClick={() => copyCommand(authCommand)}>
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={onBack} disabled={isPending}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button variant="outline" onClick={onRefresh} disabled={isRefreshing || isPending}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Checking...' : 'Refresh Status'}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
/**
* Provider Selection Step
*/
import { ChevronRight } from 'lucide-react';
import type { ProviderStepProps } from '../types';
export function ProviderStep({ providers, onSelect }: ProviderStepProps) {
return (
<div className="grid gap-2">
{providers.map((p) => (
<button
key={p.id}
onClick={() => onSelect(p.id)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div>
<div className="font-medium">{p.name}</div>
<div className="text-xs text-muted-foreground">{p.description}</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
);
}
@@ -0,0 +1,35 @@
/**
* Success Step
*/
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Check } from 'lucide-react';
import type { SuccessStepProps } from '../types';
export function SuccessStep({ variantName, onClose }: SuccessStepProps) {
return (
<div className="space-y-4 text-center">
<div className="flex justify-center">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<Check className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
</div>
<div>
<div className="font-semibold text-lg">Variant Created!</div>
<div className="text-sm text-muted-foreground">Your custom variant is ready to use</div>
</div>
<Card>
<CardContent className="p-4 space-y-2">
<div className="text-sm text-muted-foreground">Usage:</div>
<code className="block px-3 py-2 bg-muted rounded-md font-mono text-sm">
ccs {variantName} "your prompt here"
</code>
</CardContent>
</Card>
<Button onClick={onClose} className="w-full">
Done
</Button>
</div>
);
}
@@ -0,0 +1,104 @@
/**
* Variant Creation Step
*/
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, User } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
import type { VariantStepProps } from '../types';
export function VariantStep({
selectedProvider,
selectedAccount,
variantName,
modelName,
isPending,
privacyMode,
onVariantNameChange,
onModelChange,
onBack,
onSkip,
onCreate,
}: VariantStepProps) {
return (
<div className="space-y-4">
{selectedAccount && (
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
<User className="w-4 h-4" />
<span>
Using:{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccount.email || selectedAccount.id}
</span>
</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="variant-name">Variant Name *</Label>
<Input
id="variant-name"
value={variantName}
onChange={(e) => onVariantNameChange(e.target.value)}
placeholder="e.g., my-gemini, g3, flash"
/>
<div className="text-xs text-muted-foreground">
Use this name to invoke: ccs {variantName || '<name>'} "prompt"
</div>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Select value={modelName} onValueChange={onModelChange}>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{MODEL_CATALOGS[selectedProvider]?.models.map((m) => (
<SelectItem key={m.id} value={m.id}>
<div className="flex items-center gap-2">
<span>{m.name}</span>
{m.description && (
<span className="text-xs text-muted-foreground">- {m.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
Default: {MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onSkip}>
Skip
</Button>
<Button onClick={onCreate} disabled={!variantName || isPending}>
{isPending ? 'Creating...' : 'Create Variant'}
</Button>
</div>
</div>
<p className="text-xs text-center text-muted-foreground">
Skip if you just wanted to add an account without creating a variant
</p>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Types for Quick Setup Wizard
*/
import type { OAuthAccount } from '@/lib/api-client';
export type WizardStep = 'provider' | 'auth' | 'account' | 'variant' | 'success';
export interface QuickSetupWizardProps {
open: boolean;
onClose: () => void;
}
export interface ProviderOption {
id: string;
name: string;
description: string;
}
export interface ProviderStepProps {
providers: ProviderOption[];
onSelect: (providerId: string) => void;
}
export interface AuthStepProps {
selectedProvider: string;
providers: ProviderOption[];
authCommand: string;
isRefreshing: boolean;
isPending: boolean;
onBack: () => void;
onStartAuth: () => void;
onRefresh: () => void;
}
export interface AccountStepProps {
accounts: OAuthAccount[];
privacyMode: boolean;
onSelect: (account: OAuthAccount) => void;
onAddNew: () => void;
onBack: () => void;
}
export interface VariantStepProps {
selectedProvider: string;
selectedAccount: OAuthAccount | null;
variantName: string;
modelName: string;
isPending: boolean;
privacyMode: boolean;
onVariantNameChange: (value: string) => void;
onModelChange: (value: string) => void;
onBack: () => void;
onSkip: () => void;
onCreate: () => void;
}
export interface SuccessStepProps {
variantName: string;
onClose: () => void;
}
export interface ProgressIndicatorProps {
currentProgress: number;
allSteps: string[];
}