feat: 添加移动端下拉菜单和主题色设置

- 新增 MobileMenu 组件,小屏幕使用汉堡菜单替代多个按钮
- 重构 LanguageSettings 为统一下拉框样式
- 新增设置页面,支持主题色切换
- 翻译页添加源语言选择器
- 更新 8 种语言的 i18n 翻译
This commit is contained in:
2026-03-10 13:44:52 +08:00
parent 6b9fba254d
commit abcae1b8d1
22 changed files with 877 additions and 177 deletions

View File

@@ -193,14 +193,20 @@
"sign_in": "Anmelden", "sign_in": "Anmelden",
"profile": "Profil", "profile": "Profil",
"folders": "Ordner", "folders": "Ordner",
"explore": "Entdecken", "explore": "Erkunden",
"favorites": "Favoriten" "favorites": "Favoriten",
"settings": "Einstellungen"
}, },
"profile": { "profile": {
"myProfile": "Mein Profil", "myProfile": "Mein Profil",
"email": "E-Mail: {email}", "email": "E-Mail: {email}",
"logout": "Abmelden" "logout": "Abmelden"
}, },
"settings": {
"title": "Einstellungen",
"themeColor": "Designfarbe",
"themeColorDescription": "Wählen Sie Ihre bevorzugte Designfarbe"
},
"srt_player": { "srt_player": {
"uploadVideo": "Video hochladen", "uploadVideo": "Video hochladen",
"uploadSubtitle": "Untertitel hochladen", "uploadSubtitle": "Untertitel hochladen",
@@ -239,6 +245,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "Sprache erkennen", "detectLanguage": "Sprache erkennen",
"sourceLanguage": "Quellsprache",
"auto": "Automatisch",
"generateIPA": "IPA generieren", "generateIPA": "IPA generieren",
"translateInto": "übersetzen in", "translateInto": "übersetzen in",
"chinese": "Chinesisch", "chinese": "Chinesisch",

View File

@@ -194,13 +194,19 @@
"profile": "Profile", "profile": "Profile",
"folders": "Folders", "folders": "Folders",
"explore": "Explore", "explore": "Explore",
"favorites": "Favorites" "favorites": "Favorites",
"settings": "Settings"
}, },
"profile": { "profile": {
"myProfile": "My Profile", "myProfile": "My Profile",
"email": "Email: {email}", "email": "Email: {email}",
"logout": "Logout" "logout": "Logout"
}, },
"settings": {
"title": "Settings",
"themeColor": "Theme Color",
"themeColorDescription": "Choose your preferred theme color"
},
"srt_player": { "srt_player": {
"uploadVideo": "Upload Video", "uploadVideo": "Upload Video",
"uploadSubtitle": "Upload Subtitle", "uploadSubtitle": "Upload Subtitle",
@@ -239,6 +245,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "detect language", "detectLanguage": "detect language",
"sourceLanguage": "source language",
"auto": "Auto",
"generateIPA": "generate ipa", "generateIPA": "generate ipa",
"translateInto": "translate into", "translateInto": "translate into",
"chinese": "Chinese", "chinese": "Chinese",

View File

@@ -188,19 +188,25 @@
} }
}, },
"navbar": { "navbar": {
"title": "apprendre-langues", "title": "learn-languages",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Se connecter", "sign_in": "Connexion",
"profile": "Profil", "profile": "Profil",
"folders": "Dossiers", "folders": "Dossiers",
"explore": "Explorer", "explore": "Explorer",
"favorites": "Favoris" "favorites": "Favoris",
"settings": "Paramètres"
}, },
"profile": { "profile": {
"myProfile": "Mon profil", "myProfile": "Mon profil",
"email": "E-mail : {email}", "email": "E-mail : {email}",
"logout": "Déconnexion" "logout": "Déconnexion"
}, },
"settings": {
"title": "Paramètres",
"themeColor": "Couleur du thème",
"themeColorDescription": "Choisissez votre couleur de thème préférée"
},
"srt_player": { "srt_player": {
"uploadVideo": "Télécharger la vidéo", "uploadVideo": "Télécharger la vidéo",
"uploadSubtitle": "Télécharger les sous-titres", "uploadSubtitle": "Télécharger les sous-titres",
@@ -239,6 +245,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "détecter la langue", "detectLanguage": "détecter la langue",
"sourceLanguage": "langue source",
"auto": "Auto",
"generateIPA": "générer l'api", "generateIPA": "générer l'api",
"translateInto": "traduire en", "translateInto": "traduire en",
"chinese": "Chinois", "chinese": "Chinois",

View File

@@ -188,19 +188,25 @@
} }
}, },
"navbar": { "navbar": {
"title": "impara-lingue", "title": "learn-languages",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Accedi", "sign_in": "Accedi",
"profile": "Profilo", "profile": "Profilo",
"folders": "Cartelle", "folders": "Cartelle",
"explore": "Esplora", "explore": "Esplora",
"favorites": "Preferiti" "favorites": "Preferiti",
"settings": "Impostazioni"
}, },
"profile": { "profile": {
"myProfile": "Il Mio Profilo", "myProfile": "Il Mio Profilo",
"email": "Email: {email}", "email": "Email: {email}",
"logout": "Esci" "logout": "Esci"
}, },
"settings": {
"title": "Impostazioni",
"themeColor": "Colore del tema",
"themeColorDescription": "Scegli il tuo colore del tema preferito"
},
"srt_player": { "srt_player": {
"uploadVideo": "Carica Video", "uploadVideo": "Carica Video",
"uploadSubtitle": "Carica Sottotitoli", "uploadSubtitle": "Carica Sottotitoli",
@@ -239,6 +245,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "rileva lingua", "detectLanguage": "rileva lingua",
"sourceLanguage": "lingua di origine",
"auto": "Auto",
"generateIPA": "genera ipa", "generateIPA": "genera ipa",
"translateInto": "traduci in", "translateInto": "traduci in",
"chinese": "Cinese", "chinese": "Cinese",

View File

@@ -194,13 +194,19 @@
"profile": "プロフィール", "profile": "プロフィール",
"folders": "フォルダー", "folders": "フォルダー",
"explore": "探索", "explore": "探索",
"favorites": "お気に入り" "favorites": "お気に入り",
"settings": "設定"
}, },
"profile": { "profile": {
"myProfile": "マイプロフィール", "myProfile": "マイプロフィール",
"email": "メール: {email}", "email": "メール: {email}",
"logout": "ログアウト" "logout": "ログアウト"
}, },
"settings": {
"title": "設定",
"themeColor": "テーマカラー",
"themeColorDescription": "お好みのテーマカラーを選択してください"
},
"srt_player": { "srt_player": {
"uploadVideo": "ビデオをアップロード", "uploadVideo": "ビデオをアップロード",
"uploadSubtitle": "字幕をアップロード", "uploadSubtitle": "字幕をアップロード",
@@ -239,6 +245,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "言語を検出", "detectLanguage": "言語を検出",
"sourceLanguage": "ソース言語",
"auto": "自動",
"generateIPA": "ipaを生成", "generateIPA": "ipaを生成",
"translateInto": "翻訳先", "translateInto": "翻訳先",
"chinese": "中国語", "chinese": "中国語",

View File

@@ -194,13 +194,19 @@
"profile": "프로필", "profile": "프로필",
"folders": "폴더", "folders": "폴더",
"explore": "탐색", "explore": "탐색",
"favorites": "즐겨찾기" "favorites": "즐겨찾기",
"settings": "설정"
}, },
"profile": { "profile": {
"myProfile": "내 프로필", "myProfile": "내 프로필",
"email": "이메일: {email}", "email": "이메일: {email}",
"logout": "로그아웃" "logout": "로그아웃"
}, },
"settings": {
"title": "설정",
"themeColor": "테마 색상",
"themeColorDescription": "원하는 테마 색상을 선택하세요"
},
"srt_player": { "srt_player": {
"uploadVideo": "비디오 업로드", "uploadVideo": "비디오 업로드",
"uploadSubtitle": "자막 업로드", "uploadSubtitle": "자막 업로드",
@@ -239,6 +245,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "언어 감지", "detectLanguage": "언어 감지",
"sourceLanguage": "원본 언어",
"auto": "자동",
"generateIPA": "IPA 생성", "generateIPA": "IPA 생성",
"translateInto": "번역할 언어", "translateInto": "번역할 언어",
"chinese": "중국어", "chinese": "중국어",

View File

@@ -188,19 +188,25 @@
} }
}, },
"navbar": { "navbar": {
"title": "تىل-ئۆگىنىش", "title": "learn-languages",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "كىرىش", "sign_in": "كىرىش",
"profile": "شەخسىي ئۇچۇر", "profile": "شەخسىي ئۇچۇر",
"folders": "قىسقۇچلار", "folders": "قىسقۇچلار",
"explore": "ئىزدىنىش", "explore": "ئىزدىنىش",
"favorites": "يىغىپ ساقلانغانلار" "favorites": "يىغىپ ساقلاش",
"settings": "تەڭشەكلەر"
}, },
"profile": { "profile": {
"myProfile": "شەخسىي ئۇچۇرۇم", "myProfile": "شەخسىي ئۇچۇرۇم",
"email": "ئېلخەت: {email}", "email": "ئېلخەت: {email}",
"logout": "چىكىنىش" "logout": "چىكىنىش"
}, },
"settings": {
"title": "تەڭشەكلەر",
"themeColor": "تېما رەڭگى",
"themeColorDescription": "ياقتۇرىدىغان تېما رەڭگىڭىزنى تاللاڭ"
},
"srt_player": { "srt_player": {
"uploadVideo": "ۋىدېئو يۈكلەش", "uploadVideo": "ۋىدېئو يۈكلەش",
"uploadSubtitle": "تر پودكاست يۈكلەش", "uploadSubtitle": "تر پودكاست يۈكلەش",
@@ -239,6 +245,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "تىلنى تونۇش", "detectLanguage": "تىلنى تونۇش",
"sourceLanguage": "مەنبە تىلى",
"auto": "ئاپتوماتىك",
"generateIPA": "ipa ھاسىل قىلىش", "generateIPA": "ipa ھاسىل قىلىش",
"translateInto": "تەرجىمە قىلىش", "translateInto": "تەرجىمە قىلىش",
"chinese": "خەنزۇچە", "chinese": "خەنزۇچە",

View File

@@ -194,13 +194,19 @@
"profile": "个人资料", "profile": "个人资料",
"folders": "文件夹", "folders": "文件夹",
"explore": "探索", "explore": "探索",
"favorites": "收藏" "favorites": "收藏",
"settings": "设置"
}, },
"profile": { "profile": {
"myProfile": "我的个人资料", "myProfile": "我的个人资料",
"email": "邮箱:{email}", "email": "邮箱:{email}",
"logout": "退出登录" "logout": "退出登录"
}, },
"settings": {
"title": "设置",
"themeColor": "主题色",
"themeColorDescription": "选择您喜欢的主题色"
},
"srt_player": { "srt_player": {
"upload": "上传", "upload": "上传",
"uploadVideo": "上传视频", "uploadVideo": "上传视频",
@@ -239,6 +245,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "检测语言", "detectLanguage": "检测语言",
"sourceLanguage": "源语言",
"auto": "自动",
"generateIPA": "生成国际音标", "generateIPA": "生成国际音标",
"translateInto": "翻译为", "translateInto": "翻译为",
"chinese": "中文", "chinese": "中文",

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button"; import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
import { Select } from "@/design-system/base/select";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -10,16 +11,45 @@ import { toast } from "sonner";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { TSharedTranslationResult } from "@/shared/translator-type"; import { TSharedTranslationResult } from "@/shared/translator-type";
const SOURCE_LANGUAGES = [
{ value: "Auto", labelKey: "auto" },
{ value: "Chinese", labelKey: "chinese" },
{ value: "English", labelKey: "english" },
{ value: "Japanese", labelKey: "japanese" },
{ value: "Korean", labelKey: "korean" },
{ value: "French", labelKey: "french" },
{ value: "German", labelKey: "german" },
{ value: "Italian", labelKey: "italian" },
{ value: "Spanish", labelKey: "spanish" },
{ value: "Portuguese", labelKey: "portuguese" },
{ value: "Russian", labelKey: "russian" },
] as const;
const TARGET_LANGUAGES = [
{ value: "Chinese", labelKey: "chinese" },
{ value: "English", labelKey: "english" },
{ value: "Japanese", labelKey: "japanese" },
{ value: "Korean", labelKey: "korean" },
{ value: "French", labelKey: "french" },
{ value: "German", labelKey: "german" },
{ value: "Italian", labelKey: "italian" },
{ value: "Spanish", labelKey: "spanish" },
{ value: "Portuguese", labelKey: "portuguese" },
{ value: "Russian", labelKey: "russian" },
] as const;
export default function TranslatorPage() { export default function TranslatorPage() {
const t = useTranslations("translator"); const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null); const taref = useRef<HTMLTextAreaElement>(null);
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese"); const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null); const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
const [needIpa, setNeedIpa] = useState(true); const [needIpa, setNeedIpa] = useState(true);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [lastTranslation, setLastTranslation] = useState<{ const [lastTranslation, setLastTranslation] = useState<{
sourceText: string; sourceText: string;
sourceLanguage: string;
targetLanguage: string; targetLanguage: string;
} | null>(null); } | null>(null);
const { load, play } = useAudioPlayer(); const { load, play } = useAudioPlayer();
@@ -63,9 +93,10 @@ export default function TranslatorPage() {
const sourceText = taref.current.value; const sourceText = taref.current.value;
// 判断是否需要强制重新翻译 // 判断是否需要强制重新翻译
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译 // 只有当源文本、源语言和目标语言都与上次相同时,才强制重新翻译
const forceRetranslate = const forceRetranslate =
lastTranslation?.sourceText === sourceText && lastTranslation?.sourceText === sourceText &&
lastTranslation?.sourceLanguage === sourceLanguage &&
lastTranslation?.targetLanguage === targetLanguage; lastTranslation?.targetLanguage === targetLanguage;
try { try {
@@ -74,12 +105,14 @@ export default function TranslatorPage() {
targetLanguage, targetLanguage,
forceRetranslate, forceRetranslate,
needIpa, needIpa,
sourceLanguage: sourceLanguage === "Auto" ? undefined : sourceLanguage,
}); });
if (result.success && result.data) { if (result.success && result.data) {
setTranslationResult(result.data); setTranslationResult(result.data);
setLastTranslation({ setLastTranslation({
sourceText, sourceText,
sourceLanguage,
targetLanguage, targetLanguage,
}); });
} else { } else {
@@ -132,11 +165,47 @@ export default function TranslatorPage() {
></IconClick> ></IconClick>
</div> </div>
</div> </div>
<div className="option1 w-full flex flex-row justify-between items-center"> <div className="option1 w-full flex gap-1 items-center overflow-x-auto">
<span>{t("detectLanguage")}</span> <span className="shrink-0">{t("sourceLanguage")}</span>
<LightButton
selected={sourceLanguage === "Auto"}
onClick={() => setSourceLanguage("Auto")}
className="shrink-0 hidden lg:inline-flex"
>
{t("auto")}
</LightButton>
<LightButton
selected={sourceLanguage === "Chinese"}
onClick={() => setSourceLanguage("Chinese")}
className="shrink-0 hidden lg:inline-flex"
>
{t("chinese")}
</LightButton>
<LightButton
selected={sourceLanguage === "English"}
onClick={() => setSourceLanguage("English")}
className="shrink-0 hidden xl:inline-flex"
>
{t("english")}
</LightButton>
<Select
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
variant="light"
size="sm"
className="w-auto min-w-[100px] shrink-0"
>
{SOURCE_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{t(lang.labelKey)}
</option>
))}
</Select>
<div className="flex-1"></div>
<LightButton <LightButton
selected={needIpa} selected={needIpa}
onClick={() => setNeedIpa((prev) => !prev)} onClick={() => setNeedIpa((prev) => !prev)}
className="shrink-0"
> >
{t("generateIPA")} {t("generateIPA")}
</LightButton> </LightButton>
@@ -172,37 +241,42 @@ export default function TranslatorPage() {
></IconClick> ></IconClick>
</div> </div>
</div> </div>
<div className="option2 w-full flex gap-1 items-center flex-wrap"> <div className="option2 w-full flex gap-1 items-center overflow-x-auto">
<span>{t("translateInto")}</span> <span className="shrink-0">{t("translateInto")}</span>
<LightButton <LightButton
selected={targetLanguage === "Chinese"} selected={targetLanguage === "Chinese"}
onClick={() => setTargetLanguage("Chinese")} onClick={() => setTargetLanguage("Chinese")}
className="shrink-0 hidden lg:inline-flex"
> >
{t("chinese")} {t("chinese")}
</LightButton> </LightButton>
<LightButton <LightButton
selected={targetLanguage === "English"} selected={targetLanguage === "English"}
onClick={() => setTargetLanguage("English")} onClick={() => setTargetLanguage("English")}
className="shrink-0 hidden lg:inline-flex"
> >
{t("english")} {t("english")}
</LightButton> </LightButton>
<LightButton <LightButton
selected={targetLanguage === "Italian"} selected={targetLanguage === "Japanese"}
onClick={() => setTargetLanguage("Italian")} onClick={() => setTargetLanguage("Japanese")}
className="shrink-0 hidden xl:inline-flex"
> >
{t("italian")} {t("japanese")}
</LightButton> </LightButton>
<LightButton <Select
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)} value={targetLanguage}
onClick={() => { onChange={(e) => setTargetLanguage(e.target.value)}
const newLang = prompt(t("enterLanguage")); variant="light"
if (newLang) { size="sm"
setTargetLanguage(newLang); className="w-auto min-w-[100px] shrink-0"
}
}}
> >
{t("other")} {TARGET_LANGUAGES.map((lang) => (
</LightButton> <option key={lang.value} value={lang.value}>
{t(lang.labelKey)}
</option>
))}
</Select>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,18 +5,17 @@
* 使用 @theme 指令定义主题变量 * 使用 @theme 指令定义主题变量
*/ */
@theme { @theme {
/* 主色 - Teal */ --color-primary-50: var(--primary-50);
--color-primary-50: #f0f9f8; --color-primary-100: var(--primary-100);
--color-primary-100: #e0f2f0; --color-primary-200: var(--primary-200);
--color-primary-200: #bce6e1; --color-primary-300: var(--primary-300);
--color-primary-300: #8dd4cc; --color-primary-400: var(--primary-400);
--color-primary-400: #5ec2b7; --color-primary-500: var(--primary-500);
--color-primary-500: #35786f; --color-primary-600: var(--primary-600);
--color-primary-600: #2a605b; --color-primary-700: var(--primary-700);
--color-primary-700: #1f4844; --color-primary-800: var(--primary-800);
--color-primary-800: #183835; --color-primary-900: var(--primary-900);
--color-primary-900: #122826; --color-primary-950: var(--primary-950);
--color-primary-950: #0a1413;
/* 中性色 */ /* 中性色 */
--color-gray-50: #f9fafb; --color-gray-50: #f9fafb;
@@ -100,6 +99,19 @@
* 定义全局 CSS 变量用于主题切换和动态样式 * 定义全局 CSS 变量用于主题切换和动态样式
*/ */
:root { :root {
/* 主题色 - 默认 Teal */
--primary-50: #f0f9f8;
--primary-100: #e0f2f0;
--primary-200: #bce6e1;
--primary-300: #8dd4cc;
--primary-400: #5ec2b7;
--primary-500: #35786f;
--primary-600: #2a605b;
--primary-700: #1f4844;
--primary-800: #183835;
--primary-900: #122826;
--primary-950: #0a1413;
/* 基础颜色 */ /* 基础颜色 */
--background: #ffffff; --background: #ffffff;
--foreground: #111827; --foreground: #111827;

View File

@@ -5,6 +5,7 @@ import { NextIntlClientProvider } from "next-intl";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { ThemeProvider } from "@/components/theme-provider";
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
@@ -25,11 +26,13 @@ export default async function RootLayout({
<html lang="en"> <html lang="en">
<body className={`antialiased`}> <body className={`antialiased`}>
<StrictMode> <StrictMode>
<ThemeProvider>
<NextIntlClientProvider> <NextIntlClientProvider>
<Navbar></Navbar> <Navbar></Navbar>
{children} {children}
<Toaster /> <Toaster />
</NextIntlClientProvider> </NextIntlClientProvider>
</ThemeProvider>
</StrictMode> </StrictMode>
</body> </body>
</html> </html>

57
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client";
import { useTheme } from "@/components/theme-provider";
import { useTranslations } from "next-intl";
import { cn } from "@/utils/cn";
export default function SettingsPage() {
const t = useTranslations("settings");
const { currentTheme, setTheme, availableThemes } = useTheme();
return (
<div className="min-h-[calc(100vh-64px)] bg-white p-4 md:p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{t("title")}
</h1>
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-gray-800 mb-3">
{t("themeColor")}
</h2>
<p className="text-sm text-gray-600 mb-4">
{t("themeColorDescription")}
</p>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-3">
{availableThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={cn(
"group relative flex flex-col items-center gap-2 p-2 rounded-lg transition-all",
currentTheme === theme.id
? "ring-2 ring-offset-2"
: "hover:bg-gray-50"
)}
style={{
["--tw-ring-color" as string]: theme.colors[500],
}}
>
<div
className="w-8 h-8 rounded-full shadow-md ring-1 ring-black/10"
style={{ backgroundColor: theme.colors[500] }}
/>
<span className="text-xs text-gray-600 group-hover:text-gray-900">
{theme.name}
</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,81 +1,97 @@
"use client"; "use client";
import { GhostLightButton } from "@/design-system/base/button"; import { useState, useEffect, useRef } from "react";
import { useState } from "react";
import { Languages } from "lucide-react"; import { Languages } from "lucide-react";
import { cn } from "@/utils/cn";
const languages = [
{ code: "en-US", label: "English" },
{ code: "zh-CN", label: "中文" },
{ code: "ja-JP", label: "日本語" },
{ code: "ko-KR", label: "한국어" },
{ code: "de-DE", label: "Deutsch" },
{ code: "fr-FR", label: "Français" },
{ code: "it-IT", label: "Italiano" },
{ code: "ug-CN", label: "ئۇيغۇرچە" },
];
export function LanguageSettings() { export function LanguageSettings() {
const [showLanguageMenu, setShowLanguageMenu] = useState(false); const [isOpen, setIsOpen] = useState(false);
const handleLanguageClick = () => { const menuRef = useRef<HTMLDivElement>(null);
setShowLanguageMenu((prev) => !prev);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}; };
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen]);
const setLocale = async (locale: string) => { const setLocale = async (locale: string) => {
document.cookie = `locale=${locale}`; document.cookie = `locale=${locale}`;
window.location.reload(); window.location.reload();
}; };
return ( return (
<> <div className="relative" ref={menuRef}>
<GhostLightButton <button
size="md" onClick={() => setIsOpen(!isOpen)}
onClick={handleLanguageClick} className="flex items-center justify-center p-2 rounded-md text-white hover:bg-white/10 transition-colors"
aria-label="切换语言"
aria-expanded={isOpen}
> >
<Languages size={20} /> <Languages size={20} />
</GhostLightButton> </button>
<div className="relative">
{showLanguageMenu && ( <div
<div> className={cn(
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2"> "absolute right-0 top-full mt-2 w-40 rounded-lg bg-white shadow-lg ring-1 ring-black/5 overflow-hidden transition-all duration-200 origin-top-right z-50",
<GhostLightButton isOpen
className="w-full bg-primary-500" ? "opacity-100 scale-100"
onClick={() => setLocale("en-US")} : "opacity-0 scale-95 pointer-events-none"
>
English
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("zh-CN")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ja-JP")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ko-KR")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("de-DE")}
>
Deutsch
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("fr-FR")}
>
Français
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("it-IT")}
>
Italiano
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ug-CN")}
>
ئۇيغۇرچە
</GhostLightButton>
</div>
</div>
)} )}
</div></> role="menu"
>
<div className="py-1">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => setLocale(lang.code)}
className="w-full flex items-center px-4 py-2.5 text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors text-left"
role="menuitem"
>
{lang.label}
</button>
))}
</div>
</div>
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
)}
</div>
); );
} }

View File

@@ -0,0 +1,92 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Menu, X } from "lucide-react";
import { cn } from "@/utils/cn";
import type { NavigationItem } from "./Navbar";
interface MobileMenuProps {
items: NavigationItem[];
}
export function MobileMenu({ items }: MobileMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen]);
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center p-2 rounded-md text-white hover:bg-white/10 transition-colors"
aria-label={isOpen ? "关闭菜单" : "打开菜单"}
aria-expanded={isOpen}
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</button>
<div
className={cn(
"absolute right-0 top-full mt-2 w-56 rounded-lg bg-white shadow-lg ring-1 ring-black/5 overflow-hidden transition-all duration-200 origin-top-right z-50",
isOpen
? "opacity-100 scale-100"
: "opacity-0 scale-95 pointer-events-none"
)}
role="menu"
>
<div className="py-1">
{items.map((item, index) => (
<a
key={index}
href={item.href}
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors"
role="menuitem"
onClick={() => setIsOpen(false)}
target={item.external ? "_blank" : undefined}
rel={item.external ? "noopener noreferrer" : undefined}
>
{item.icon && <span className="shrink-0 text-gray-500">{item.icon}</span>}
<span>{item.label}</span>
</a>
))}
</div>
</div>
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
)}
</div>
);
}

View File

@@ -1,11 +1,18 @@
import Image from "next/image"; import { Compass, Folder, Heart, Home, Settings, User, Github } from "lucide-react";
import { IMAGES } from "@/config/images";
import { Compass, Folder, Heart, Home, User } from "lucide-react";
import { LanguageSettings } from "./LanguageSettings"; import { LanguageSettings } from "./LanguageSettings";
import { MobileMenu } from "./MobileMenu";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { GhostLightButton } from "@/design-system/base/button"; import { GhostLightButton } from "@/design-system/base/button";
import type { ReactNode } from "react";
export interface NavigationItem {
label: string;
href: string;
icon?: ReactNode;
external?: boolean;
}
export async function Navbar() { export async function Navbar() {
const t = await getTranslations("navbar"); const t = await getTranslations("navbar");
@@ -13,49 +20,38 @@ export async function Navbar() {
headers: await headers() headers: await headers()
}); });
const mobileMenuItems: NavigationItem[] = [
{ label: t("folders"), href: "/folders", icon: <Folder size={18} /> },
{ label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
{ label: t("settings"), href: "/settings", icon: <Settings size={18} /> },
...(session
? [{ label: t("profile"), href: "/profile", icon: <User size={18} /> }]
: [{ label: t("sign_in"), href: "/login", icon: <User size={18} /> }]
),
];
return ( return (
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-primary-500 text-white"> <div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-primary-500 text-white">
<GhostLightButton href="/" className="border-b hidden! md:block!" size="md"> <GhostLightButton href="/" className="border-b hidden! md:block!" size="md">
{t("title")} {t("title")}
</GhostLightButton> </GhostLightButton>
<GhostLightButton className="block! md:hidden!" size="md" href={"/"}> <GhostLightButton className="block! md:hidden!" size="md" href="/">
<Home size={20} /> <Home size={20} />
</GhostLightButton> </GhostLightButton>
<div className="flex gap-0.5 justify-center items-center"> <div className="flex gap-0.5 justify-center items-center">
<LanguageSettings /> <LanguageSettings />
<GhostLightButton
className="md:hidden! block!"
size="md"
href="https://github.com/GoddoNebianU/learn-languages"
>
<Image
src={IMAGES.github_mark_white}
alt="GitHub"
width={20}
height={20}
/>
</GhostLightButton>
<GhostLightButton href="/folders" className="md:block! hidden!" size="md"> <GhostLightButton href="/folders" className="md:block! hidden!" size="md">
{t("folders")} {t("folders")}
</GhostLightButton> </GhostLightButton>
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
<Folder size={20} />
</GhostLightButton>
<GhostLightButton href="/explore" className="md:block! hidden!" size="md"> <GhostLightButton href="/explore" className="md:block! hidden!" size="md">
{t("explore")} {t("explore")}
</GhostLightButton> </GhostLightButton>
<GhostLightButton href="/explore" className="md:hidden! block!" size="md">
<Compass size={20} />
</GhostLightButton>
{session && ( {session && (
<>
<GhostLightButton href="/favorites" className="md:block! hidden!" size="md"> <GhostLightButton href="/favorites" className="md:block! hidden!" size="md">
{t("favorites")} {t("favorites")}
</GhostLightButton> </GhostLightButton>
<GhostLightButton href="/favorites" className="md:hidden! block!" size="md">
<Heart size={20} />
</GhostLightButton>
</>
)} )}
<GhostLightButton <GhostLightButton
className="hidden! md:block!" className="hidden! md:block!"
@@ -64,23 +60,21 @@ export async function Navbar() {
> >
{t("sourceCode")} {t("sourceCode")}
</GhostLightButton> </GhostLightButton>
{ <GhostLightButton href="/settings" className="hidden! md:block!" size="md">
(() => { {t("settings")}
return session &&
<>
<GhostLightButton href="/profile" className="hidden! md:block!" size="md">{t("profile")}</GhostLightButton>
<GhostLightButton href="/profile" className="md:hidden! block!" size="md">
<User size={20} />
</GhostLightButton> </GhostLightButton>
</> {session ? (
|| <> <GhostLightButton href="/profile" className="hidden! md:block!" size="md">
<GhostLightButton href="/login" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton> {t("profile")}
<GhostLightButton href="/login" className="md:hidden! block!" size="md">
<User size={20} />
</GhostLightButton> </GhostLightButton>
</>; ) : (
})() <GhostLightButton href="/login" className="hidden! md:block!" size="md">
} {t("sign_in")}
</GhostLightButton>
)}
<div className="md:hidden!">
<MobileMenu items={mobileMenuItems} />
</div>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,76 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import {
THEME_PRESETS,
DEFAULT_THEME,
getThemePreset,
applyThemeColors,
type ThemePreset,
} from "@/shared/theme-presets";
type ThemeContextType = {
currentTheme: string;
themePreset: ThemePreset;
setTheme: (themeId: string) => void;
availableThemes: ThemePreset[];
};
const ThemeContext = createContext<ThemeContextType | null>(null);
const STORAGE_KEY = "theme-preset";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const savedTheme = localStorage.getItem(STORAGE_KEY);
if (savedTheme && getThemePreset(savedTheme)) {
setCurrentTheme(savedTheme);
}
}, []);
useEffect(() => {
if (!mounted) return;
const preset = getThemePreset(currentTheme);
if (preset) {
applyThemeColors(preset);
localStorage.setItem(STORAGE_KEY, currentTheme);
}
}, [currentTheme, mounted]);
const setTheme = (themeId: string) => {
if (getThemePreset(themeId)) {
setCurrentTheme(themeId);
}
};
const themePreset = getThemePreset(currentTheme) || THEME_PRESETS[0];
if (!mounted) {
return null;
}
return (
<ThemeContext.Provider
value={{
currentTheme,
themePreset,
setTheme,
availableThemes: THEME_PRESETS,
}}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -31,6 +31,7 @@ const selectVariants = cva(
default: "border-b-2 border-gray-300 bg-transparent rounded-t-md", default: "border-b-2 border-gray-300 bg-transparent rounded-t-md",
bordered: "border-gray-300 bg-white", bordered: "border-gray-300 bg-white",
filled: "border-transparent bg-gray-100", filled: "border-transparent bg-gray-100",
light: "border-transparent bg-gray-100 shadow-sm hover:bg-gray-200 font-semibold cursor-pointer",
}, },
size: { size: {
sm: "h-9 px-3 text-sm", sm: "h-9 px-3 text-sm",
@@ -48,6 +49,11 @@ const selectVariants = cva(
error: true, error: true,
className: "bg-error-50", className: "bg-error-50",
}, },
{
variant: "light",
error: true,
className: "bg-error-50 hover:bg-error-100",
},
], ],
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",

View File

@@ -132,19 +132,28 @@ async function generateIPA(
export async function executeTranslation( export async function executeTranslation(
sourceText: string, sourceText: string,
targetLanguage: string, targetLanguage: string,
needIpa: boolean needIpa: boolean,
sourceLanguage?: string
): Promise<TranslationLLMResponse> { ): Promise<TranslationLLMResponse> {
try { try {
log.debug("Starting translation", { sourceText, targetLanguage, needIpa }); log.debug("Starting translation", { sourceText, targetLanguage, needIpa, sourceLanguage });
let detectedLanguage: string;
if (sourceLanguage) {
log.debug("[Stage 1] Using provided source language", { sourceLanguage });
detectedLanguage = sourceLanguage;
} else {
log.debug("[Stage 1] Detecting source language"); log.debug("[Stage 1] Detecting source language");
const detectionResult = await detectLanguage(sourceText); const detectionResult = await detectLanguage(sourceText);
log.debug("[Stage 1] Detection result", { detectionResult }); log.debug("[Stage 1] Detection result", { detectionResult });
detectedLanguage = detectionResult.sourceLanguage;
}
log.debug("[Stage 2] Performing translation"); log.debug("[Stage 2] Performing translation");
const translatedText = await performTranslation( const translatedText = await performTranslation(
sourceText, sourceText,
detectionResult.sourceLanguage, detectedLanguage,
targetLanguage targetLanguage
); );
log.debug("[Stage 2] Translation complete", { translatedText }); log.debug("[Stage 2] Translation complete", { translatedText });
@@ -160,7 +169,7 @@ export async function executeTranslation(
if (needIpa) { if (needIpa) {
log.debug("[Stage 3] Generating IPA"); log.debug("[Stage 3] Generating IPA");
sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage); sourceIpa = await generateIPA(sourceText, detectedLanguage);
log.debug("[Stage 3] Source IPA", { sourceIpa }); log.debug("[Stage 3] Source IPA", { sourceIpa });
targetIpa = await generateIPA(translatedText, targetLanguage); targetIpa = await generateIPA(translatedText, targetLanguage);
@@ -171,7 +180,7 @@ export async function executeTranslation(
const finalResult: TranslationLLMResponse = { const finalResult: TranslationLLMResponse = {
sourceText, sourceText,
translatedText, translatedText,
sourceLanguage: detectionResult.sourceLanguage, sourceLanguage: detectedLanguage,
targetLanguage, targetLanguage,
sourceIpa, sourceIpa,
targetIpa, targetIpa,

View File

@@ -14,6 +14,7 @@ const schemaActionInputTranslateText = z.object({
forceRetranslate: z.boolean().optional().default(false), forceRetranslate: z.boolean().optional().default(false),
needIpa: z.boolean().optional().default(true), needIpa: z.boolean().optional().default(true),
userId: z.string().optional(), userId: z.string().optional(),
sourceLanguage: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(),
}); });
export type ActionInputTranslateText = z.infer<typeof schemaActionInputTranslateText>; export type ActionInputTranslateText = z.infer<typeof schemaActionInputTranslateText>;

View File

@@ -6,6 +6,7 @@ export type ServiceInputTranslateText = {
forceRetranslate: boolean; forceRetranslate: boolean;
needIpa: boolean; needIpa: boolean;
userId?: string; userId?: string;
sourceLanguage?: string;
}; };
export type ServiceOutputTranslateText = TSharedTranslationResult; export type ServiceOutputTranslateText = TSharedTranslationResult;

View File

@@ -8,7 +8,7 @@ const log = createLogger("translator-service");
export const serviceTranslateText = async ( export const serviceTranslateText = async (
dto: ServiceInputTranslateText dto: ServiceInputTranslateText
): Promise<ServiceOutputTranslateText> => { ): Promise<ServiceOutputTranslateText> => {
const { sourceText, targetLanguage, forceRetranslate, needIpa, userId } = dto; const { sourceText, targetLanguage, forceRetranslate, needIpa, userId, sourceLanguage } = dto;
// Check for existing translation // Check for existing translation
const lastTranslation = await repoSelectLatestTranslation({ const lastTranslation = await repoSelectLatestTranslation({
@@ -21,7 +21,8 @@ export const serviceTranslateText = async (
const response = await executeTranslation( const response = await executeTranslation(
sourceText, sourceText,
targetLanguage, targetLanguage,
needIpa needIpa,
sourceLanguage
); );
// Save translation history asynchronously (don't block response) // Save translation history asynchronously (don't block response)

294
src/shared/theme-presets.ts Normal file
View File

@@ -0,0 +1,294 @@
/**
* 主题色预设
*
* 每个预设包含完整的 10 级色阶
*/
export interface ThemePreset {
id: string;
name: string;
colors: {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
950: string;
};
}
export const THEME_PRESETS: ThemePreset[] = [
{
id: "teal",
name: "青绿",
colors: {
50: "#f0f9f8",
100: "#e0f2f0",
200: "#bce6e1",
300: "#8dd4cc",
400: "#5ec2b7",
500: "#35786f",
600: "#2a605b",
700: "#1f4844",
800: "#183835",
900: "#122826",
950: "#0a1413",
},
},
{
id: "blue",
name: "蓝色",
colors: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
950: "#172554",
},
},
{
id: "violet",
name: "紫罗兰",
colors: {
50: "#f5f3ff",
100: "#ede9fe",
200: "#ddd6fe",
300: "#c4b5fd",
400: "#a78bfa",
500: "#8b5cf6",
600: "#7c3aed",
700: "#6d28d9",
800: "#5b21b6",
900: "#4c1d95",
950: "#2e1065",
},
},
{
id: "rose",
name: "玫瑰",
colors: {
50: "#fff1f2",
100: "#ffe4e6",
200: "#fecdd3",
300: "#fda4af",
400: "#fb7185",
500: "#f43f5e",
600: "#e11d48",
700: "#be123c",
800: "#9f1239",
900: "#881337",
950: "#4c0519",
},
},
{
id: "amber",
name: "琥珀",
colors: {
50: "#fffbeb",
100: "#fef3c7",
200: "#fde68a",
300: "#fcd34d",
400: "#fbbf24",
500: "#f59e0b",
600: "#d97706",
700: "#b45309",
800: "#92400e",
900: "#78350f",
950: "#451a03",
},
},
{
id: "emerald",
name: "翡翠",
colors: {
50: "#ecfdf5",
100: "#d1fae5",
200: "#a7f3d0",
300: "#6ee7b7",
400: "#34d399",
500: "#10b981",
600: "#059669",
700: "#047857",
800: "#065f46",
900: "#064e3b",
950: "#022c22",
},
},
{
id: "orange",
name: "橙色",
colors: {
50: "#fff7ed",
100: "#ffedd5",
200: "#fed7aa",
300: "#fdba74",
400: "#fb923c",
500: "#f97316",
600: "#ea580c",
700: "#c2410c",
800: "#9a3412",
900: "#7c2d12",
950: "#431407",
},
},
{
id: "indigo",
name: "靛蓝",
colors: {
50: "#eef2ff",
100: "#e0e7ff",
200: "#c7d2fe",
300: "#a5b4fc",
400: "#818cf8",
500: "#6366f1",
600: "#4f46e5",
700: "#4338ca",
800: "#3730a3",
900: "#312e81",
950: "#1e1b4b",
},
},
{
id: "slate",
name: "石墨",
colors: {
50: "#f8fafc",
100: "#f1f5f9",
200: "#e2e8f0",
300: "#cbd5e1",
400: "#94a3b8",
500: "#64748b",
600: "#475569",
700: "#334155",
800: "#1e293b",
900: "#0f172a",
950: "#020617",
},
},
{
id: "sage",
name: "鼠尾草",
colors: {
50: "#f6f7f6",
100: "#e3e7e3",
200: "#c7d0c7",
300: "#a3b1a3",
400: "#7d8e7d",
500: "#5f715f",
600: "#4b5a4b",
700: "#3d483d",
800: "#323b32",
900: "#2a312a",
950: "#151915",
},
},
{
id: "taupe",
name: "暖沙",
colors: {
50: "#faf8f6",
100: "#f2ede7",
200: "#e5dbd0",
300: "#d4c4b3",
400: "#bfa690",
500: "#a88c73",
600: "#8f735d",
700: "#755e4d",
800: "#614e42",
900: "#514239",
950: "#2b221d",
},
},
{
id: "mauve",
name: "薰衣草",
colors: {
50: "#faf8f9",
100: "#f3eef1",
200: "#e8dfe4",
300: "#d9ccd4",
400: "#c5b0be",
500: "#ad94a7",
600: "#967a90",
700: "#7d6579",
800: "#675465",
900: "#564755",
950: "#2e242e",
},
},
{
id: "mist",
name: "雾蓝",
colors: {
50: "#f7f8fa",
100: "#eef1f5",
200: "#dce2eb",
300: "#c4cdd9",
400: "#a3b0c1",
500: "#8594a8",
600: "#6b7a8d",
700: "#596474",
800: "#4b5360",
900: "#414850",
950: "#22262b",
},
},
{
id: "dusty",
name: "玫瑰灰",
colors: {
50: "#faf8f7",
100: "#f4efed",
200: "#e9dfdb",
300: "#daced0",
400: "#c6b0a8",
500: "#b0948c",
600: "#967a74",
700: "#7d6560",
800: "#675451",
900: "#554644",
950: "#2c2423",
},
},
{
id: "olive",
name: "橄榄",
colors: {
50: "#f7f7f4",
100: "#eeeeda",
200: "#dddcb6",
300: "#c8c58c",
400: "#b2ad64",
500: "#9a9648",
600: "#7d7a3b",
700: "#656333",
800: "#53512d",
900: "#454429",
950: "#252413",
},
},
];
export const DEFAULT_THEME = "teal";
export function getThemePreset(id: string): ThemePreset | undefined {
return THEME_PRESETS.find((preset) => preset.id === id);
}
export function applyThemeColors(preset: ThemePreset): void {
const root = document.documentElement;
Object.entries(preset.colors).forEach(([shade, color]) => {
root.style.setProperty(`--color-primary-${shade}`, color);
});
}