diff --git a/web/src/components/LocaleSelect.tsx b/web/src/components/LocaleSelect.tsx index d691784e5..a5aa48c8e 100644 --- a/web/src/components/LocaleSelect.tsx +++ b/web/src/components/LocaleSelect.tsx @@ -2,6 +2,7 @@ import { GlobeIcon } from "lucide-react"; import { FC } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { locales } from "@/i18n"; +import { getLocaleDisplayName } from "@/utils/i18n"; interface Props { value: Locale; @@ -24,26 +25,11 @@ const LocaleSelect: FC = (props: Props) => { - {locales.map((locale) => { - try { - const languageName = new Intl.DisplayNames([locale], { type: "language" }).of(locale); - if (languageName) { - return ( - - {languageName.charAt(0).toUpperCase() + languageName.slice(1)} - - ); - } - } catch { - // do nth - } - - return ( - - {locale} - - ); - })} + {locales.map((locale) => ( + + {getLocaleDisplayName(locale)} + + ))} ); diff --git a/web/src/components/Settings/PreferencesSection.tsx b/web/src/components/Settings/PreferencesSection.tsx index af9cf5123..978343e74 100644 --- a/web/src/components/Settings/PreferencesSection.tsx +++ b/web/src/components/Settings/PreferencesSection.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react-lite"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { userStore } from "@/store"; +import { userStore, workspaceStore } from "@/store"; import { Visibility } from "@/types/proto/api/v1/memo_service"; import { UserSetting_GeneralSetting } from "@/types/proto/api/v1/user_service"; import { useTranslate } from "@/utils/i18n"; @@ -16,6 +16,9 @@ const PreferencesSection = observer(() => { const generalSetting = userStore.state.userGeneralSetting; const handleLocaleSelectChange = async (locale: Locale) => { + // Update workspace store immediately for instant UI feedback + workspaceStore.state.setPartial({ locale }); + // Persist to user settings await userStore.updateUserGeneralSetting({ locale }, ["locale"]); }; diff --git a/web/src/components/ThemeSelect.tsx b/web/src/components/ThemeSelect.tsx index ce4857765..3e0a1a4e2 100644 --- a/web/src/components/ThemeSelect.tsx +++ b/web/src/components/ThemeSelect.tsx @@ -1,6 +1,7 @@ import { Moon, Palette, Sun, Wallpaper } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { workspaceStore } from "@/store"; +import { THEME_OPTIONS } from "@/utils/theme"; interface ThemeSelectProps { value?: string; @@ -8,16 +9,16 @@ interface ThemeSelectProps { className?: string; } +const THEME_ICONS: Record = { + default: , + "default-dark": , + paper: , + whitewall: , +}; + const ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {}) => { const currentTheme = value || workspaceStore.state.theme || "default"; - const themeOptions: { value: Theme; icon: JSX.Element; label: string }[] = [ - { value: "default", icon: , label: "Default Light" }, - { value: "default-dark", icon: , label: "Default Dark" }, - { value: "paper", icon: , label: "Paper" }, - { value: "whitewall", icon: , label: "Whitewall" }, - ]; - const handleThemeChange = (newTheme: Theme) => { if (onValueChange) { onValueChange(newTheme); @@ -34,10 +35,10 @@ const ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {}) - {themeOptions.map((option) => ( + {THEME_OPTIONS.map((option) => (
- {option.icon} + {THEME_ICONS[option.value]} {option.label}
diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx index 4eacbaf9d..a6de0b15f 100644 --- a/web/src/components/UserMenu.tsx +++ b/web/src/components/UserMenu.tsx @@ -1,22 +1,58 @@ -import { ArchiveIcon, LogOutIcon, User2Icon, SquareUserIcon, SettingsIcon, BellIcon } from "lucide-react"; +import { + ArchiveIcon, + LogOutIcon, + User2Icon, + SquareUserIcon, + SettingsIcon, + BellIcon, + GlobeIcon, + PaletteIcon, + CheckIcon, +} from "lucide-react"; +import { observer } from "mobx-react-lite"; import { authServiceClient } from "@/grpcweb"; import useCurrentUser from "@/hooks/useCurrentUser"; import useNavigateTo from "@/hooks/useNavigateTo"; +import { locales } from "@/i18n"; import { cn } from "@/lib/utils"; import { Routes } from "@/router"; -import { useTranslate } from "@/utils/i18n"; +import { userStore, workspaceStore } from "@/store"; +import { getLocaleDisplayName, useTranslate } from "@/utils/i18n"; +import { THEME_OPTIONS } from "@/utils/theme"; import UserAvatar from "./UserAvatar"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; interface Props { collapsed?: boolean; } -const UserMenu = (props: Props) => { +const UserMenu = observer((props: Props) => { const { collapsed } = props; const t = useTranslate(); const navigateTo = useNavigateTo(); const currentUser = useCurrentUser(); + const generalSetting = userStore.state.userGeneralSetting; + const currentLocale = generalSetting?.locale || "en"; + const currentTheme = generalSetting?.theme || "default"; + + const handleLocaleChange = async (locale: Locale) => { + // Update workspace store immediately for instant UI feedback + workspaceStore.state.setPartial({ locale }); + // Persist to user settings + await userStore.updateUserGeneralSetting({ locale }, ["locale"]); + }; + + const handleThemeChange = async (theme: string) => { + await userStore.updateUserGeneralSetting({ theme }, ["theme"]); + }; const handleSignOut = async () => { await authServiceClient.deleteSession({}); @@ -67,6 +103,36 @@ const UserMenu = (props: Props) => { {t("common.inbox")} + + + + {t("common.language")} + + + {locales.map((locale) => ( + handleLocaleChange(locale)}> + {currentLocale === locale && } + {currentLocale !== locale && } + {getLocaleDisplayName(locale)} + + ))} + + + + + + {t("setting.preference-section.theme")} + + + {THEME_OPTIONS.map((option) => ( + handleThemeChange(option.value)}> + {currentTheme === option.value && } + {currentTheme !== option.value && } + {option.label} + + ))} + + navigateTo(Routes.SETTING)}> {t("common.settings")} @@ -78,6 +144,6 @@ const UserMenu = (props: Props) => { ); -}; +}); export default UserMenu; diff --git a/web/src/utils/i18n.ts b/web/src/utils/i18n.ts index 7d6a33c53..99ccb84d4 100644 --- a/web/src/utils/i18n.ts +++ b/web/src/utils/i18n.ts @@ -51,3 +51,20 @@ export const isValidateLocale = (locale: string | undefined | null): boolean => if (!locale) return false; return locales.includes(locale); }; + +/** + * Get the display name for a locale in its native language + * @param locale - The locale code (e.g., "en", "zh-Hans", "fr") + * @returns The display name with capitalized first letter, or the locale code if display name is unavailable + */ +export const getLocaleDisplayName = (locale: string): string => { + try { + const displayName = new Intl.DisplayNames([locale], { type: "language" }).of(locale); + if (displayName) { + return displayName.charAt(0).toUpperCase() + displayName.slice(1); + } + } catch { + // Intl.DisplayNames might not be available or might fail for some locales + } + return locale; +}; diff --git a/web/src/utils/theme.ts b/web/src/utils/theme.ts index 83a8ffac7..7957cb558 100644 --- a/web/src/utils/theme.ts +++ b/web/src/utils/theme.ts @@ -12,6 +12,18 @@ const THEME_CONTENT: Record = { whitewall: whitewallThemeContent, }; +export interface ThemeOption { + value: string; + label: string; +} + +export const THEME_OPTIONS: ThemeOption[] = [ + { value: "default", label: "Default Light" }, + { value: "default-dark", label: "Default Dark" }, + { value: "paper", label: "Paper" }, + { value: "whitewall", label: "Whitewall" }, +]; + const validateTheme = (theme: string): ValidTheme => { return VALID_THEMES.includes(theme as ValidTheme) ? (theme as ValidTheme) : "default"; };