mirror of
https://github.com/usememos/memos.git
synced 2025-12-16 05:43:06 +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 { FC } from "react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { locales } from "@/i18n";
|
import { locales } from "@/i18n";
|
||||||
|
import { getLocaleDisplayName } from "@/utils/i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: Locale;
|
value: Locale;
|
||||||
|
|
@ -24,26 +25,11 @@ const LocaleSelect: FC<Props> = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{locales.map((locale) => {
|
{locales.map((locale) => (
|
||||||
try {
|
<SelectItem key={locale} value={locale}>
|
||||||
const languageName = new Intl.DisplayNames([locale], { type: "language" }).of(locale);
|
{getLocaleDisplayName(locale)}
|
||||||
if (languageName) {
|
</SelectItem>
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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 { Visibility } from "@/types/proto/api/v1/memo_service";
|
||||||
import { UserSetting_GeneralSetting } from "@/types/proto/api/v1/user_service";
|
import { UserSetting_GeneralSetting } from "@/types/proto/api/v1/user_service";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
|
@ -16,6 +16,9 @@ const PreferencesSection = observer(() => {
|
||||||
const generalSetting = userStore.state.userGeneralSetting;
|
const generalSetting = userStore.state.userGeneralSetting;
|
||||||
|
|
||||||
const handleLocaleSelectChange = async (locale: Locale) => {
|
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"]);
|
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Moon, Palette, Sun, Wallpaper } from "lucide-react";
|
import { Moon, Palette, Sun, Wallpaper } from "lucide-react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { workspaceStore } from "@/store";
|
import { workspaceStore } from "@/store";
|
||||||
|
import { THEME_OPTIONS } from "@/utils/theme";
|
||||||
|
|
||||||
interface ThemeSelectProps {
|
interface ThemeSelectProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
|
|
@ -8,16 +9,16 @@ interface ThemeSelectProps {
|
||||||
className?: string;
|
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 ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {}) => {
|
||||||
const currentTheme = value || workspaceStore.state.theme || "default";
|
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) => {
|
const handleThemeChange = (newTheme: Theme) => {
|
||||||
if (onValueChange) {
|
if (onValueChange) {
|
||||||
onValueChange(newTheme);
|
onValueChange(newTheme);
|
||||||
|
|
@ -34,10 +35,10 @@ const ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {})
|
||||||
</div>
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{themeOptions.map((option) => (
|
{THEME_OPTIONS.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{option.icon}
|
{THEME_ICONS[option.value]}
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</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 { authServiceClient } from "@/grpcweb";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
|
import { locales } from "@/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Routes } from "@/router";
|
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 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 {
|
interface Props {
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserMenu = (props: Props) => {
|
const UserMenu = observer((props: Props) => {
|
||||||
const { collapsed } = props;
|
const { collapsed } = props;
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const navigateTo = useNavigateTo();
|
const navigateTo = useNavigateTo();
|
||||||
const currentUser = useCurrentUser();
|
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 () => {
|
const handleSignOut = async () => {
|
||||||
await authServiceClient.deleteSession({});
|
await authServiceClient.deleteSession({});
|
||||||
|
|
@ -67,6 +103,36 @@ const UserMenu = (props: Props) => {
|
||||||
<BellIcon className="size-4 text-muted-foreground" />
|
<BellIcon className="size-4 text-muted-foreground" />
|
||||||
{t("common.inbox")}
|
{t("common.inbox")}
|
||||||
</DropdownMenuItem>
|
</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)}>
|
<DropdownMenuItem onClick={() => navigateTo(Routes.SETTING)}>
|
||||||
<SettingsIcon className="size-4 text-muted-foreground" />
|
<SettingsIcon className="size-4 text-muted-foreground" />
|
||||||
{t("common.settings")}
|
{t("common.settings")}
|
||||||
|
|
@ -78,6 +144,6 @@ const UserMenu = (props: Props) => {
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default UserMenu;
|
export default UserMenu;
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,20 @@ export const isValidateLocale = (locale: string | undefined | null): boolean =>
|
||||||
if (!locale) return false;
|
if (!locale) return false;
|
||||||
return locales.includes(locale);
|
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,
|
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 => {
|
const validateTheme = (theme: string): ValidTheme => {
|
||||||
return VALID_THEMES.includes(theme as ValidTheme) ? (theme as ValidTheme) : "default";
|
return VALID_THEMES.includes(theme as ValidTheme) ? (theme as ValidTheme) : "default";
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue