Makelore 2.0 initial clean snapshot
This commit is contained in:
634
src/pages/Setup/index.tsx
Normal file
634
src/pages/Setup/index.tsx
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Setup Wizard Page
|
||||
* First-time setup experience for new users
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { TitleBar } from '@/components/layout/TitleBar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useOpencodeStore } from '@/stores/opencode';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
import logoSvg from '@/assets/logo.svg';
|
||||
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const STEP = {
|
||||
WELCOME: 0,
|
||||
RUNTIME: 1,
|
||||
INSTALLING: 2,
|
||||
COMPLETE: 3,
|
||||
} as const;
|
||||
|
||||
const getSteps = (t: TFunction): SetupStep[] => [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: t('steps.welcome.title'),
|
||||
description: t('steps.welcome.description'),
|
||||
},
|
||||
{
|
||||
id: 'runtime',
|
||||
title: t('steps.runtime.title'),
|
||||
description: t('steps.runtime.description'),
|
||||
},
|
||||
{
|
||||
id: 'installing',
|
||||
title: t('steps.installing.title'),
|
||||
description: t('steps.installing.description'),
|
||||
},
|
||||
{
|
||||
id: 'complete',
|
||||
title: t('steps.complete.title'),
|
||||
description: t('steps.complete.description'),
|
||||
},
|
||||
];
|
||||
|
||||
// Default skills to auto-install (no additional API keys required)
|
||||
interface DefaultSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const getDefaultSkills = (t: TFunction): DefaultSkill[] => [
|
||||
{ id: 'opencode', name: t('defaultSkills.opencode.name'), description: t('defaultSkills.opencode.description') },
|
||||
{ id: 'python-env', name: t('defaultSkills.python-env.name'), description: t('defaultSkills.python-env.description') },
|
||||
{ id: 'code-assist', name: t('defaultSkills.code-assist.name'), description: t('defaultSkills.code-assist.description') },
|
||||
{ id: 'file-tools', name: t('defaultSkills.file-tools.name'), description: t('defaultSkills.file-tools.description') },
|
||||
{ id: 'terminal', name: t('defaultSkills.terminal.name'), description: t('defaultSkills.terminal.description') },
|
||||
];
|
||||
|
||||
const SETUP_MESSAGE_INTERVAL_MS = 1600;
|
||||
const RUNTIME_CHECK_STUCK_MS = 8000;
|
||||
|
||||
type RuntimeProgressStatus = 'checking' | 'success' | 'error';
|
||||
type InstallPhase = 'installing' | 'failed';
|
||||
|
||||
function getTranslatedMessages(t: TFunction, key: string, fallback: string[]) {
|
||||
const value = t(key, { returnObjects: true }) as unknown;
|
||||
if (Array.isArray(value)) {
|
||||
const messages = value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
if (messages.length > 0) return messages;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function useRotatingSetupMessage(messages: string[], active = true) {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || messages.length <= 1) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
setIndex((current) => (current + 1) % messages.length);
|
||||
}, SETUP_MESSAGE_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, messages]);
|
||||
|
||||
return messages[index % Math.max(messages.length, 1)] ?? messages[0] ?? '';
|
||||
}
|
||||
|
||||
function isRuntimeReady(state: string) {
|
||||
return state === 'running' || state === 'stopped';
|
||||
}
|
||||
|
||||
export function Setup() {
|
||||
const { t } = useTranslation('setup');
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState<number>(STEP.WELCOME);
|
||||
|
||||
// Setup state
|
||||
// Installation state for the Installing step
|
||||
const [installedSkills, setInstalledSkills] = useState<string[]>([]);
|
||||
// Runtime check status
|
||||
const [runtimeChecksPassed, setRuntimeChecksPassed] = useState(false);
|
||||
|
||||
const steps = getSteps(t);
|
||||
const safeStepIndex = Number.isInteger(currentStep)
|
||||
? Math.min(Math.max(currentStep, STEP.WELCOME), steps.length - 1)
|
||||
: STEP.WELCOME;
|
||||
const step = steps[safeStepIndex] ?? steps[STEP.WELCOME];
|
||||
const isFirstStep = safeStepIndex === STEP.WELCOME;
|
||||
const isLastStep = safeStepIndex === steps.length - 1;
|
||||
|
||||
const markSetupComplete = useSettingsStore((state) => state.markSetupComplete);
|
||||
|
||||
// Derive canProceed based on current step - computed directly to avoid useEffect
|
||||
const canProceed = useMemo(() => {
|
||||
switch (safeStepIndex) {
|
||||
case STEP.WELCOME:
|
||||
return true;
|
||||
case STEP.RUNTIME:
|
||||
return runtimeChecksPassed;
|
||||
case STEP.INSTALLING:
|
||||
return false; // Cannot manually proceed, auto-proceeds when done
|
||||
case STEP.COMPLETE:
|
||||
return true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}, [safeStepIndex, runtimeChecksPassed]);
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isLastStep) {
|
||||
// Complete setup
|
||||
markSetupComplete();
|
||||
toast.success(t('complete.title'));
|
||||
navigate('/');
|
||||
} else {
|
||||
setCurrentStep((i) => i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep((i) => Math.max(i - 1, 0));
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
markSetupComplete();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// Auto-proceed when installation is complete
|
||||
const handleInstallationComplete = useCallback((skills: string[]) => {
|
||||
setInstalledSkills(skills);
|
||||
// Auto-proceed to next step after a short delay
|
||||
setTimeout(() => {
|
||||
setCurrentStep((i) => i + 1);
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<div data-testid="setup-page" className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<TitleBar />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Progress Indicator */}
|
||||
<div className="flex justify-center pt-8">
|
||||
<div className="flex items-center gap-2">
|
||||
{steps.map((s, i) => (
|
||||
<div key={s.id} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full border-2 transition-colors',
|
||||
i < safeStepIndex
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: i === safeStepIndex
|
||||
? 'border-primary text-primary'
|
||||
: 'border-slate-600 text-slate-600'
|
||||
)}
|
||||
>
|
||||
{i < safeStepIndex ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<span className="text-sm">{i + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-8 transition-colors',
|
||||
i < safeStepIndex ? 'bg-primary' : 'bg-slate-600'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="mx-auto max-w-2xl p-8"
|
||||
>
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">{t(`steps.${step.id}.title`)}</h1>
|
||||
<p className="text-slate-400">{t(`steps.${step.id}.description`)}</p>
|
||||
</div>
|
||||
|
||||
{/* Step-specific content */}
|
||||
<div className="rounded-xl bg-card text-card-foreground border shadow-sm p-8 mb-8">
|
||||
{safeStepIndex === STEP.WELCOME && <WelcomeContent />}
|
||||
{safeStepIndex === STEP.RUNTIME && <RuntimeContent onStatusChange={setRuntimeChecksPassed} />}
|
||||
{safeStepIndex === STEP.INSTALLING && (
|
||||
<InstallingContent
|
||||
skills={getDefaultSkills(t)}
|
||||
onComplete={handleInstallationComplete}
|
||||
/>
|
||||
)}
|
||||
{safeStepIndex === STEP.COMPLETE && (
|
||||
<CompleteContent
|
||||
installedSkills={installedSkills}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation - hidden during installation step */}
|
||||
{safeStepIndex !== STEP.INSTALLING && (
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
{!isFirstStep && (
|
||||
<Button variant="ghost" onClick={handleBack}>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
{t('nav.back')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isLastStep && safeStepIndex !== STEP.RUNTIME && (
|
||||
<Button data-testid="setup-skip-button" variant="ghost" onClick={handleSkip}>
|
||||
{t('nav.skipSetup')}
|
||||
</Button>
|
||||
)}
|
||||
<Button data-testid="setup-next-button" onClick={handleNext} disabled={!canProceed}>
|
||||
{isLastStep ? (
|
||||
t('nav.getStarted')
|
||||
) : (
|
||||
<>
|
||||
{t('nav.next')}
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Step Content Components ====================
|
||||
|
||||
function WelcomeContent() {
|
||||
const { t } = useTranslation(['setup', 'settings']);
|
||||
const { language, setLanguage } = useSettingsStore();
|
||||
const authUser = useAuthStore((state) => state.user);
|
||||
const userDisplayName = getAuthUserDisplayName(authUser);
|
||||
|
||||
return (
|
||||
<div data-testid="setup-welcome-step" className="text-center space-y-4">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<img src={logoSvg} alt="Makelore" className="h-16 w-20 object-contain" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{userDisplayName ? `${userDisplayName},欢迎回来!` : t('welcome.title')}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('welcome.description')}
|
||||
</p>
|
||||
|
||||
{/* Language Selector */}
|
||||
<div className="flex justify-center gap-2 py-2">
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<Button
|
||||
key={lang.code}
|
||||
variant={language === lang.code ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setLanguage(lang.code)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{lang.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ul className="text-left space-y-2 text-muted-foreground pt-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
||||
{t('welcome.features.noCommand')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
||||
{t('welcome.features.modernUI')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
||||
{t('welcome.features.bundles')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
||||
{t('welcome.features.crossPlatform')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RuntimeContentProps {
|
||||
onStatusChange: (canProceed: boolean) => void;
|
||||
}
|
||||
|
||||
function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
|
||||
const { t } = useTranslation('setup');
|
||||
const opencodeStatus = useOpencodeStore((state) => state.status);
|
||||
const refreshOpencodeStatus = useOpencodeStore((state) => state.refreshStatus);
|
||||
const stuckTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const [runtimeStatus, setRuntimeStatus] = useState<RuntimeProgressStatus>('checking');
|
||||
const [runtimeProgress, setRuntimeProgress] = useState(12);
|
||||
|
||||
const runtimeMessages = useMemo(
|
||||
() => getTranslatedMessages(t, 'runtime.progress.messages', [
|
||||
'Preparing the local workspace...',
|
||||
'Checking bundled tools...',
|
||||
'Warming up the coding assistant...',
|
||||
]),
|
||||
[t],
|
||||
);
|
||||
const rotatingMessage = useRotatingSetupMessage(runtimeMessages, runtimeStatus === 'checking');
|
||||
|
||||
const clearStuckTimeout = useCallback(() => {
|
||||
if (stuckTimeoutRef.current !== null) {
|
||||
window.clearTimeout(stuckTimeoutRef.current);
|
||||
stuckTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markSuccess = useCallback(() => {
|
||||
clearStuckTimeout();
|
||||
setRuntimeStatus('success');
|
||||
setRuntimeProgress(100);
|
||||
onStatusChange(true);
|
||||
}, [clearStuckTimeout, onStatusChange]);
|
||||
|
||||
const markFailure = useCallback(() => {
|
||||
clearStuckTimeout();
|
||||
setRuntimeStatus('error');
|
||||
setRuntimeProgress(100);
|
||||
onStatusChange(false);
|
||||
}, [clearStuckTimeout, onStatusChange]);
|
||||
|
||||
const startStuckTimeout = useCallback(() => {
|
||||
clearStuckTimeout();
|
||||
stuckTimeoutRef.current = window.setTimeout(() => {
|
||||
setRuntimeStatus((current) => (current === 'checking' ? 'error' : current));
|
||||
setRuntimeProgress(100);
|
||||
onStatusChange(false);
|
||||
}, RUNTIME_CHECK_STUCK_MS);
|
||||
}, [clearStuckTimeout, onStatusChange]);
|
||||
|
||||
const runChecks = useCallback(async () => {
|
||||
onStatusChange(false);
|
||||
setRuntimeStatus('checking');
|
||||
setRuntimeProgress(18);
|
||||
startStuckTimeout();
|
||||
|
||||
try {
|
||||
setRuntimeProgress(48);
|
||||
await refreshOpencodeStatus();
|
||||
const current = useOpencodeStore.getState().status;
|
||||
if (isRuntimeReady(current.state)) {
|
||||
markSuccess();
|
||||
return;
|
||||
}
|
||||
if (current.state === 'error') {
|
||||
markFailure();
|
||||
return;
|
||||
}
|
||||
setRuntimeProgress(72);
|
||||
} catch {
|
||||
markFailure();
|
||||
}
|
||||
}, [markFailure, markSuccess, onStatusChange, refreshOpencodeStatus, startStuckTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void runChecks();
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
clearStuckTimeout();
|
||||
};
|
||||
}, [clearStuckTimeout, runChecks]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: number | null = null;
|
||||
if (opencodeStatus.state === 'running') {
|
||||
timer = window.setTimeout(markSuccess, 0);
|
||||
} else if (opencodeStatus.state === 'error') {
|
||||
timer = window.setTimeout(markFailure, 0);
|
||||
}
|
||||
return () => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [markFailure, markSuccess, opencodeStatus.state]);
|
||||
|
||||
const statusMessage = runtimeStatus === 'success'
|
||||
? t('runtime.progress.done')
|
||||
: runtimeStatus === 'error'
|
||||
? t('runtime.progress.stuck')
|
||||
: rotatingMessage;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex h-14 w-14 items-center justify-center rounded-full border',
|
||||
runtimeStatus === 'success'
|
||||
? 'border-green-500/40 bg-green-500/10 text-green-400'
|
||||
: runtimeStatus === 'error'
|
||||
? 'border-red-500/40 bg-red-500/10 text-red-400'
|
||||
: 'border-primary/40 bg-primary/10 text-primary',
|
||||
)}
|
||||
>
|
||||
{runtimeStatus === 'checking' && <Loader2 className="h-7 w-7 animate-spin" />}
|
||||
{runtimeStatus === 'success' && <CheckCircle2 className="h-7 w-7" />}
|
||||
{runtimeStatus === 'error' && <AlertCircle className="h-7 w-7" />}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t('runtime.title')}</h2>
|
||||
<p className="min-h-5 text-sm text-muted-foreground">{statusMessage}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2" data-testid="setup-runtime-progress">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('runtime.progress.label')}</span>
|
||||
<span className="font-medium text-primary">{runtimeProgress}%</span>
|
||||
</div>
|
||||
<Progress value={runtimeProgress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{runtimeStatus === 'error' && (
|
||||
<div className="flex justify-center">
|
||||
<Button data-testid="setup-runtime-retry" variant="outline" onClick={() => void runChecks()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('runtime.progress.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface InstallingContentProps {
|
||||
skills: DefaultSkill[];
|
||||
onComplete: (installedSkills: string[]) => void;
|
||||
}
|
||||
|
||||
function InstallingContent({ skills, onComplete }: InstallingContentProps) {
|
||||
const { t } = useTranslation('setup');
|
||||
const [installPhase, setInstallPhase] = useState<InstallPhase>('installing');
|
||||
const [overallProgress, setOverallProgress] = useState(0);
|
||||
const [installAttempt, setInstallAttempt] = useState(0);
|
||||
const installMessages = useMemo(
|
||||
() => getTranslatedMessages(t, 'installing.progressMessages', [
|
||||
'Preparing your first project workspace...',
|
||||
'Connecting the built-in coding tools...',
|
||||
'Finishing the starter setup...',
|
||||
]),
|
||||
[t],
|
||||
);
|
||||
const rotatingMessage = useRotatingSetupMessage(installMessages, installPhase === 'installing');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const delay = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
const runInstall = async () => {
|
||||
try {
|
||||
setInstallPhase('installing');
|
||||
setOverallProgress(14);
|
||||
await delay(350);
|
||||
if (cancelled) return;
|
||||
setOverallProgress(46);
|
||||
await delay(450);
|
||||
if (cancelled) return;
|
||||
setOverallProgress(78);
|
||||
await delay(450);
|
||||
if (cancelled) return;
|
||||
setOverallProgress(100);
|
||||
await delay(450);
|
||||
if (cancelled) return;
|
||||
onComplete(skills.map(s => s.id));
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setInstallPhase('failed');
|
||||
setOverallProgress(100);
|
||||
toast.error(t('installing.error'));
|
||||
}
|
||||
};
|
||||
|
||||
void runInstall();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [installAttempt, onComplete, skills, t]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex h-14 w-14 items-center justify-center rounded-full border',
|
||||
installPhase === 'failed'
|
||||
? 'border-red-500/40 bg-red-500/10 text-red-400'
|
||||
: 'border-primary/40 bg-primary/10 text-primary',
|
||||
)}
|
||||
>
|
||||
{installPhase === 'failed' ? (
|
||||
<AlertCircle className="h-7 w-7" />
|
||||
) : (
|
||||
<Loader2 className="h-7 w-7 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">{t('installing.title')}</h2>
|
||||
<p className="min-h-5 text-sm text-muted-foreground">
|
||||
{installPhase === 'failed' ? t('installing.stuck') : rotatingMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2" data-testid="setup-installing-progress">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('installing.progress')}</span>
|
||||
<span className="font-medium text-primary">{overallProgress}%</span>
|
||||
</div>
|
||||
<Progress value={overallProgress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{installPhase === 'installing' ? (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('installing.wait')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={() => setInstallAttempt((attempt) => attempt + 1)}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('installing.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface CompleteContentProps {
|
||||
installedSkills: string[];
|
||||
}
|
||||
|
||||
function CompleteContent({ installedSkills }: CompleteContentProps) {
|
||||
const { t } = useTranslation(['setup', 'settings']);
|
||||
const opencodeStatus = useOpencodeStore((state) => state.status);
|
||||
|
||||
const installedSkillNames = getDefaultSkills(t)
|
||||
.filter((s: DefaultSkill) => installedSkills.includes(s.id))
|
||||
.map((s: DefaultSkill) => s.name)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="text-6xl mb-4">🎉</div>
|
||||
<h2 className="text-xl font-semibold">{t('complete.title')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('complete.subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 text-left max-w-md mx-auto">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<span>{t('complete.components')}</span>
|
||||
<span className="text-green-400">
|
||||
{installedSkillNames || `${installedSkills.length} ${t('installing.status.installed')}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<span>{t('complete.runtime')}</span>
|
||||
<span className={opencodeStatus.state === 'running' ? 'text-green-400' : 'text-yellow-400'}>
|
||||
{opencodeStatus.state === 'running' ? t('complete.running') : opencodeStatus.state}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('complete.footer')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Setup;
|
||||
Reference in New Issue
Block a user