diff --git a/messages/de-DE.json b/messages/de-DE.json index 83110de..66c3668 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -193,14 +193,20 @@ "sign_in": "Anmelden", "profile": "Profil", "folders": "Ordner", - "explore": "Entdecken", - "favorites": "Favoriten" + "explore": "Erkunden", + "favorites": "Favoriten", + "settings": "Einstellungen" }, "profile": { "myProfile": "Mein Profil", "email": "E-Mail: {email}", "logout": "Abmelden" }, + "settings": { + "title": "Einstellungen", + "themeColor": "Designfarbe", + "themeColorDescription": "Wählen Sie Ihre bevorzugte Designfarbe" + }, "srt_player": { "uploadVideo": "Video hochladen", "uploadSubtitle": "Untertitel hochladen", @@ -239,6 +245,8 @@ }, "translator": { "detectLanguage": "Sprache erkennen", + "sourceLanguage": "Quellsprache", + "auto": "Automatisch", "generateIPA": "IPA generieren", "translateInto": "übersetzen in", "chinese": "Chinesisch", diff --git a/messages/en-US.json b/messages/en-US.json index 06158a5..a1888e2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -194,13 +194,19 @@ "profile": "Profile", "folders": "Folders", "explore": "Explore", - "favorites": "Favorites" + "favorites": "Favorites", + "settings": "Settings" }, "profile": { "myProfile": "My Profile", "email": "Email: {email}", "logout": "Logout" }, + "settings": { + "title": "Settings", + "themeColor": "Theme Color", + "themeColorDescription": "Choose your preferred theme color" + }, "srt_player": { "uploadVideo": "Upload Video", "uploadSubtitle": "Upload Subtitle", @@ -239,6 +245,8 @@ }, "translator": { "detectLanguage": "detect language", + "sourceLanguage": "source language", + "auto": "Auto", "generateIPA": "generate ipa", "translateInto": "translate into", "chinese": "Chinese", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index d2943d4..9bc3c2c 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -188,19 +188,25 @@ } }, "navbar": { - "title": "apprendre-langues", + "title": "learn-languages", "sourceCode": "GitHub", - "sign_in": "Se connecter", + "sign_in": "Connexion", "profile": "Profil", "folders": "Dossiers", "explore": "Explorer", - "favorites": "Favoris" + "favorites": "Favoris", + "settings": "Paramètres" }, "profile": { "myProfile": "Mon profil", "email": "E-mail : {email}", "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": { "uploadVideo": "Télécharger la vidéo", "uploadSubtitle": "Télécharger les sous-titres", @@ -239,6 +245,8 @@ }, "translator": { "detectLanguage": "détecter la langue", + "sourceLanguage": "langue source", + "auto": "Auto", "generateIPA": "générer l'api", "translateInto": "traduire en", "chinese": "Chinois", diff --git a/messages/it-IT.json b/messages/it-IT.json index 0edc8b8..1cc2a79 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -188,19 +188,25 @@ } }, "navbar": { - "title": "impara-lingue", + "title": "learn-languages", "sourceCode": "GitHub", "sign_in": "Accedi", "profile": "Profilo", "folders": "Cartelle", "explore": "Esplora", - "favorites": "Preferiti" + "favorites": "Preferiti", + "settings": "Impostazioni" }, "profile": { "myProfile": "Il Mio Profilo", "email": "Email: {email}", "logout": "Esci" }, + "settings": { + "title": "Impostazioni", + "themeColor": "Colore del tema", + "themeColorDescription": "Scegli il tuo colore del tema preferito" + }, "srt_player": { "uploadVideo": "Carica Video", "uploadSubtitle": "Carica Sottotitoli", @@ -239,6 +245,8 @@ }, "translator": { "detectLanguage": "rileva lingua", + "sourceLanguage": "lingua di origine", + "auto": "Auto", "generateIPA": "genera ipa", "translateInto": "traduci in", "chinese": "Cinese", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 73732e1..a91a5dc 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -194,13 +194,19 @@ "profile": "プロフィール", "folders": "フォルダー", "explore": "探索", - "favorites": "お気に入り" + "favorites": "お気に入り", + "settings": "設定" }, "profile": { "myProfile": "マイプロフィール", "email": "メール: {email}", "logout": "ログアウト" }, + "settings": { + "title": "設定", + "themeColor": "テーマカラー", + "themeColorDescription": "お好みのテーマカラーを選択してください" + }, "srt_player": { "uploadVideo": "ビデオをアップロード", "uploadSubtitle": "字幕をアップロード", @@ -239,6 +245,8 @@ }, "translator": { "detectLanguage": "言語を検出", + "sourceLanguage": "ソース言語", + "auto": "自動", "generateIPA": "ipaを生成", "translateInto": "翻訳先", "chinese": "中国語", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index c2297bd..75de478 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -194,13 +194,19 @@ "profile": "프로필", "folders": "폴더", "explore": "탐색", - "favorites": "즐겨찾기" + "favorites": "즐겨찾기", + "settings": "설정" }, "profile": { "myProfile": "내 프로필", "email": "이메일: {email}", "logout": "로그아웃" }, + "settings": { + "title": "설정", + "themeColor": "테마 색상", + "themeColorDescription": "원하는 테마 색상을 선택하세요" + }, "srt_player": { "uploadVideo": "비디오 업로드", "uploadSubtitle": "자막 업로드", @@ -239,6 +245,8 @@ }, "translator": { "detectLanguage": "언어 감지", + "sourceLanguage": "원본 언어", + "auto": "자동", "generateIPA": "IPA 생성", "translateInto": "번역할 언어", "chinese": "중국어", diff --git a/messages/ug-CN.json b/messages/ug-CN.json index c22a7c0..53baeba 100644 --- a/messages/ug-CN.json +++ b/messages/ug-CN.json @@ -188,19 +188,25 @@ } }, "navbar": { - "title": "تىل-ئۆگىنىش", + "title": "learn-languages", "sourceCode": "GitHub", "sign_in": "كىرىش", "profile": "شەخسىي ئۇچۇر", "folders": "قىسقۇچلار", "explore": "ئىزدىنىش", - "favorites": "يىغىپ ساقلانغانلار" + "favorites": "يىغىپ ساقلاش", + "settings": "تەڭشەكلەر" }, "profile": { "myProfile": "شەخسىي ئۇچۇرۇم", "email": "ئېلخەت: {email}", "logout": "چىكىنىش" }, + "settings": { + "title": "تەڭشەكلەر", + "themeColor": "تېما رەڭگى", + "themeColorDescription": "ياقتۇرىدىغان تېما رەڭگىڭىزنى تاللاڭ" + }, "srt_player": { "uploadVideo": "ۋىدېئو يۈكلەش", "uploadSubtitle": "تر پودكاست يۈكلەش", @@ -239,6 +245,8 @@ }, "translator": { "detectLanguage": "تىلنى تونۇش", + "sourceLanguage": "مەنبە تىلى", + "auto": "ئاپتوماتىك", "generateIPA": "ipa ھاسىل قىلىش", "translateInto": "تەرجىمە قىلىش", "chinese": "خەنزۇچە", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 91027cb..fed8647 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -194,13 +194,19 @@ "profile": "个人资料", "folders": "文件夹", "explore": "探索", - "favorites": "收藏" + "favorites": "收藏", + "settings": "设置" }, "profile": { "myProfile": "我的个人资料", "email": "邮箱:{email}", "logout": "退出登录" }, + "settings": { + "title": "设置", + "themeColor": "主题色", + "themeColorDescription": "选择您喜欢的主题色" + }, "srt_player": { "upload": "上传", "uploadVideo": "上传视频", @@ -239,6 +245,8 @@ }, "translator": { "detectLanguage": "检测语言", + "sourceLanguage": "源语言", + "auto": "自动", "generateIPA": "生成国际音标", "translateInto": "翻译为", "chinese": "中文", diff --git a/src/app/(features)/translator/page.tsx b/src/app/(features)/translator/page.tsx index 2cf5f6b..6523b32 100644 --- a/src/app/(features)/translator/page.tsx +++ b/src/app/(features)/translator/page.tsx @@ -1,6 +1,7 @@ "use client"; import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button"; +import { Select } from "@/design-system/base/select"; import { IMAGES } from "@/config/images"; import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useTranslations } from "next-intl"; @@ -10,16 +11,45 @@ import { toast } from "sonner"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; 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() { const t = useTranslations("translator"); const taref = useRef(null); + const [sourceLanguage, setSourceLanguage] = useState("Auto"); const [targetLanguage, setTargetLanguage] = useState("Chinese"); const [translationResult, setTranslationResult] = useState(null); const [needIpa, setNeedIpa] = useState(true); const [processing, setProcessing] = useState(false); const [lastTranslation, setLastTranslation] = useState<{ sourceText: string; + sourceLanguage: string; targetLanguage: string; } | null>(null); const { load, play } = useAudioPlayer(); @@ -63,9 +93,10 @@ export default function TranslatorPage() { const sourceText = taref.current.value; // 判断是否需要强制重新翻译 - // 只有当源文本和目标语言都与上次相同时,才强制重新翻译 + // 只有当源文本、源语言和目标语言都与上次相同时,才强制重新翻译 const forceRetranslate = lastTranslation?.sourceText === sourceText && + lastTranslation?.sourceLanguage === sourceLanguage && lastTranslation?.targetLanguage === targetLanguage; try { @@ -74,12 +105,14 @@ export default function TranslatorPage() { targetLanguage, forceRetranslate, needIpa, + sourceLanguage: sourceLanguage === "Auto" ? undefined : sourceLanguage, }); if (result.success && result.data) { setTranslationResult(result.data); setLastTranslation({ sourceText, + sourceLanguage, targetLanguage, }); } else { @@ -132,11 +165,47 @@ export default function TranslatorPage() { > -
- {t("detectLanguage")} +
+ {t("sourceLanguage")} + setSourceLanguage("Auto")} + className="shrink-0 hidden lg:inline-flex" + > + {t("auto")} + + setSourceLanguage("Chinese")} + className="shrink-0 hidden lg:inline-flex" + > + {t("chinese")} + + setSourceLanguage("English")} + className="shrink-0 hidden xl:inline-flex" + > + {t("english")} + + +
setNeedIpa((prev) => !prev)} + className="shrink-0" > {t("generateIPA")} @@ -172,37 +241,42 @@ export default function TranslatorPage() { >
-
- {t("translateInto")} +
+ {t("translateInto")} setTargetLanguage("Chinese")} + className="shrink-0 hidden lg:inline-flex" > {t("chinese")} setTargetLanguage("English")} + className="shrink-0 hidden lg:inline-flex" > {t("english")} setTargetLanguage("Italian")} + selected={targetLanguage === "Japanese"} + onClick={() => setTargetLanguage("Japanese")} + className="shrink-0 hidden xl:inline-flex" > - {t("italian")} + {t("japanese")} - { - const newLang = prompt(t("enterLanguage")); - if (newLang) { - setTargetLanguage(newLang); - } - }} +
diff --git a/src/app/globals.css b/src/app/globals.css index 96ac94f..e60a85e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,18 +5,17 @@ * 使用 @theme 指令定义主题变量 */ @theme { - /* 主色 - Teal */ - --color-primary-50: #f0f9f8; - --color-primary-100: #e0f2f0; - --color-primary-200: #bce6e1; - --color-primary-300: #8dd4cc; - --color-primary-400: #5ec2b7; - --color-primary-500: #35786f; - --color-primary-600: #2a605b; - --color-primary-700: #1f4844; - --color-primary-800: #183835; - --color-primary-900: #122826; - --color-primary-950: #0a1413; + --color-primary-50: var(--primary-50); + --color-primary-100: var(--primary-100); + --color-primary-200: var(--primary-200); + --color-primary-300: var(--primary-300); + --color-primary-400: var(--primary-400); + --color-primary-500: var(--primary-500); + --color-primary-600: var(--primary-600); + --color-primary-700: var(--primary-700); + --color-primary-800: var(--primary-800); + --color-primary-900: var(--primary-900); + --color-primary-950: var(--primary-950); /* 中性色 */ --color-gray-50: #f9fafb; @@ -100,6 +99,19 @@ * 定义全局 CSS 变量用于主题切换和动态样式 */ :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; --foreground: #111827; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3085fff..8536948 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { NextIntlClientProvider } from "next-intl"; import { Navbar } from "@/components/layout/Navbar"; import { Toaster } from "sonner"; import { StrictMode } from "react"; +import { ThemeProvider } from "@/components/theme-provider"; export const viewport: Viewport = { width: "device-width", @@ -25,11 +26,13 @@ export default async function RootLayout({ - - - {children} - - + + + + {children} + + + diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..05e42c4 --- /dev/null +++ b/src/app/settings/page.tsx @@ -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 ( +
+
+

+ {t("title")} +

+ +
+
+

+ {t("themeColor")} +

+

+ {t("themeColorDescription")} +

+ +
+ {availableThemes.map((theme) => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/src/components/layout/LanguageSettings.tsx b/src/components/layout/LanguageSettings.tsx index 1bca78e..4db8227 100644 --- a/src/components/layout/LanguageSettings.tsx +++ b/src/components/layout/LanguageSettings.tsx @@ -1,81 +1,97 @@ "use client"; -import { GhostLightButton } from "@/design-system/base/button"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "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() { - const [showLanguageMenu, setShowLanguageMenu] = useState(false); - const handleLanguageClick = () => { - setShowLanguageMenu((prev) => !prev); + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } }; - const setLocale = async (locale: string) => { - document.cookie = `locale=${locale}`; - window.location.reload(); + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); }; - return ( - <> - { + 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) => { + document.cookie = `locale=${locale}`; + window.location.reload(); + }; + + return ( +
+ + +
+
+ {languages.map((lang) => ( + + ))} +
+
+ + {isOpen && ( +
setIsOpen(false)} + aria-hidden="true" + /> + )} +
+ ); } diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx new file mode 100644 index 0000000..414f969 --- /dev/null +++ b/src/components/layout/MobileMenu.tsx @@ -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(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 ( +
+ + + + + {isOpen && ( +
setIsOpen(false)} + aria-hidden="true" + /> + )} +
+ ); +} diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index a5bb538..1fc4956 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -1,11 +1,18 @@ -import Image from "next/image"; -import { IMAGES } from "@/config/images"; -import { Compass, Folder, Heart, Home, User } from "lucide-react"; +import { Compass, Folder, Heart, Home, Settings, User, Github } from "lucide-react"; import { LanguageSettings } from "./LanguageSettings"; +import { MobileMenu } from "./MobileMenu"; import { auth } from "@/auth"; import { headers } from "next/headers"; import { getTranslations } from "next-intl/server"; 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() { const t = await getTranslations("navbar"); @@ -13,49 +20,38 @@ export async function Navbar() { headers: await headers() }); + const mobileMenuItems: NavigationItem[] = [ + { label: t("folders"), href: "/folders", icon: }, + { label: t("explore"), href: "/explore", icon: }, + ...(session ? [{ label: t("favorites"), href: "/favorites", icon: }] : []), + { label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: , external: true }, + { label: t("settings"), href: "/settings", icon: }, + ...(session + ? [{ label: t("profile"), href: "/profile", icon: }] + : [{ label: t("sign_in"), href: "/login", icon: }] + ), + ]; + return (
{t("title")} - +
- - GitHub - {t("folders")} - - - {t("explore")} - - - {session && ( - <> - - {t("favorites")} - - - - - + + {t("favorites")} + )} {t("sourceCode")} - { - (() => { - return session && - <> - {t("profile")} - - - - - || <> - {t("sign_in")} - - - - ; - })() - } + + {t("settings")} + + {session ? ( + + {t("profile")} + + ) : ( + + {t("sign_in")} + + )} +
+ +
); diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..84978ce --- /dev/null +++ b/src/components/theme-provider.tsx @@ -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(null); + +const STORAGE_KEY = "theme-preset"; + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [currentTheme, setCurrentTheme] = useState(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 ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/src/design-system/base/select.tsx b/src/design-system/base/select.tsx index 5c2533a..38a3890 100644 --- a/src/design-system/base/select.tsx +++ b/src/design-system/base/select.tsx @@ -31,6 +31,7 @@ const selectVariants = cva( default: "border-b-2 border-gray-300 bg-transparent rounded-t-md", bordered: "border-gray-300 bg-white", filled: "border-transparent bg-gray-100", + light: "border-transparent bg-gray-100 shadow-sm hover:bg-gray-200 font-semibold cursor-pointer", }, size: { sm: "h-9 px-3 text-sm", @@ -48,6 +49,11 @@ const selectVariants = cva( error: true, className: "bg-error-50", }, + { + variant: "light", + error: true, + className: "bg-error-50 hover:bg-error-100", + }, ], defaultVariants: { variant: "default", diff --git a/src/lib/bigmodel/translator/orchestrator.ts b/src/lib/bigmodel/translator/orchestrator.ts index 92bd55e..d9174f8 100644 --- a/src/lib/bigmodel/translator/orchestrator.ts +++ b/src/lib/bigmodel/translator/orchestrator.ts @@ -132,19 +132,28 @@ async function generateIPA( export async function executeTranslation( sourceText: string, targetLanguage: string, - needIpa: boolean + needIpa: boolean, + sourceLanguage?: string ): Promise { try { - log.debug("Starting translation", { sourceText, targetLanguage, needIpa }); + log.debug("Starting translation", { sourceText, targetLanguage, needIpa, sourceLanguage }); - log.debug("[Stage 1] Detecting source language"); - const detectionResult = await detectLanguage(sourceText); - log.debug("[Stage 1] Detection result", { detectionResult }); + let detectedLanguage: string; + + if (sourceLanguage) { + log.debug("[Stage 1] Using provided source language", { sourceLanguage }); + detectedLanguage = sourceLanguage; + } else { + log.debug("[Stage 1] Detecting source language"); + const detectionResult = await detectLanguage(sourceText); + log.debug("[Stage 1] Detection result", { detectionResult }); + detectedLanguage = detectionResult.sourceLanguage; + } log.debug("[Stage 2] Performing translation"); const translatedText = await performTranslation( sourceText, - detectionResult.sourceLanguage, + detectedLanguage, targetLanguage ); log.debug("[Stage 2] Translation complete", { translatedText }); @@ -160,7 +169,7 @@ export async function executeTranslation( if (needIpa) { log.debug("[Stage 3] Generating IPA"); - sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage); + sourceIpa = await generateIPA(sourceText, detectedLanguage); log.debug("[Stage 3] Source IPA", { sourceIpa }); targetIpa = await generateIPA(translatedText, targetLanguage); @@ -171,7 +180,7 @@ export async function executeTranslation( const finalResult: TranslationLLMResponse = { sourceText, translatedText, - sourceLanguage: detectionResult.sourceLanguage, + sourceLanguage: detectedLanguage, targetLanguage, sourceIpa, targetIpa, diff --git a/src/modules/translator/translator-action-dto.ts b/src/modules/translator/translator-action-dto.ts index 7332c0b..c0b9fe0 100644 --- a/src/modules/translator/translator-action-dto.ts +++ b/src/modules/translator/translator-action-dto.ts @@ -14,6 +14,7 @@ const schemaActionInputTranslateText = z.object({ forceRetranslate: z.boolean().optional().default(false), needIpa: z.boolean().optional().default(true), userId: z.string().optional(), + sourceLanguage: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(), }); export type ActionInputTranslateText = z.infer; diff --git a/src/modules/translator/translator-service-dto.ts b/src/modules/translator/translator-service-dto.ts index 2f1d05e..e85a6bc 100644 --- a/src/modules/translator/translator-service-dto.ts +++ b/src/modules/translator/translator-service-dto.ts @@ -6,6 +6,7 @@ export type ServiceInputTranslateText = { forceRetranslate: boolean; needIpa: boolean; userId?: string; + sourceLanguage?: string; }; export type ServiceOutputTranslateText = TSharedTranslationResult; diff --git a/src/modules/translator/translator-service.ts b/src/modules/translator/translator-service.ts index 6ecff0b..7c964f9 100644 --- a/src/modules/translator/translator-service.ts +++ b/src/modules/translator/translator-service.ts @@ -8,7 +8,7 @@ const log = createLogger("translator-service"); export const serviceTranslateText = async ( dto: ServiceInputTranslateText ): Promise => { - const { sourceText, targetLanguage, forceRetranslate, needIpa, userId } = dto; + const { sourceText, targetLanguage, forceRetranslate, needIpa, userId, sourceLanguage } = dto; // Check for existing translation const lastTranslation = await repoSelectLatestTranslation({ @@ -21,7 +21,8 @@ export const serviceTranslateText = async ( const response = await executeTranslation( sourceText, targetLanguage, - needIpa + needIpa, + sourceLanguage ); // Save translation history asynchronously (don't block response) diff --git a/src/shared/theme-presets.ts b/src/shared/theme-presets.ts new file mode 100644 index 0000000..6eb6a4a --- /dev/null +++ b/src/shared/theme-presets.ts @@ -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); + }); +}