feat(web): add quick language and theme switchers to user menu

- Add language and theme selector submenus to UserMenu component for quick access
- Refactor shared utilities: extract THEME_OPTIONS constant and getLocaleDisplayName() function
- Update LocaleSelect and ThemeSelect to use shared utilities, eliminating code duplication
- Make UserMenu reactive with MobX observer for real-time setting updates
- Fix language switching reactivity by immediately updating workspaceStore.state.locale
- Add scrollable menu support for language selector (max-h-[90vh])
- Apply same instant locale update to PreferencesSection for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
pull/5203/head
Steven 7 days ago
parent d693142dd4
commit 2371bbb1b7

@ -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: Props) => {
</div>
</SelectTrigger>
<SelectContent>
{locales.map((locale) => {
try {
const languageName = new Intl.DisplayNames([locale], { type: "language" }).of(locale);
if (languageName) {
return (
<SelectItem key={locale} value={locale}>
{languageName.charAt(0).toUpperCase() + languageName.slice(1)}
</SelectItem>
);
}
} catch {
// do nth
}
return (
<SelectItem key={locale} value={locale}>
{locale}
</SelectItem>
);
})}
{locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{getLocaleDisplayName(locale)}
</SelectItem>
))}
</SelectContent>
</Select>
);

@ -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"]);
};

@ -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<string, JSX.Element> = {
default: <Sun className="w-4 h-4" />,
"default-dark": <Moon className="w-4 h-4" />,
paper: <Palette className="w-4 h-4" />,
whitewall: <Wallpaper className="w-4 h-4" />,
};
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: <Sun className="w-4 h-4" />, label: "Default Light" },
{ value: "default-dark", icon: <Moon className="w-4 h-4" />, label: "Default Dark" },
{ value: "paper", icon: <Palette className="w-4 h-4" />, label: "Paper" },
{ value: "whitewall", icon: <Wallpaper className="w-4 h-4" />, label: "Whitewall" },
];
const handleThemeChange = (newTheme: Theme) => {
if (onValueChange) {
onValueChange(newTheme);
@ -34,10 +35,10 @@ const ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {})
</div>
</SelectTrigger>
<SelectContent>
{themeOptions.map((option) => (
{THEME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
{option.icon}
{THEME_ICONS[option.value]}
<span>{option.label}</span>
</div>
</SelectItem>

@ -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) => {
<BellIcon className="size-4 text-muted-foreground" />
{t("common.inbox")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<GlobeIcon className="size-4 text-muted-foreground" />
{t("common.language")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[90vh] overflow-y-auto">
{locales.map((locale) => (
<DropdownMenuItem key={locale} onClick={() => handleLocaleChange(locale)}>
{currentLocale === locale && <CheckIcon className="w-4 h-auto mr-2" />}
{currentLocale !== locale && <span className="w-4 mr-2" />}
{getLocaleDisplayName(locale)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PaletteIcon className="size-4 text-muted-foreground" />
{t("setting.preference-section.theme")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{THEME_OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => handleThemeChange(option.value)}>
{currentTheme === option.value && <CheckIcon className="w-4 h-auto mr-2" />}
{currentTheme !== option.value && <span className="w-4 mr-2" />}
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem onClick={() => navigateTo(Routes.SETTING)}>
<SettingsIcon className="size-4 text-muted-foreground" />
{t("common.settings")}
@ -78,6 +144,6 @@ const UserMenu = (props: Props) => {
</DropdownMenuContent>
</DropdownMenu>
);
};
});
export default UserMenu;

@ -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;
};

@ -12,6 +12,18 @@ const THEME_CONTENT: Record<ValidTheme, string | null> = {
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";
};

Loading…
Cancel
Save