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>
339 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|