Files
CodePilot/src/components/settings/GeneralSection.tsx
T
7418andClaude Opus 4.6 4da188bfe5 feat: theme family system with 12 color palettes + NavRail redesign
Theme system:
- Two-layer architecture: mode (light/dark/system via next-themes) + theme family (color palette via CSS variables)
- 12 theme families: Default, GitHub, Rosé Pine, Tokyo Night, Everforest, Nord, Kanagawa, Night Owl, Poimandres, Vesper, Horizon, SynthWave '84
- Per-family code block highlighting for Prism, HLJS, and Shiki (no more generic oneLight/oneDark fallback)
- Anti-FOUC: synchronous script in <head> reads localStorage and sets data-theme-family before paint
- Theme JSON files in themes/ directory, auto-discovered by server-side loader
- OKLCH color space for all values; CSS specificity html[data-theme-family] beats :root

UI changes:
- NavRail: moved Settings to bottom (replacing theme toggle), removed light/dark toggle button
- Settings: merged General controls into one card with dividers; Appearance section with live Shiki code preview
- Replaced hardcoded blue-500/blue-600 with primary/primary-foreground across 19 components
- Appearance settings: pill mode selector (light/dark/system), theme family dropdown, live code preview

New files:
- src/lib/theme/{types,loader,render-css,context,code-themes}.ts — core theme infrastructure
- src/components/layout/ThemeFamilyProvider.tsx — client context provider
- src/components/settings/AppearanceSection.tsx — appearance settings UI
- src/hooks/useAppTheme.ts — combined theme hook
- themes/*.json — 12 theme family definitions
- docs/handover/theme-system.md — handover documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:15:01 +08:00

339 lines
13 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { HugeiconsIcon } from "@hugeicons/react";
import { ReloadIcon, Loading02Icon } from "@hugeicons/core-free-icons";
import { useUpdate } from "@/hooks/useUpdate";
import { useTranslation } from "@/hooks/useTranslation";
import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
import type { TranslationKey } from "@/i18n";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AppearanceSection } from "./AppearanceSection";
function UpdateCard() {
const { updateInfo, checking, checkForUpdates, downloadUpdate, quitAndInstall, setShowDialog } = useUpdate();
const { t } = useTranslation();
const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const isDownloading = updateInfo?.isNativeUpdate && !updateInfo.readyToInstall
&& updateInfo.downloadProgress != null;
return (
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.codepilot')}</h2>
<p className="text-xs text-muted-foreground">{t('settings.version', { version: currentVersion })}</p>
</div>
<div className="flex items-center gap-2">
{/* Show install/restart button when update available */}
{updateInfo?.updateAvailable && !checking && (
updateInfo.readyToInstall ? (
<Button size="sm" onClick={quitAndInstall}>
{t('update.restartToUpdate')}
</Button>
) : updateInfo.isNativeUpdate && !isDownloading ? (
<Button size="sm" onClick={downloadUpdate}>
{t('update.installUpdate')}
</Button>
) : !updateInfo.isNativeUpdate ? (
<Button size="sm" variant="outline" onClick={() => window.open(updateInfo.releaseUrl, "_blank")}>
{t('settings.viewRelease')}
</Button>
) : null
)}
<Button
variant="outline"
size="sm"
onClick={checkForUpdates}
disabled={checking}
className="gap-2"
>
{checking ? (
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin" />
) : (
<HugeiconsIcon icon={ReloadIcon} className="h-3.5 w-3.5" />
)}
{checking ? t('settings.checking') : t('settings.checkForUpdates')}
</Button>
</div>
</div>
{updateInfo && !checking && (
<div className="mt-3">
{updateInfo.updateAvailable ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-green-500' : isDownloading ? 'bg-yellow-500 animate-pulse' : 'bg-primary'}`} />
<span className="text-sm">
{updateInfo.readyToInstall
? t('update.readyToInstall', { version: updateInfo.latestVersion })
: isDownloading
? `${t('update.downloading')} ${Math.round(updateInfo.downloadProgress!)}%`
: t('settings.updateAvailable', { version: updateInfo.latestVersion })}
</span>
{updateInfo.releaseNotes && (
<Button
variant="link"
size="sm"
className="h-auto p-0 text-xs text-muted-foreground"
onClick={() => setShowDialog(true)}
>
{t('gallery.viewDetails')}
</Button>
)}
</div>
{/* Download progress bar */}
{isDownloading && (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(updateInfo.downloadProgress!, 100)}%` }}
/>
</div>
)}
{updateInfo.lastError && (
<p className="text-xs text-red-600 dark:text-red-400">
{updateInfo.lastError}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">{t('settings.latestVersion')}</p>
)}
</div>
)}
</div>
);
}
export function GeneralSection() {
const [skipPermissions, setSkipPermissions] = useState(false);
const [showSkipPermWarning, setShowSkipPermWarning] = useState(false);
const [skipPermSaving, setSkipPermSaving] = useState(false);
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
const [accountInfo, setAccountInfo] = useState<{ email?: string; organization?: string; subscriptionType?: string } | null>(null);
const { t, locale, setLocale } = useTranslation();
const fetchAppSettings = useCallback(async () => {
try {
const res = await fetch("/api/settings/app");
if (res.ok) {
const data = await res.json();
const appSettings = data.settings || {};
setSkipPermissions(appSettings.dangerously_skip_permissions === "true");
if (appSettings.thinking_mode) {
setThinkingMode(appSettings.thinking_mode);
}
}
} catch {
// ignore
}
}, []);
const fetchAccountInfo = useCallback(async () => {
try {
const res = await fetch("/api/sdk/account");
if (res.ok) {
const data = await res.json();
if (data.account) {
setAccountInfo(data.account);
}
}
} catch {
// Account info not available
}
}, []);
useEffect(() => {
fetchAppSettings();
fetchAccountInfo();
}, [fetchAppSettings, fetchAccountInfo]);
const saveThinkingMode = async (mode: string) => {
setThinkingMode(mode);
try {
await fetch("/api/settings/app", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: { thinking_mode: mode },
}),
});
} catch {
// ignore
}
};
const handleSkipPermToggle = (checked: boolean) => {
if (checked) {
setShowSkipPermWarning(true);
} else {
saveSkipPermissions(false);
}
};
const saveSkipPermissions = async (enabled: boolean) => {
setSkipPermSaving(true);
try {
const res = await fetch("/api/settings/app", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: { dangerously_skip_permissions: enabled ? "true" : "" },
}),
});
if (res.ok) {
setSkipPermissions(enabled);
}
} catch {
// ignore
} finally {
setSkipPermSaving(false);
setShowSkipPermWarning(false);
}
};
return (
<div className="max-w-3xl space-y-6">
<UpdateCard />
{/* General settings card */}
<div className={`rounded-lg border p-4 space-y-4 transition-shadow hover:shadow-sm ${skipPermissions ? "border-orange-500/50 bg-orange-500/5" : "border-border/50"}`}>
{/* Auto-approve toggle */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.autoApproveTitle')}</h2>
<p className="text-xs text-muted-foreground">
{t('settings.autoApproveDesc')}
</p>
</div>
<Switch
checked={skipPermissions}
onCheckedChange={handleSkipPermToggle}
disabled={skipPermSaving}
/>
</div>
{skipPermissions && (
<div className="flex items-center gap-2 rounded-md bg-orange-500/10 px-3 py-2 text-xs text-orange-600 dark:text-orange-400">
<span className="h-2 w-2 shrink-0 rounded-full bg-orange-500" />
{t('settings.autoApproveWarning')}
</div>
)}
<div className="border-t border-border/30" />
{/* Language picker */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.language')}</h2>
<p className="text-xs text-muted-foreground">{t('settings.languageDesc')}</p>
</div>
<Select value={locale} onValueChange={(v) => setLocale(v as Locale)}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LOCALES.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border-t border-border/30" />
{/* Thinking mode */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.thinkingMode' as TranslationKey)}</h2>
<p className="text-xs text-muted-foreground">{t('settings.thinkingModeDesc' as TranslationKey)}</p>
</div>
<Select value={thinkingMode} onValueChange={saveThinkingMode}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="adaptive">{t('settings.thinkingAdaptive' as TranslationKey)}</SelectItem>
<SelectItem value="enabled">{t('settings.thinkingEnabled' as TranslationKey)}</SelectItem>
<SelectItem value="disabled">{t('settings.thinkingDisabled' as TranslationKey)}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Appearance */}
<AppearanceSection />
{/* Account info */}
{accountInfo && (
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<h2 className="text-sm font-medium mb-2">{t('settings.accountInfo' as TranslationKey)}</h2>
<div className="space-y-1">
{accountInfo.email && (
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{t('settings.email' as TranslationKey)}:</span> {accountInfo.email}
</p>
)}
{accountInfo.organization && (
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{t('settings.organization' as TranslationKey)}:</span> {accountInfo.organization}
</p>
)}
{accountInfo.subscriptionType && (
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{t('settings.subscription' as TranslationKey)}:</span> {accountInfo.subscriptionType}
</p>
)}
</div>
</div>
)}
{/* Skip-permissions warning dialog */}
<AlertDialog open={showSkipPermWarning} onOpenChange={setShowSkipPermWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('settings.autoApproveDialogTitle')}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
{t('settings.autoApproveDialogDesc')}
</p>
<ul className="list-disc pl-5 space-y-1">
<li>{t('settings.autoApproveShellCommands')}</li>
<li>{t('settings.autoApproveFileOps')}</li>
<li>{t('settings.autoApproveNetwork')}</li>
</ul>
<p className="font-medium text-orange-600 dark:text-orange-400">
{t('settings.autoApproveTrustWarning')}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('settings.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => saveSkipPermissions(true)}
className="bg-orange-600 hover:bg-orange-700 text-white"
>
{t('settings.enableAutoApprove')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}