feat: language selector (#484)
* feat: language selector * refactor: move user general page * feat: language selector * refactor: move user general page * feat: add language combobox in user general
This commit is contained in:
@@ -47,6 +47,7 @@
|
|||||||
"chroma-js": "^2.4.2",
|
"chroma-js": "^2.4.2",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
"flag-icons": "^7.2.1",
|
||||||
"glob": "^10.3.15",
|
"glob": "^10.3.15",
|
||||||
"jotai": "^2.8.0",
|
"jotai": "^2.8.0",
|
||||||
"next": "^14.2.3",
|
"next": "^14.2.3",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { LanguageCombobox } from "~/components/language/language-combobox";
|
||||||
|
|
||||||
|
export const ProfileLanguageChange = () => {
|
||||||
|
return (
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>Language & Region</Title>
|
||||||
|
<LanguageCombobox />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,10 +10,11 @@ import {
|
|||||||
DangerZoneRoot,
|
DangerZoneRoot,
|
||||||
} from "~/components/manage/danger-zone";
|
} from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||||
import { DeleteUserButton } from "./_delete-user-button";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { UserProfileAvatarForm } from "./_profile-avatar-form";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { UserProfileForm } from "./_profile-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
import { canAccessUserEditPage } from "./access";
|
import { UserProfileForm } from "./_components/_profile-form";
|
||||||
|
import { ProfileLanguageChange } from "./_components/_profile-language-change";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: {
|
params: {
|
||||||
@@ -67,6 +68,8 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<ProfileLanguageChange />
|
||||||
|
|
||||||
<DangerZoneRoot>
|
<DangerZoneRoot>
|
||||||
<DangerZoneItem
|
<DangerZoneItem
|
||||||
label={t("user.action.delete.label")}
|
label={t("user.action.delete.label")}
|
||||||
@@ -69,7 +69,7 @@ export default async function Layout({
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<NavigationLink
|
<NavigationLink
|
||||||
href={`/manage/users/${params.userId}`}
|
href={`/manage/users/${params.userId}/general`}
|
||||||
label={tUser("setting.general.title")}
|
label={tUser("setting.general.title")}
|
||||||
icon={<IconSettings size="1rem" stroke={1.5} />}
|
icon={<IconSettings size="1rem" stroke={1.5} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { ChangePasswordForm } from "./_change-password-form";
|
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const UserListComponent = ({
|
|||||||
header: t("user.field.username.label"),
|
header: t("user.field.username.label"),
|
||||||
grow: 100,
|
grow: 100,
|
||||||
Cell: ({ renderedCellValue, row }) => (
|
Cell: ({ renderedCellValue, row }) => (
|
||||||
<Link href={`/manage/users/${row.original.id}`}>
|
<Link href={`/manage/users/${row.original.id}/general`}>
|
||||||
<Group>
|
<Group>
|
||||||
<Avatar size="sm"></Avatar>
|
<Avatar size="sm"></Avatar>
|
||||||
{renderedCellValue}
|
{renderedCellValue}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.flagIcon {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
94
apps/nextjs/src/components/language/language-combobox.tsx
Normal file
94
apps/nextjs/src/components/language/language-combobox.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
|
||||||
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
|
import { localeAttributes, supportedLanguages } from "@homarr/translation";
|
||||||
|
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import classes from "./language-combobox.module.css";
|
||||||
|
|
||||||
|
export const LanguageCombobox = () => {
|
||||||
|
const combobox = useCombobox({
|
||||||
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
|
});
|
||||||
|
const currentLocale = useCurrentLocale();
|
||||||
|
const changeLocale = useChangeLocale();
|
||||||
|
|
||||||
|
const handleOnOptionSubmit = React.useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changeLocale(value as SupportedLanguage);
|
||||||
|
combobox.closeDropdown();
|
||||||
|
},
|
||||||
|
[changeLocale, combobox],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOnClick = React.useCallback(() => {
|
||||||
|
combobox.toggleDropdown();
|
||||||
|
}, [combobox]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
|
||||||
|
<Combobox.Target>
|
||||||
|
<InputBase
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
pointer
|
||||||
|
rightSection={<Combobox.Chevron />}
|
||||||
|
rightSectionPointerEvents="none"
|
||||||
|
onClick={handleOnClick}
|
||||||
|
variant="filled"
|
||||||
|
>
|
||||||
|
<OptionItem currentLocale={currentLocale} localeKey={currentLocale} />
|
||||||
|
</InputBase>
|
||||||
|
</Combobox.Target>
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<Combobox.Options>
|
||||||
|
{supportedLanguages.map((languageKey) => (
|
||||||
|
<Combobox.Option value={languageKey} key={languageKey}>
|
||||||
|
<OptionItem
|
||||||
|
currentLocale={currentLocale}
|
||||||
|
localeKey={languageKey}
|
||||||
|
showCheck
|
||||||
|
/>
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OptionItem = ({
|
||||||
|
currentLocale,
|
||||||
|
localeKey,
|
||||||
|
showCheck,
|
||||||
|
}: {
|
||||||
|
currentLocale: SupportedLanguage;
|
||||||
|
localeKey: SupportedLanguage;
|
||||||
|
showCheck?: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Group wrap="nowrap" justify="space-between">
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
<span
|
||||||
|
className={`fi fi-${localeAttributes[localeKey].flagIcon} ${classes.flagIcon}`}
|
||||||
|
></span>
|
||||||
|
<Group wrap="nowrap" gap="xs">
|
||||||
|
<Text>{localeAttributes[localeKey].name}</Text>
|
||||||
|
<Text size="xs" c="dimmed" inherit>
|
||||||
|
({localeAttributes[localeKey].translatedName})
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
{showCheck && localeKey === currentLocale && (
|
||||||
|
<IconCheck color="currentColor" size={16} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,6 +27,10 @@ import { signOut, useSession } from "@homarr/auth/client";
|
|||||||
import { createModal, useModalAction } from "@homarr/modals";
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import "flag-icons/css/flag-icons.min.css";
|
||||||
|
|
||||||
|
import { LanguageCombobox } from "./language/language-combobox";
|
||||||
|
|
||||||
interface UserAvatarMenuProps {
|
interface UserAvatarMenuProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -57,7 +61,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
|||||||
}, [openModal, router]);
|
}, [openModal, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu width={200} withArrow withinPortal>
|
<Menu width={300} withArrow withinPortal>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={toggleColorScheme}
|
onClick={toggleColorScheme}
|
||||||
@@ -72,23 +76,32 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
|||||||
>
|
>
|
||||||
{t("navigateDefaultBoard")}
|
{t("navigateDefaultBoard")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{Boolean(session.data) && (
|
<Menu.Divider />
|
||||||
<Menu.Item
|
|
||||||
component={Link}
|
<Menu.Item p={0} closeMenuOnClick={false}>
|
||||||
href={`/manage/users/${session.data?.user.id}`}
|
<LanguageCombobox />
|
||||||
leftSection={<IconSettings size="1rem" />}
|
|
||||||
>
|
|
||||||
{t("preferences")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
<Menu.Item
|
|
||||||
component={Link}
|
|
||||||
href="/manage"
|
|
||||||
leftSection={<IconTool size="1rem" />}
|
|
||||||
>
|
|
||||||
{t("management")}
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
{Boolean(session.data) && (
|
||||||
|
<>
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
href={`/manage/users/${session.data?.user.id}`}
|
||||||
|
leftSection={<IconSettings size="1rem" />}
|
||||||
|
>
|
||||||
|
{t("preferences")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
href="/manage"
|
||||||
|
leftSection={<IconTool size="1rem" />}
|
||||||
|
>
|
||||||
|
{t("management")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Menu.Divider />
|
||||||
{session.status === "authenticated" ? (
|
{session.status === "authenticated" ? (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={handleSignout}
|
onClick={handleSignout}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import { createI18nClient } from "next-international/client";
|
|||||||
import { languageMapping } from "./lang";
|
import { languageMapping } from "./lang";
|
||||||
import enTranslation from "./lang/en";
|
import enTranslation from "./lang/en";
|
||||||
|
|
||||||
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient(
|
export const {
|
||||||
languageMapping(),
|
useI18n,
|
||||||
{
|
useScopedI18n,
|
||||||
fallbackLocale: enTranslation,
|
useCurrentLocale,
|
||||||
},
|
useChangeLocale,
|
||||||
);
|
I18nProviderClient,
|
||||||
|
} = createI18nClient(languageMapping(), {
|
||||||
|
fallbackLocale: enTranslation,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { stringOrTranslation, TranslationFunction } from "./type";
|
import type { stringOrTranslation, TranslationFunction } from "./type";
|
||||||
|
|
||||||
export * from "./type";
|
export * from "./type";
|
||||||
|
export * from "./locale-attributes";
|
||||||
|
|
||||||
export const supportedLanguages = ["en", "de"] as const;
|
export const supportedLanguages = ["en", "de"] as const;
|
||||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||||
|
|||||||
21
packages/translation/src/locale-attributes.ts
Normal file
21
packages/translation/src/locale-attributes.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SupportedLanguage } from ".";
|
||||||
|
|
||||||
|
export const localeAttributes: Record<
|
||||||
|
SupportedLanguage,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
translatedName: string;
|
||||||
|
flagIcon: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
de: {
|
||||||
|
name: "Deutsch",
|
||||||
|
translatedName: "German",
|
||||||
|
flagIcon: "de",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
name: "English",
|
||||||
|
translatedName: "English",
|
||||||
|
flagIcon: "us",
|
||||||
|
},
|
||||||
|
};
|
||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -159,6 +159,9 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.4.5
|
specifier: ^16.4.5
|
||||||
version: 16.4.5
|
version: 16.4.5
|
||||||
|
flag-icons:
|
||||||
|
specifier: ^7.2.1
|
||||||
|
version: 7.2.1
|
||||||
glob:
|
glob:
|
||||||
specifier: ^10.3.15
|
specifier: ^10.3.15
|
||||||
version: 10.3.15
|
version: 10.3.15
|
||||||
@@ -3423,6 +3426,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
flag-icons@7.2.1:
|
||||||
|
resolution: {integrity: sha512-EaU4XZmFt1BOilz9nMmJKjma5pOaNjzL7somOhadrrilollh4xj6aaXI2M1sd00VUfVWN0E25Q6xaW3SNt0k/Q==}
|
||||||
|
|
||||||
flat-cache@3.2.0:
|
flat-cache@3.2.0:
|
||||||
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
|
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
|
||||||
engines: {node: ^10.12.0 || >=12.0.0}
|
engines: {node: ^10.12.0 || >=12.0.0}
|
||||||
@@ -8283,6 +8289,8 @@ snapshots:
|
|||||||
locate-path: 6.0.0
|
locate-path: 6.0.0
|
||||||
path-exists: 4.0.0
|
path-exists: 4.0.0
|
||||||
|
|
||||||
|
flag-icons@7.2.1: {}
|
||||||
|
|
||||||
flat-cache@3.2.0:
|
flat-cache@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
flatted: 3.2.9
|
flatted: 3.2.9
|
||||||
|
|||||||
Reference in New Issue
Block a user