6cf8a82889
- Settings → Appearance: mode Dark / Light / System (follows the OS live) and a named theme remembered per mode — dark: Default, Darcula, Apple Dark; light: Default, Apple Light, IntelliJ Light, Canvas. Each theme is a CSS-variable override block keyed by `data-theme` on <html>; the sidebar quick toggle gains System and swaps between the two remembered themes. - An inline boot script in index.html applies the stored mode and theme before first paint, so a reload no longer flashes the default colours. - Diff +/- rows are easier to tell apart on every theme: a /20 status tint, a 3px status bar and a bold coloured marker; the changed-span emphasis no longer vanishes on light themes. - #42: the Add-lane modal (Repo mode / "Current branch") asks POST /api/lanes/ensure with `exact: true`. Without it a folder nested inside an existing lane resolved to that parent lane with created:false, so the modal closed and no new card appeared. When the folder really already has a lane, the modal now says which one instead of closing. Closes #42
129 lines
5.0 KiB
TypeScript
129 lines
5.0 KiB
TypeScript
/**
|
|
* @file AppearanceSettings.tsx
|
|
* @description Settings → Appearance: the Dark / Light / System mode switch and
|
|
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
|
|
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
|
|
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
|
* is painted by the same tokens as the app — no hex is duplicated here.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
import { Monitor, Moon, Palette, Sun } from "lucide-react";
|
|
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
|
|
|
const THEME_LABELS: Record<string, string> = {
|
|
darcula: "Darcula",
|
|
"apple-dark": "Apple Dark",
|
|
"apple-light": "Apple Light",
|
|
"intellij-light": "IntelliJ Light",
|
|
canvas: "Canvas",
|
|
};
|
|
|
|
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
|
{ mode: "dark", Icon: Moon },
|
|
{ mode: "light", Icon: Sun },
|
|
{ mode: "system", Icon: Monitor },
|
|
];
|
|
|
|
function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
|
return (
|
|
<div
|
|
aria-hidden
|
|
className={`${mode === "dark" ? "dark" : ""} rounded-md border border-border bg-surface-0 p-2 text-fg-primary`}
|
|
data-mode={mode}
|
|
data-theme={name === "default" ? undefined : name}
|
|
>
|
|
<div className="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="h-2 w-2 rounded-full bg-accent" />
|
|
<span className="h-1.5 w-10 rounded-full bg-fg-primary/70" />
|
|
</div>
|
|
<div className="font-mono text-[10px] leading-tight">
|
|
<span className="text-syntax-keyword">const</span>{" "}
|
|
<span className="text-syntax-function">x</span>{" "}
|
|
<span className="text-syntax-operator">=</span>{" "}
|
|
<span className="text-syntax-string">"ok"</span>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<span className="h-1.5 flex-1 rounded-full bg-status-success/40" />
|
|
<span className="h-1.5 flex-1 rounded-full bg-status-danger/40" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AppearanceSettings() {
|
|
const { t } = useTranslation("settings");
|
|
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
|
|
|
return (
|
|
<section id="appearance" className="scroll-mt-24">
|
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
|
<Palette className="w-4 h-4 text-fg-muted" />
|
|
{t("appearance.title")}
|
|
</h3>
|
|
<p className="text-xs text-fg-muted mb-4">{t("appearance.description")}</p>
|
|
|
|
<div className="card p-5 space-y-5">
|
|
<div>
|
|
<p className="text-xs font-medium text-fg-secondary mb-2">{t("appearance.mode")}</p>
|
|
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
|
|
{MODES.map(({ mode: m, Icon }) => (
|
|
<button
|
|
key={m}
|
|
type="button"
|
|
onClick={() => setMode(m)}
|
|
aria-pressed={mode === m}
|
|
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
mode === m
|
|
? "bg-accent/20 text-accent"
|
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
|
}`}
|
|
>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
{t(`appearance.modes.${m}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{(["dark", "light"] as const).map((group) => (
|
|
<div key={group}>
|
|
<p className="text-xs font-medium text-fg-secondary mb-2">
|
|
{t(`appearance.${group}Themes`)}
|
|
{theme === group && (
|
|
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
|
|
)}
|
|
</p>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
{THEMES[group].map((name) => {
|
|
const selected = themeFor[group] === name;
|
|
const label = THEME_LABELS[name] ?? t("appearance.default");
|
|
return (
|
|
<button
|
|
key={name}
|
|
type="button"
|
|
onClick={() => setThemeFor(group, name)}
|
|
aria-pressed={selected}
|
|
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
|
|
className={`rounded-lg border p-1.5 text-left transition-colors ${
|
|
selected
|
|
? "border-accent ring-2 ring-accent/40"
|
|
: "border-border hover:border-border-light"
|
|
}`}
|
|
>
|
|
<Swatch mode={group} name={name} />
|
|
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|