mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
- Rename appearance-tab → preferences-tab; AppearanceTab → PreferencesTab - i18n top-level key appearance → preferences; tab label "Appearance" → "Preferences" / "偏好设置" - Swap icon Palette → SlidersHorizontal (preferences semantic) - SettingsPage: read active tab from ?tab= via NavigationAdapter, write back with replace() on change; whitelist valid tabs (incl. desktop extras daemon/updates), unknown values fall back to profile - Update conventions.mdx (en + zh) references to renamed file and i18n key Why preferences over appearance: the tab held both theme and language; "Appearance" semantically excludes localization. "Preferences" follows Linear/Slack/Discord and leaves room to add timezone/date format later. Why query param over path: settings tabs are UI modifier state, not resources; query persistence keeps the existing single Next.js route file and desktop memory router unchanged, gives a natural fallback for unknown values, and avoids 404 risk. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
233 lines
7.3 KiB
TypeScript
233 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { toast } from "sonner";
|
|
import { useTheme } from "@multica/ui/components/common/theme-provider";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import {
|
|
DEFAULT_LOCALE,
|
|
SUPPORTED_LOCALES,
|
|
type SupportedLocale,
|
|
} from "@multica/core/i18n";
|
|
import { useLocaleAdapter } from "@multica/core/i18n/react";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import { api } from "@multica/core/api";
|
|
import { useT } from "../../i18n";
|
|
|
|
const LIGHT_COLORS = {
|
|
titleBar: "#e8e8e8",
|
|
content: "#ffffff",
|
|
sidebar: "#f4f4f5",
|
|
bar: "#e4e4e7",
|
|
barMuted: "#d4d4d8",
|
|
};
|
|
|
|
const DARK_COLORS = {
|
|
titleBar: "#333338",
|
|
content: "#27272a",
|
|
sidebar: "#1e1e21",
|
|
bar: "#3f3f46",
|
|
barMuted: "#52525b",
|
|
};
|
|
|
|
function WindowMockup({
|
|
variant,
|
|
className,
|
|
}: {
|
|
variant: "light" | "dark";
|
|
className?: string;
|
|
}) {
|
|
const colors = variant === "light" ? LIGHT_COLORS : DARK_COLORS;
|
|
|
|
return (
|
|
<div className={cn("flex h-full w-full flex-col", className)}>
|
|
{/* Title bar */}
|
|
<div
|
|
className="flex items-center gap-[3px] px-2 py-1.5"
|
|
style={{ backgroundColor: colors.titleBar }}
|
|
>
|
|
<span className="size-[6px] rounded-full bg-[#ff5f57]" />
|
|
<span className="size-[6px] rounded-full bg-[#febc2e]" />
|
|
<span className="size-[6px] rounded-full bg-[#28c840]" />
|
|
</div>
|
|
{/* Content area */}
|
|
<div
|
|
className="flex flex-1"
|
|
style={{ backgroundColor: colors.content }}
|
|
>
|
|
{/* Sidebar */}
|
|
<div
|
|
className="w-[30%] space-y-1 p-2"
|
|
style={{ backgroundColor: colors.sidebar }}
|
|
>
|
|
<div
|
|
className="h-1 w-3/4 rounded-full"
|
|
style={{ backgroundColor: colors.bar }}
|
|
/>
|
|
<div
|
|
className="h-1 w-1/2 rounded-full"
|
|
style={{ backgroundColor: colors.bar }}
|
|
/>
|
|
</div>
|
|
{/* Main */}
|
|
<div className="flex-1 space-y-1.5 p-2">
|
|
<div
|
|
className="h-1.5 w-4/5 rounded-full"
|
|
style={{ backgroundColor: colors.bar }}
|
|
/>
|
|
<div
|
|
className="h-1 w-full rounded-full"
|
|
style={{ backgroundColor: colors.barMuted }}
|
|
/>
|
|
<div
|
|
className="h-1 w-3/5 rounded-full"
|
|
style={{ backgroundColor: colors.barMuted }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PreferencesTab() {
|
|
const { theme, setTheme } = useTheme();
|
|
const { t, i18n } = useT("settings");
|
|
const localeAdapter = useLocaleAdapter();
|
|
const user = useAuthStore((s) => s.user);
|
|
|
|
// i18next.language can be a region-tagged BCP-47 string (e.g. "en-US",
|
|
// "zh-Hans-CN") returned by intl-localematcher. Normalize to a supported
|
|
// locale before comparing — otherwise the radio shows neither option active.
|
|
const currentLocale: SupportedLocale = SUPPORTED_LOCALES.includes(
|
|
i18n.language as SupportedLocale,
|
|
)
|
|
? (i18n.language as SupportedLocale)
|
|
: DEFAULT_LOCALE;
|
|
|
|
const themeOptions = [
|
|
{ value: "light" as const, label: t(($) => $.preferences.theme.light) },
|
|
{ value: "dark" as const, label: t(($) => $.preferences.theme.dark) },
|
|
{ value: "system" as const, label: t(($) => $.preferences.theme.system) },
|
|
];
|
|
|
|
const languageOptions: { value: SupportedLocale; label: string }[] = [
|
|
{ value: "en", label: t(($) => $.preferences.language.english) },
|
|
{ value: "zh-Hans", label: t(($) => $.preferences.language.chinese) },
|
|
];
|
|
|
|
// Persist locally → sync to user.language → reload. Reload (vs in-place
|
|
// changeLanguage) avoids hydration mismatch and is the i18next-recommended
|
|
// pattern for App Router.
|
|
//
|
|
// If the cross-device sync (PATCH /api/me) fails, the local cookie is
|
|
// already written so the new locale will take effect after reload — but
|
|
// the user's other devices won't see the change. Surface that explicitly
|
|
// via a toast and delay the reload long enough for the toast to be read,
|
|
// otherwise the failure would be invisible.
|
|
const handleLanguageChange = async (next: SupportedLocale) => {
|
|
if (next === currentLocale) return;
|
|
localeAdapter.persist(next);
|
|
|
|
let syncFailed = false;
|
|
if (user) {
|
|
try {
|
|
await api.updateMe({ language: next });
|
|
} catch {
|
|
syncFailed = true;
|
|
}
|
|
}
|
|
|
|
if (syncFailed) {
|
|
toast.warning(t(($) => $.preferences.language.sync_failed));
|
|
// Give the toast 2.5s of visible time before navigating away.
|
|
setTimeout(() => window.location.reload(), 2500);
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<section className="space-y-4">
|
|
<h2 className="text-sm font-semibold">
|
|
{t(($) => $.preferences.theme.title)}
|
|
</h2>
|
|
<div className="flex gap-6" role="radiogroup">
|
|
{themeOptions.map((opt) => {
|
|
const active = theme === opt.value;
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
role="radio"
|
|
aria-checked={active}
|
|
onClick={() => setTheme(opt.value)}
|
|
className="group flex flex-col items-center gap-2"
|
|
>
|
|
<div
|
|
className={cn(
|
|
"aspect-[4/3] w-36 overflow-hidden rounded-lg ring-1 transition-all",
|
|
active
|
|
? "ring-2 ring-brand"
|
|
: "ring-border hover:ring-2 hover:ring-border"
|
|
)}
|
|
>
|
|
{opt.value === "system" ? (
|
|
<div className="relative h-full w-full">
|
|
<WindowMockup
|
|
variant="light"
|
|
className="absolute inset-0"
|
|
/>
|
|
<WindowMockup
|
|
variant="dark"
|
|
className="absolute inset-0 [clip-path:inset(0_0_0_50%)]"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<WindowMockup variant={opt.value} />
|
|
)}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
"text-sm transition-colors",
|
|
active
|
|
? "font-medium text-foreground"
|
|
: "text-muted-foreground"
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-4">
|
|
<h2 className="text-sm font-semibold">
|
|
{t(($) => $.preferences.language.title)}
|
|
</h2>
|
|
<div className="flex gap-3" role="radiogroup">
|
|
{languageOptions.map((opt) => {
|
|
const active = currentLocale === opt.value;
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
role="radio"
|
|
aria-checked={active}
|
|
onClick={() => handleLanguageChange(opt.value)}
|
|
className={cn(
|
|
"rounded-md border px-4 py-2 text-sm transition-colors",
|
|
active
|
|
? "border-brand bg-brand/10 font-medium text-foreground"
|
|
: "border-border text-muted-foreground hover:border-foreground/30"
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|