Implement settings service and routes for file-based settings management

- Add SettingsService to handle reading/writing global and project settings.
- Introduce API routes for managing settings, including global settings, credentials, and project-specific settings.
- Implement migration functionality to transfer settings from localStorage to file-based storage.
- Create common utilities for settings routes and integrate logging for error handling.
- Update server entry point to include new settings routes.
This commit is contained in:
Cody Seibert
2025-12-20 01:52:25 -05:00
parent 8fcc6cb4db
commit 0c6447a6f5
42 changed files with 4516 additions and 1984 deletions

View File

@@ -0,0 +1,309 @@
import { useEffect, useState, useMemo } from "react";
const TOTAL_DURATION = 2300; // Total animation duration in ms (tightened from 4000)
const LOGO_ENTER_DURATION = 500; // Tightened from 1200
const PARTICLES_ENTER_DELAY = 100; // Tightened from 400
const EXIT_START = 1800; // Adjusted for shorter duration
interface Particle {
id: number;
x: number;
y: number;
size: number;
delay: number;
angle: number;
distance: number;
opacity: number;
floatDuration: number;
}
function generateParticles(count: number): Particle[] {
return Array.from({ length: count }, (_, i) => {
const angle = (i / count) * 360 + Math.random() * 30;
const distance = 60 + Math.random() * 80; // Increased spread
return {
id: i,
x: Math.cos((angle * Math.PI) / 180) * distance,
y: Math.sin((angle * Math.PI) / 180) * distance,
size: 3 + Math.random() * 6, // Slightly smaller range for more subtle look
delay: Math.random() * 400,
angle,
distance: 300 + Math.random() * 200,
opacity: 0.4 + Math.random() * 0.6,
floatDuration: 3000 + Math.random() * 4000,
};
});
}
export function SplashScreen({ onComplete }: { onComplete: () => void }) {
const [phase, setPhase] = useState<"enter" | "hold" | "exit" | "done">(
"enter"
);
const particles = useMemo(() => generateParticles(50), []);
useEffect(() => {
const timers: NodeJS.Timeout[] = [];
// Phase transitions
timers.push(setTimeout(() => setPhase("hold"), LOGO_ENTER_DURATION));
timers.push(setTimeout(() => setPhase("exit"), EXIT_START));
timers.push(
setTimeout(() => {
setPhase("done");
onComplete();
}, TOTAL_DURATION)
);
return () => timers.forEach(clearTimeout);
}, [onComplete]);
if (phase === "done") return null;
return (
<div
className={`
fixed inset-0 z-[9999] flex items-center justify-center
bg-background
transition-opacity duration-500 ease-out
${phase === "exit" ? "opacity-0" : "opacity-100"}
`}
style={{
pointerEvents: phase === "exit" ? "none" : "auto",
}}
>
<style>{`
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin-slow-reverse {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(6px, -6px); }
}
`}</style>
{/* Subtle gradient background */}
<div
className="absolute inset-0 opacity-30"
style={{
background:
"radial-gradient(circle at center, var(--brand-500) 0%, transparent 70%)",
}}
/>
{/* Particle container 1 - Clockwise */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ animation: "spin-slow 60s linear infinite" }}
>
{particles.slice(0, 25).map((particle) => (
<div
key={particle.id}
className="absolute"
style={{
transform:
phase === "exit"
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
: `translate(${particle.x}px, ${particle.y}px)`,
transition:
phase === "enter"
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
: phase === "exit"
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
: "all 300ms ease-out",
}}
>
<div
className="rounded-full"
style={{
width: particle.size,
height: particle.size,
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
opacity:
phase === "enter"
? 0
: phase === "hold"
? particle.opacity
: 0,
transform: phase === "exit" ? "scale(0)" : "scale(1)",
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
transition: "opacity 300ms ease-out, transform 300ms ease-out",
}}
/>
</div>
))}
</div>
{/* Particle container 2 - Counter-Clockwise */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ animation: "spin-slow-reverse 75s linear infinite" }}
>
{particles.slice(25).map((particle) => (
<div
key={particle.id}
className="absolute"
style={{
transform:
phase === "exit"
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
: `translate(${particle.x}px, ${particle.y}px)`,
transition:
phase === "enter"
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
: phase === "exit"
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
: "all 300ms ease-out",
}}
>
<div
className="rounded-full"
style={{
width: particle.size,
height: particle.size,
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
opacity:
phase === "enter"
? 0
: phase === "hold"
? particle.opacity
: 0,
transform: phase === "exit" ? "scale(0)" : "scale(1)",
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
animationDelay: `${particle.delay}ms`,
transition: "opacity 300ms ease-out, transform 300ms ease-out",
}}
/>
</div>
))}
</div>
{/* Logo container */}
<div
className="relative z-10"
style={{
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
transform:
phase === "enter"
? "scale(0.3) rotate(-20deg)"
: phase === "exit"
? "scale(2.5) translateY(-100px)"
: "scale(1) rotate(0deg)",
transition:
phase === "enter"
? `all ${LOGO_ENTER_DURATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`
: phase === "exit"
? "all 600ms cubic-bezier(0.4, 0, 1, 1)"
: "all 300ms ease-out",
}}
>
{/* Glow effect behind logo */}
<div
className="absolute inset-0 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--brand-500) 0%, transparent 70%)",
transform: "scale(2.5)",
opacity: phase === "hold" ? 0.6 : 0,
transition: "opacity 500ms ease-out",
}}
/>
{/* The logo */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="relative z-10"
style={{
width: 120,
height: 120,
filter: "drop-shadow(0 0 30px var(--brand-500))",
}}
>
<defs>
<linearGradient
id="splash-bg"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: "var(--brand-400)" }} />
<stop offset="100%" style={{ stopColor: "var(--brand-600)" }} />
</linearGradient>
<filter
id="splash-shadow"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect
x="16"
y="16"
width="224"
height="224"
rx="56"
fill="url(#splash-bg)"
/>
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#splash-shadow)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
</div>
{/* Automaker text that fades in below the logo */}
<div
className="absolute flex items-center gap-1"
style={{
top: "calc(50% + 80px)",
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
transform:
phase === "enter"
? "translateY(20px)"
: phase === "exit"
? "translateY(-30px) scale(1.2)"
: "translateY(0)",
transition:
phase === "enter"
? `all 600ms ease-out ${LOGO_ENTER_DURATION - 200}ms`
: phase === "exit"
? "all 500ms cubic-bezier(0.4, 0, 1, 1)"
: "all 300ms ease-out",
}}
>
<span className="font-bold text-foreground text-4xl tracking-tight leading-none">
automaker<span className="text-brand-500">.</span>
</span>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { useSetupStore } from "@/store/setup-store";
import { StepIndicator } from "./setup-view/components";
import {
WelcomeStep,
ThemeStep,
CompleteStep,
ClaudeSetupStep,
GitHubSetupStep,
@@ -19,12 +20,13 @@ export function SetupView() {
} = useSetupStore();
const navigate = useNavigate();
const steps = ["welcome", "claude", "github", "complete"] as const;
const steps = ["welcome", "theme", "claude", "github", "complete"] as const;
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === "claude_detect" || currentStep === "claude_auth")
return "claude";
if (currentStep === "welcome") return "welcome";
if (currentStep === "theme") return "theme";
if (currentStep === "github") return "github";
return "complete";
};
@@ -39,6 +41,10 @@ export function SetupView() {
);
switch (from) {
case "welcome":
console.log("[Setup Flow] Moving to theme step");
setCurrentStep("theme");
break;
case "theme":
console.log("[Setup Flow] Moving to claude_detect step");
setCurrentStep("claude_detect");
break;
@@ -56,9 +62,12 @@ export function SetupView() {
const handleBack = (from: string) => {
console.log("[Setup Flow] handleBack called from:", from);
switch (from) {
case "claude":
case "theme":
setCurrentStep("welcome");
break;
case "claude":
setCurrentStep("theme");
break;
case "github":
setCurrentStep("claude_detect");
break;
@@ -98,42 +107,47 @@ export function SetupView() {
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="p-8">
<div className="w-full max-w-2xl mx-auto">
<div className="mb-8">
<StepIndicator
currentStep={currentIndex}
totalSteps={steps.length}
<div className="flex-1 overflow-y-auto min-h-0 flex items-center justify-center">
<div className="w-full max-w-2xl mx-auto px-8">
<div className="mb-8">
<StepIndicator
currentStep={currentIndex}
totalSteps={steps.length}
/>
</div>
<div>
{currentStep === "welcome" && (
<WelcomeStep onNext={() => handleNext("welcome")} />
)}
{currentStep === "theme" && (
<ThemeStep
onNext={() => handleNext("theme")}
onBack={() => handleBack("theme")}
/>
</div>
)}
<div className="py-8">
{currentStep === "welcome" && (
<WelcomeStep onNext={() => handleNext("welcome")} />
)}
{(currentStep === "claude_detect" ||
currentStep === "claude_auth") && (
<ClaudeSetupStep
onNext={() => handleNext("claude")}
onBack={() => handleBack("claude")}
onSkip={handleSkipClaude}
/>
)}
{(currentStep === "claude_detect" ||
currentStep === "claude_auth") && (
<ClaudeSetupStep
onNext={() => handleNext("claude")}
onBack={() => handleBack("claude")}
onSkip={handleSkipClaude}
/>
)}
{currentStep === "github" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "github" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}
</div>
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
// Re-export all setup step components for easier imports
export { WelcomeStep } from "./welcome-step";
export { ThemeStep } from "./theme-step";
export { CompleteStep } from "./complete-step";
export { ClaudeSetupStep } from "./claude-setup-step";
export { GitHubSetupStep } from "./github-setup-step";

View File

@@ -0,0 +1,90 @@
import { Button } from "@/components/ui/button";
import { ArrowRight, ArrowLeft, Check } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { useAppStore } from "@/store/app-store";
import { cn } from "@/lib/utils";
interface ThemeStepProps {
onNext: () => void;
onBack: () => void;
}
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const { theme, setTheme, setPreviewTheme } = useAppStore();
const handleThemeHover = (themeValue: string) => {
setPreviewTheme(themeValue as typeof theme);
};
const handleThemeLeave = () => {
setPreviewTheme(null);
};
const handleThemeClick = (themeValue: string) => {
setTheme(themeValue as typeof theme);
setPreviewTheme(null);
};
return (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-3xl font-bold text-foreground mb-3">
Choose Your Theme
</h2>
<p className="text-muted-foreground max-w-md mx-auto">
Pick a theme that suits your style. Hover to preview, click to select.
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{themeOptions.map((option) => {
const Icon = option.Icon;
const isSelected = theme === option.value;
return (
<button
key={option.value}
data-testid={option.testId}
onMouseEnter={() => handleThemeHover(option.value)}
onMouseLeave={handleThemeLeave}
onClick={() => handleThemeClick(option.value)}
className={cn(
"relative flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all duration-200",
"hover:scale-105 hover:shadow-lg",
isSelected
? "border-brand-500 bg-brand-500/10"
: "border-border hover:border-brand-400 bg-card"
)}
>
{isSelected && (
<div className="absolute top-2 right-2">
<Check className="w-4 h-4 text-brand-500" />
</div>
)}
<Icon className="w-6 h-6 text-foreground" />
<span className="text-sm font-medium text-foreground">
{option.label}
</span>
</button>
);
})}
</div>
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button
size="lg"
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
onClick={onNext}
data-testid="theme-continue-button"
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
);
}