mirror of
https://github.com/usememos/memos.git
synced 2025-12-15 21:29:52 +08:00
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>
This commit is contained in:
parent
d693142dd4
commit
2371bbb1b7
6 changed files with 120 additions and 35 deletions
|
|
@ -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…
Add table
Reference in a new issue