refactor: consolidate English translations (#6640)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-21 19:00:27 +02:00
1 parent 81aa704537
commit 9e14a07e64
449 files changed
+3515 -3922

No files matched your search

@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { userRegistrationSchema } from "@homarr/validation/user";
@@ -19,7 +19,7 @@ interface RegistrationFormProps {
}
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
const t = useScopedI18n("user");
const t = useI18n("user");
const router = useRouter();
const { mutate, isPending } = clientApi.user.register.useMutation();
const form = useZodForm(userRegistrationSchema, {
@@ -6,7 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { RegistrationForm } from "./_registration-form";
@@ -45,7 +45,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
if (!invite || invite.expirationDate < new Date()) notFound();
const t = await getScopedI18n("user.page.invite");
const t = await getI18n("user.page.invite");
return (
<OnboardingAuthShell
@@ -12,7 +12,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { userSignInSchema } from "@homarr/validation/user";
@@ -28,7 +28,7 @@ interface LoginFormProps {
const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) });
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useScopedI18n("user");
const t = useI18n("user");
const searchParams = useSearchParams();
const isError = searchParams.has("error");
const router = useRouter();
@@ -196,7 +196,7 @@ interface PasswordForgottenCollapseProps {
}
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
const [visible, { toggle }] = useDisclosure(false);
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
const tForgotPassword = useI18n("user.action.login.forgotPassword");
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
@@ -5,7 +5,7 @@ import { IconLogin } from "@tabler/icons-react";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
@@ -26,7 +26,7 @@ export default async function Login(props: LoginProps) {
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
}
const t = await getScopedI18n("user.page.login");
const t = await getI18n("user.page.login");
return (
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
@@ -1,11 +1,11 @@
import { IconLayoutOff } from "@tabler/icons-react";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { BoardNotFound } from "~/components/board/not-found";
export default async function BoardNotFoundPage() {
const tNotFound = await getScopedI18n("board.error.notFound");
const tNotFound = await getI18n("board.error.notFound");
return (
<BoardNotFound
icon={IconLayoutOff}
@@ -6,7 +6,7 @@ import { IconBulb } from "@tabler/icons-react";
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
@@ -33,9 +33,9 @@ const APP_SHELL_INLINE_PADDING = 32;
export const ClientBoard = () => {
const board = useRequiredBoard();
const t = useScopedI18n("board.landmark");
const tPreview = useScopedI18n("board.setting.section.layout.preview");
const tTips = useScopedI18n("tips");
const t = useI18n("board.landmark");
const tPreview = useI18n("board.setting.section.layout.preview");
const tTips = useI18n("tips");
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth();
@@ -104,10 +104,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try {
const board = await getInitialBoard(await params);
const t = await getI18n();
const t = await getI18n("board");
return {
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
icons: {
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
@@ -29,7 +29,9 @@ const AddMenu = () => {
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const t = useI18n();
const tBoard = useI18n("board");
const tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
@@ -77,7 +79,7 @@ const AddMenu = () => {
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
<HeaderButton w="auto" px={4} aria-label={tBoard("action.addContent")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
@@ -86,19 +88,19 @@ const AddMenu = () => {
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{t("item.action.create")}
{tBoard("emptyState.addWidget")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{t("app.action.add")}
{tBoard("emptyState.addApp")}
</Menu.Item>
{session?.user.permissions.includes("integration-create") && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
{tIntegration("action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{t("section.container.action.create")}
{tContainer("action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
@@ -24,7 +24,7 @@ import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -53,7 +53,7 @@ const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const t = useI18n();
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
@@ -68,7 +68,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
@@ -88,8 +88,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal();
const commonT = useI18n();
const t = useScopedI18n("board.action.edit");
const tCommon = useI18n("common");
const tBoardSetting = useI18n("board.setting");
const t = useI18n("board.action.edit");
const returnToLayoutSettings =
isEditMode &&
searchParams.get("edit") === "true" &&
@@ -104,7 +105,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
},
onSuccess() {
showSuccessNotification({
title: t("notification.success.title"),
title: tCommon("notification.update.success"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
@@ -115,7 +116,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
},
onError() {
showErrorNotification({
title: t("notification.error.title"),
title: tCommon("notification.update.error"),
message: t("notification.error.message"),
});
},
@@ -178,9 +179,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const cancelLayoutEdit = useCallback(() => {
openConfirmModal({
title: commonT("board.setting.section.layout.edit.cancel.title"),
children: commonT("board.setting.section.layout.edit.cancel.message"),
confirmProps: { children: commonT("common.action.discard"), color: "red" },
title: tBoardSetting("section.layout.edit.cancel.title"),
children: tBoardSetting("section.layout.edit.cancel.message"),
confirmProps: { children: tCommon("action.discard"), color: "red" },
onConfirm() {
void (async () => {
await utils.board.getBoardByName.invalidate({ name: board.name });
@@ -189,7 +190,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
})();
},
});
}, [board.name, close, commonT, openConfirmModal, router, utils.board.getBoardByName]);
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode);
@@ -200,14 +201,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
<HeaderButton
onClick={() => saveBoard(board)}
loading={isPending}
aria-label={commonT("board.setting.section.layout.edit.save")}
aria-label={tBoardSetting("section.layout.edit.save")}
>
<IconDeviceFloppy stroke={1.5} />
</HeaderButton>
<HeaderButton
onClick={cancelLayoutEdit}
disabled={isPending}
aria-label={commonT("board.setting.section.layout.edit.cancel.action")}
aria-label={tBoardSetting("section.layout.edit.cancel.action")}
>
<IconX stroke={1.5} />
</HeaderButton>
@@ -235,7 +236,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
};
const SelectBoardsMenu = () => {
const t = useI18n();
const t = useI18n("board");
const [isOpen, setIsOpen] = useState(false);
const utils = clientApi.useUtils();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
@@ -246,7 +247,7 @@ const SelectBoardsMenu = () => {
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
<HeaderButton w="auto" px={4} aria-label={t("action.switch")}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
@@ -277,7 +278,8 @@ const SelectBoardsMenu = () => {
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
@@ -293,13 +295,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.stopPropagation();
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
title: tBoard("action.edit.confirmLeave.title"),
children: tBoard("action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: t("common.action.discard"),
children: tCommon("action.discard"),
},
});
};
@@ -325,5 +327,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isDirty, openConfirmModal, router, t]);
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
};
@@ -16,15 +16,15 @@ export default async function NotFoundBoardHomePage() {
const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const boardCount = await db.$count(boards);
const t = await getI18n();
const t = await getI18n("board");
if (boardCount === 0) {
return {
icon: { src: "/favicon.ico", alt: "Homarr logo" },
title: t("board.error.noBoard.title"),
description: t("board.error.noBoard.description"),
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
notice: t("board.error.noBoard.notice"),
title: t("error.noBoard.title"),
description: t("error.noBoard.description"),
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
notice: t("error.noBoard.notice"),
};
}
@@ -39,9 +39,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
return {
icon: IconHomeOff,
title: t(`board.error.homeBoard.title`),
description: t(`board.error.homeBoard.${type}.description`),
link: { label: t(`board.error.homeBoard.${type}.link`), href },
notice: t(`board.error.homeBoard.${type}.notice`),
title: t("error.homeBoard.title"),
description: t(`error.homeBoard.${type}.description` as never),
link: { label: t(`error.homeBoard.${type}.link` as never), href },
notice: t(`error.homeBoard.${type}.notice` as never),
};
};
@@ -37,22 +37,23 @@ const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const theme = useMantineTheme();
return (
<SectionCard title={t("board.setting.section.appearance.title")}>
<SectionCard title={tBoard("setting.section.appearance.title")}>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<BoardColorInput label={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<BoardColorInput label={t("board.field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
<Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
@@ -63,7 +64,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Collapse>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}>
<InputWrapper label={tBoard("field.opacity.label")}>
<Slider
my={6}
min={0}
@@ -77,7 +78,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput
label={t("board.field.iconColor.label")}
label={tBoard("field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
@@ -90,20 +91,20 @@ export const ColorSettingsContent = ({ form }: Props) => {
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
{tBoard("field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
label={tBoard("field.itemRadius.label")}
description={tBoard("field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
{ label: tBoard("field.itemRadius.option.xs"), value: "xs" },
{ label: tBoard("field.itemRadius.option.sm"), value: "sm" },
{ label: tBoard("field.itemRadius.option.md"), value: "md" },
{ label: tBoard("field.itemRadius.option.lg"), value: "lg" },
{ label: tBoard("field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
@@ -23,7 +23,7 @@ interface Props {
}
export const BackgroundSettingsContent = ({ form }: Props) => {
const t = useI18n();
const tBoard = useI18n("board");
const { data: session } = useSession();
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
@@ -44,7 +44,7 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
return (
<SectionCard title={t("board.setting.section.background.title")}>
<SectionCard title={tBoard("setting.section.background.title")}>
<Grid>
<Grid.Col span={12}>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
@@ -67,8 +67,8 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}
// We filter it on the server
filter={({ options }) => options}
label={t("board.field.backgroundImageUrl.label")}
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
label={tBoard("field.backgroundImageUrl.label")}
placeholder={`${tBoard("field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => {
const current = imageMap.get(option.value);
if (!current) return null;
@@ -87,13 +87,13 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}}
data={[
{
group: t("board.field.backgroundImageUrl.group.your"),
group: tBoard("field.backgroundImageUrl.group.your"),
items: images
.filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
{
group: t("board.field.backgroundImageUrl.group.other"),
group: tBoard("field.backgroundImageUrl.group.other"),
items: images
.filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
@@ -123,21 +123,21 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")}
label={tBoard("field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")}
label={tBoard("field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")}
label={tBoard("field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
@@ -176,19 +176,20 @@ const useBackgroundOptionData = <
defaultValue: keyof TOptions;
},
) => {
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
return data.values.map(
(value) =>
({
label: t(`board.field.${key}.option.${value as string}.label` as never),
description: t(`board.field.${key}.option.${value as string}.description` as never),
label: tBoard(`field.${key}.option.${value as string}.label` as never),
description: tBoard(`field.${key}.option.${value as string}.description` as never),
value: value as string,
badge:
data.defaultValue === value
? {
color: "blue",
label: t("common.select.badge.recommended"),
label: tCommon("select.badge.recommended"),
}
: undefined,
}) satisfies SelectItemWithDescriptionBadge,
@@ -13,13 +13,13 @@ interface Props {
}
export const BehaviorSettingsContent = ({ form }: Props) => {
const t = useI18n();
const t = useI18n("board");
return (
<SectionCard title={t("board.setting.section.behavior.title")}>
<SectionCard title={t("setting.section.behavior.title")}>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
label={t("field.disableStatus.label")}
description={t("field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
</SectionCard>
@@ -19,7 +19,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils();
const t = useI18n();
const tBoard = useI18n("board");
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{
@@ -52,7 +52,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
mutate: userMutation.mutate,
isPending: userMutation.isPending,
}}
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
translate={(key) => tBoard(`setting.section.access.permission.item.${key}.label` as never)}
permission={{
items: boardPermissions,
default: "view",
@@ -10,7 +10,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
@@ -24,11 +24,11 @@ interface Props {
export const CustomCssSettingsContent = ({ form }: Props) => {
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const t = useI18n("board");
const customCssT = useI18n("board.field.customCss");
return (
<SectionCard title={t("board.setting.section.customCss.title")}>
<SectionCard title={t("setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
@@ -46,7 +46,7 @@ interface CustomCssInputProps {
}
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss");
const customCssT = useI18n("board.field.customCss");
return (
<Input.Wrapper
@@ -7,14 +7,15 @@ import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
const board = useRequiredBoard();
const t = useScopedI18n("board.setting");
const t = useI18n("board.setting");
const tCommonAction = useI18n("common.action");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal);
@@ -104,11 +105,11 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
/>
)}
<DangerZoneItem
label={t("section.dangerZone.action.delete.label")}
label={tCommonAction("delete")}
description={t("section.dangerZone.action.delete.description")}
action={
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
{t("section.dangerZone.action.delete.button")}
{tCommonAction("delete")}
</Button>
}
/>
@@ -20,25 +20,21 @@ interface Props {
}
export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n();
const t = useI18n("board");
useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
return (
<SectionCard title={t("board.setting.section.general.title")}>
<SectionCard title={t("setting.section.general.title")}>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.pageTitle.label")}
placeholder="Homarr"
{...form.getInputProps("pageTitle")}
/>
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
label={t("field.metaTitle.label")}
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")}
/>
@@ -46,7 +42,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("logoImageUrl")}
label={t("board.field.logoImageUrl.label")}
label={t("field.logoImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
@@ -54,7 +50,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("board.field.faviconImageUrl.label")}
label={t("field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
@@ -6,7 +6,7 @@ import { IconLayoutGrid } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
@@ -23,6 +23,8 @@ interface Props {
}
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
const tBoard = useI18n("board");
const tSection = useI18n("section");
const t = useI18n();
const elements = projectBoardLayout(board, sourceLayout, layout);
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
@@ -42,13 +44,13 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
{representativeWidth}px
</Badge>
<Text size="xs">
{layout.columnCount} {t("board.setting.section.layout.preview.columns")}
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
</Text>
</Group>
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
{elements.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="xl">
{t("board.setting.section.layout.preview.empty")}
{tBoard("setting.section.layout.preview.empty")}
</Text>
) : (
<Stack gap="xs">
@@ -76,8 +78,8 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
const containerLabel =
container?.kind === "container" && container.options.title
? container.options.title
: t("section.container.untitled");
const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : containerLabel);
: tSection("container.untitled");
const label = app?.name ?? (item ? getWidgetName(item.kind, t) : containerLabel);
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
return (
@@ -41,7 +41,9 @@ interface Props {
}
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const t = useI18n();
const tBoard = useI18n("board");
const tLayout = useI18n("layout");
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
@@ -50,8 +52,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
onError() {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
title: tCommon("notification.update.error"),
message: tCommon("notification.update.error"),
});
},
});
@@ -84,9 +86,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const confirmReset = (layout: FormValues["layouts"][number]) => {
openConfirmModal({
title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
children: t("board.setting.section.layout.reset.confirm.message"),
confirmProps: { children: t("board.setting.section.layout.reset.action"), color: "red" },
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
children: tBoard("setting.section.layout.reset.confirm.message"),
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
onConfirm() {
setResettingLayoutId(layout.id);
void (async () => {
@@ -115,12 +117,12 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
return (
<SectionCard title={t("board.setting.section.layout.title")}>
<SectionCard title={tBoard("setting.section.layout.title")}>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2} maw="52rem">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<Text size="sm">{t("board.setting.section.layout.responsive.description")}</Text>
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
<Text size="sm">{tBoard("setting.section.layout.responsive.description")}</Text>
</Stack>
<Button
type="button"
@@ -132,7 +134,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
...form.values.layouts,
{
id: createId(),
name: t("board.setting.section.layout.custom.defaultName"),
name: tBoard("setting.section.layout.custom.defaultName"),
columnCount: baseLayout.columnCount,
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
@@ -142,7 +144,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
]);
}}
>
{t("board.setting.section.layout.responsive.action.add")}
{tBoard("setting.section.layout.responsive.action.add")}
</Button>
</Group>
@@ -159,9 +161,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
key={layout.id}
legend={
<Group gap="xs">
<Text>{layout.name || t(`board.setting.section.layout.role.${layout.role}`)}</Text>
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
<Badge size="sm" variant="default">
{t(`board.setting.section.layout.role.${layout.role}`)}
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
</Badge>
</Group>
}
@@ -170,10 +172,10 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
<Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
<Input.Wrapper label={tLayout("field.columnCount.label")}>
<Slider
thumbLabel={`${t("layout.field.columnCount.label")} — ${layout.name}`}
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
mt="xs"
min={1}
max={24}
@@ -194,11 +196,11 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
</Input.Wrapper>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
label={tLayout("field.breakpoint.label")}
description={
layout.role === "mobile"
? t("board.setting.section.layout.mobile.breakpointDescription")
: t("layout.field.breakpoint.description")
? tBoard("setting.section.layout.mobile.breakpointDescription")
: tLayout("field.breakpoint.description")
}
disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }}
@@ -246,7 +248,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
disabled={isSaving}
onClick={() => confirmReset(layout)}
>
{t("board.setting.section.layout.reset.action")}
{tBoard("setting.section.layout.reset.action")}
</Button>
)}
{layout.role === "custom" && (
@@ -262,7 +264,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
);
}}
>
{t("common.action.remove")}
{tCommon("action.remove")}
</Button>
)}
</Group>
@@ -275,8 +277,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
onClick={() => void openLayoutEditorAsync(layout)}
>
{form.isDirty() || !persistedLayout
? t("board.setting.section.layout.edit.saveAndEdit")
: t("board.setting.section.layout.edit.action")}
? tBoard("setting.section.layout.edit.saveAndEdit")
: tBoard("setting.section.layout.edit.action")}
</Button>
</Group>
</Fieldset>
@@ -322,16 +324,16 @@ const gutterMarks = [
];
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
const t = useI18n();
const t = useI18n("layout");
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
return (
<Paper className={classes.gutterSettings} p="md">
<Stack gap="md">
<Box>
<Text fw={600}>{t("layout.field.gutters.label")}</Text>
<Text fw={600}>{t("field.gutters.label")}</Text>
<Text size="sm" c="dimmed">
{t("layout.field.gutters.description")}
{t("field.gutters.description")}
</Text>
</Box>
@@ -371,7 +373,7 @@ const GutterControl = ({
max: number;
onChange: (value: number) => void;
}) => {
const t = useI18n();
const t = useI18n("layout");
const enabled = value > 0;
return (
@@ -380,10 +382,10 @@ const GutterControl = ({
checked={enabled}
disabled={max < 1}
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
label={t(`layout.field.gutters.${side}.label`)}
label={t(`field.gutters.${side}.label` as never)}
/>
{enabled && (
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
<Input.Wrapper label={t("field.gutters.width.label")}>
<Slider
mt="xs"
min={1}
@@ -391,7 +393,7 @@ const GutterControl = ({
value={Math.min(value, max)}
marks={gutterMarks.filter((mark) => mark.value <= max)}
restrictToMarks
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
thumbLabel={t(`field.gutters.${side}.thumbLabel` as never)}
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
/>
</Input.Wrapper>
@@ -401,7 +403,7 @@ const GutterControl = ({
};
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
const t = useI18n();
const t = useI18n("layout");
const safeColumnCount = Math.max(1, columnCount);
const main = Math.max(1, safeColumnCount - left - right);
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
@@ -411,7 +413,7 @@ const GutterPreview = ({ left, right, columnCount }: { left: number; right: numb
return (
<Box
className={classes.gutterPreview}
aria-label={t("layout.field.gutters.preview.label")}
aria-label={t("field.gutters.preview.label")}
style={{ gridTemplateColumns: tracks }}
>
{left > 0 && <Box className={classes.previewGutter} data-side="left" />}
@@ -10,7 +10,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
@@ -86,8 +86,8 @@ interface BoardSettingsFormProps {
}
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n();
const tSection = useScopedI18n("board.setting.section");
const t = useI18n("common");
const tSection = useI18n("board.setting.section");
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board);
@@ -156,14 +156,14 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
form.resetDirty(canonicalValues);
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("common.notification.update.success"),
title: t("notification.update.success"),
message: t("notification.update.success"),
});
return canonicalValues;
} catch {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
title: t("notification.update.error"),
message: t("notification.update.error"),
});
return null;
}
@@ -200,10 +200,10 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
{t("action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
{t("action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
@@ -6,7 +6,7 @@ import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { BoardSettingsForm } from "./_settings-form";
@@ -46,7 +46,7 @@ export default async function BoardSettingsPage(props: Props) {
const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
const t = await getScopedI18n("board.setting");
const t = await getI18n("board.setting");
if (!hasChangeAccess) {
notFound();
@@ -16,7 +16,7 @@ import {
} from "@mantine/core";
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface RequestDetails {
widgetName: string;
@@ -31,7 +31,8 @@ interface RequestDetails {
}
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const t = useScopedI18n("customWidget.secretEntry");
const t = useI18n("customWidget.secretEntry");
const tSecret = useI18n("customWidget.secret");
const [details, setDetails] = useState<RequestDetails | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [baseUrl, setBaseUrl] = useState("");
@@ -120,7 +121,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
return (
<Input
key={kind}
label={t(`field.${kind}`)}
label={tSecret(kind)}
leftSection={<IconKey size={16} />}
value={values[kind] ?? ""}
onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))}
@@ -1,12 +1,12 @@
import { Box, Group, Image, Stack, Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { IntegrationMarquee } from "@homarr/ui";
import classes from "./hero-banner.module.css";
export const HeroBanner = async () => {
const t = await getScopedI18n("management.page.home");
const t = await getI18n("management.page.home");
return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
@@ -35,7 +35,7 @@ import {
import { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -47,7 +47,7 @@ import crowdinContributors from "@static-data/translators.json";
import classes from "./about.module.css";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const t = await getI18n("management");
return {
title: createMetaTitle(t("metaTitle")),
@@ -55,7 +55,7 @@ export async function generateMetadata() {
}
export default async function AboutPage() {
const t = await getScopedI18n("management.page.about");
const t = await getI18n("management.page.about");
const version = getPackageVersion();
const dependencies = await getDependenciesAsync();
@@ -9,15 +9,15 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number];
}
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useScopedI18n("app.page.delete");
const tList = useScopedI18n("app.page.list.action");
const t = useI18n("app.page.delete");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation();
@@ -33,14 +33,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
@@ -48,7 +48,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
);
},
});
}, [app, mutate, t, openConfirmModal]);
}, [app, mutate, t, tCommon, openConfirmModal]);
return (
<ActionIcon
@@ -57,7 +57,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
color="red"
size={44}
onClick={onClick}
aria-label={tList("delete", { name: app.name })}
aria-label={tCommon("action.deleteNamed", { name: app.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { AppForm } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
interface AppEditFormProps {
@@ -17,14 +17,14 @@ interface AppEditFormProps {
}
export const AppEditForm = ({ app }: AppEditFormProps) => {
const tScoped = useScopedI18n("app.page.edit.notification");
const t = useI18n();
const tScoped = useI18n("app.page.edit.notification");
const tCommon = useI18n("common");
const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: tScoped("success.title"),
title: tCommon("notification.update.success"),
message: tScoped("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
@@ -33,7 +33,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
},
onError: () => {
showErrorNotification({
title: tScoped("error.title"),
title: tCommon("notification.update.error"),
message: tScoped("error.message"),
});
},
@@ -52,7 +52,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
return (
<AppForm
buttonLabels={{
submit: t("common.action.save"),
submit: tCommon("action.save"),
}}
initialValues={app}
handleSubmit={handleSubmit}
@@ -9,7 +9,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getSafeAppHref } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
@@ -52,7 +52,8 @@ export default async function AppsPage(props: AppsPageProps) {
const { items: apps, totalCount } = canManageAll
? await api.app.getPaginated(searchParams)
: { items: [], totalCount: 0 };
const t = await getScopedI18n("app");
const t = await getI18n("app");
const tCommon = await getI18n("common");
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
const emptyState = !canManageAll ? (
@@ -67,7 +68,7 @@ export default async function AppsPage(props: AppsPageProps) {
icon={IconBox}
title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }}
action={{ label: tCommon("action.clearSearch"), href: "/manage/apps" }}
/>
) : (
<NoResults
@@ -80,7 +81,7 @@ export default async function AppsPage(props: AppsPageProps) {
const page = (
<ManageCollectionPage
title={t("page.list.title")}
title={tCommon("entity.apps")}
ariaLabel={t("page.list.ariaLabel")}
itemCount={apps.length}
emptyState={emptyState}
@@ -115,7 +116,7 @@ export default async function AppsPage(props: AppsPageProps) {
key={app.id}
app={app}
canDelete={canDelete}
editLabel={t("page.list.action.edit", { name: app.name })}
editLabel={tCommon("action.editNamed", { name: app.name })}
/>
))}
</ManageCollectionPage>
@@ -51,7 +51,7 @@ import {
} from "@homarr/definitions";
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
@@ -140,7 +140,8 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => (
);
export const AssistantConfiguration = () => {
const t = useScopedI18n("management.page.settings.section.assistant");
const t = useI18n("management.page.settings.section.assistant");
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
@@ -208,7 +209,7 @@ export const AssistantConfiguration = () => {
.filter((providerId) => assistantProviderPresets[providerId].category === category)
.map((providerId) => ({
value: providerId,
label: t(`provider.options.${providerId}.label`),
label: assistantProviderPresets[providerId].label ?? t("provider.options.custom.label"),
}));
return { group: t(`provider.groups.${category}`), items };
@@ -372,7 +373,7 @@ export const AssistantConfiguration = () => {
</ThemeIcon>
<Box className={classes.summaryProviderText}>
<Group gap="xs">
<Text fw={650}>{t(`provider.options.${provider}.label`)}</Text>
<Text fw={650}>{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}</Text>
<Badge
size="sm"
variant="light"
@@ -418,7 +419,7 @@ export const AssistantConfiguration = () => {
description={t("connection.description")}
status={
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
{t(`provider.options.${provider}.label`)}
{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}
</Badge>
}
>
@@ -758,7 +759,7 @@ export const AssistantConfiguration = () => {
{t("serverTools.webSearch.label")}
</Text>
<Badge size="xs" variant="light">
{t("serverTools.beta")}
{tCommon("beta")}
</Badge>
</Group>
<Text size="sm" c="dimmed" maw="66ch">
@@ -2,13 +2,13 @@ import { notFound } from "next/navigation";
import { Text } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() {
const t = await getScopedI18n("management.page.settings.section.assistant");
const t = await getI18n("management.page.settings.section.assistant");
return {
title: `${t("title")} • Homarr`,
};
@@ -21,7 +21,7 @@ export default async function AssistantPage() {
notFound();
}
const t = await getScopedI18n("management.page.settings.section.assistant");
const t = await getI18n("management.page.settings.section.assistant");
return (
<ManagePageLayout title={t("title")}>
<Text c="dimmed" maw="70ch">
@@ -10,7 +10,7 @@ import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -28,8 +28,9 @@ interface BoardCardMenuDropdownProps {
}
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common");
const t = useI18n("management.page.board.action");
const tRoot = useI18n();
const tCommon = useI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession();
@@ -57,7 +58,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const handleDeletion = useCallback(() => {
openConfirmModal({
title: t("delete.confirm.title"),
title: tCommon("action.deleteNamed", { name: board.name }),
children: t("delete.confirm.description", {
name: board.name,
}),
@@ -68,7 +69,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
});
},
});
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]);
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
@@ -97,7 +98,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}
{tRoot("board.action.duplicate.title")}
</Menu.Item>
)}
{hasChangeAccess && (
@@ -122,7 +123,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
onClick={handleDeletion}
disabled={deleteBoardMutation.isPending}
>
{t("delete.label")}
{tCommon("action.delete")}
</Menu.Item>
</>
)}
@@ -10,13 +10,13 @@ import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n();
const t = useI18n("management.page.board.action");
const { openModal } = useModalAction(AddBoardModal);
return (
<ManageMobilePrimaryAction>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
{t("management.page.board.action.new.label")}
{t("new.label")}
</Button>
</ManageMobilePrimaryAction>
);
@@ -25,7 +25,7 @@ import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
@@ -36,7 +36,7 @@ import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() {
const t = await getScopedI18n("management.page.board");
const t = await getI18n("management.page.board");
const session = await auth();
const boards = await api.board.getManageOverview();
const canCreateBoards = session?.user.permissions.includes("board-create");
@@ -3,12 +3,12 @@
import { Alert, Anchor, Text } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
export function CustomWidgetBetaBanner() {
const t = useScopedI18n("customWidget.betaBanner");
const t = useI18n("customWidget.betaBanner");
return (
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
@@ -3,20 +3,22 @@
import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
export function CodeEditor(props: CodeEditorProps) {
const t = useScopedI18n("customWidget.editor");
const t = useI18n("customWidget.editor");
const tCommon = useI18n("common");
return (
<CustomWidgetCodeEditor
{...props}
messages={{
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
languageJsx: invariantTechnicalLabels.jsx,
languageJson: invariantTechnicalLabels.json,
undo: tCommon("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
@@ -24,10 +26,10 @@ export function CodeEditor(props: CodeEditorProps) {
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copy: tCommon("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
schemaTab: invariantTechnicalLabels.jsonSchema,
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
@@ -5,7 +5,7 @@ import { IconCopy, IconRobot } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
@@ -22,7 +22,7 @@ export const CopyAiPromptButton = ({
request,
documentationUrl,
}: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget");
const t = useI18n("customWidget");
const assistant = useOptionalHomarrAssistant();
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
@@ -22,7 +22,7 @@ import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } fr
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
@@ -39,7 +39,7 @@ interface WidgetRef {
}
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const t = useI18n("customWidget");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
@@ -4,14 +4,14 @@ import { useEffect, useState } from "react";
import { Accordion } from "@mantine/core";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
const t = useScopedI18n("customWidget.workbench.builder");
const t = useI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(
{
$schema: "homarr-custom-widget-v2",
@@ -4,7 +4,7 @@ import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@man
import { IconClipboard, IconSparkles } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
@@ -18,7 +18,7 @@ interface AiCardProps {
}
export function CustomWidgetAiCard(props: AiCardProps) {
const t = useScopedI18n("customWidget.workbench.ai");
const t = useI18n("customWidget.workbench.ai");
return (
<Accordion variant="contained">
<Accordion.Item value="ai">
@@ -5,7 +5,7 @@ import type { Icon } from "@tabler/icons-react";
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import classes from "./_custom-widget-form.module.css";
@@ -52,7 +52,8 @@ export function SaveActions({
mode: "create" | "edit";
onPreview(): void;
}) {
const t = useScopedI18n("customWidget.workbench");
const t = useI18n("customWidget.workbench");
const tCommon = useI18n("common");
return (
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
@@ -77,7 +78,7 @@ export function SaveActions({
{previewPending ? t("action.previewLoading") : t("action.preview")}
</Button>
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
{mode === "create" ? t("action.create") : t("action.save")}
{mode === "create" ? tCommon("action.create") : tCommon("action.save")}
</Button>
</Group>
</Group>
@@ -22,7 +22,7 @@ import { customWidgetOptionsSchema, getCustomWidgetDefaultOptions } from "@homar
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
@@ -57,8 +57,9 @@ const sectionLinks = [
] as const;
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const t = useScopedI18n("customWidget");
const w = useScopedI18n("customWidget.workbench");
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const w = useI18n("customWidget.workbench");
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
@@ -169,7 +170,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
/>
<CustomWidgetAdvancedManifest form={form} />
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<TextInput label={tCommon("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
<IconPicker
withAsterisk={false}
@@ -5,7 +5,7 @@ import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
@@ -18,20 +18,22 @@ interface CustomWidgetListProps {
}
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
const t = useScopedI18n("customWidget");
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const tEntities = useI18n("common.entity");
if (definitions.length === 0) {
return (
<NoResults
icon={IconApi}
title={t("page.list.noResults")}
action={{ href: "/manage/custom-widgets/new", label: t("action.create") }}
action={{ href: "/manage/custom-widgets/new", label: tCommon("action.create") }}
/>
);
}
return (
<ManageCollectionList ariaLabel={t("page.list.title")}>
<ManageCollectionList ariaLabel={tEntities("customWidgets")}>
{definitions.map((definition) => (
<CustomWidgetRow key={definition.id} widget={definition} />
))}
@@ -42,7 +44,8 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget");
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
const origins = [...new Set(widget.sources.map((source) => source.origin))];
@@ -94,7 +97,7 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{t("action.edit")}
{tCommon("action.edit")}
</Button>
)}
<CustomWidgetRowActions
@@ -122,14 +125,8 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
}
/** Everything that decides whether this widget can actually run right now. */
function CustomWidgetStatusBadges({
widget,
onConfigureSources,
}: {
widget: WidgetDef;
onConfigureSources(): void;
}) {
const t = useScopedI18n("customWidget");
function CustomWidgetStatusBadges({ widget, onConfigureSources }: { widget: WidgetDef; onConfigureSources(): void }) {
const t = useI18n("customWidget");
if (widget.migrationRequired) {
return (
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
@@ -21,7 +21,7 @@ export function DefaultValueEditor({
choices?: Array<{ label: string; value: string | number }>;
onChange: (value: unknown) => void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const t = useI18n("customWidget.workbench.builder");
if (control === "switch")
return (
<Switch
@@ -84,7 +84,7 @@ export function OptionChoicesEditor({
value: Array<{ label: string; value: string | number }>;
onChange(value: Array<{ label: string; value: string | number }>): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const t = useI18n("customWidget.workbench.builder");
return (
<Stack gap="xs">
{value.map((choice, index) => (
@@ -150,7 +150,7 @@ export function JsonValueEditor({
value: unknown;
onChange(value: unknown): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const t = useI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
@@ -16,7 +16,7 @@ import {
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { customWidgetOptionControls } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
@@ -30,7 +30,7 @@ export function CustomWidgetOptionsEditor({
form: CustomWidgetWorkbenchForm;
onRename(currentName: string, nextName: string): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const t = useI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.options);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const requests = parseJson(form.values.requests);
@@ -8,7 +8,7 @@ import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } fro
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
@@ -19,7 +19,7 @@ export function PreviewActionControl({
request: CustomJsxRequest & { id: string };
sessionId?: string;
}) {
const t = useScopedI18n("customWidget.workbench.preview");
const t = useI18n("customWidget.workbench.preview");
const { openConfirmModal } = useConfirmModal();
const actionMutation = clientApi.customWidget.previewAction.useMutation();
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
@@ -19,7 +19,7 @@ vi.mock("@homarr/api/client", () => ({
},
},
}));
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/translation/client", () => ({ useI18n: () => (key: string) => key }));
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
const rendererAttempt = vi.hoisted(() => vi.fn());
const codeEditorProps = vi.hoisted(() => vi.fn());
@@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
import { CodeEditor } from "./_code-editor";
@@ -37,8 +37,9 @@ interface PreviewPanelProps {
}
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
const t = useScopedI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useScopedI18n("customWidget.editor.errorBoundary");
const t = useI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
const actionT = useI18n("common.action");
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
@@ -155,7 +156,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
<PreviewErrorBoundary
title={errorBoundaryMessages("title")}
description={t("containedFailures")}
retryLabel={errorBoundaryMessages("retry")}
retryLabel={actionT("tryAgain")}
resetKeys={[rendererResetKey]}
>
<CustomJsxDisplay data={displayData} />
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { isRecord } from "./_custom-widget-form-utils";
@@ -21,7 +21,7 @@ export function QueryValuesEditor({
allowParams: boolean;
onChange(value: Record<string, unknown>): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const t = useI18n("customWidget.workbench.builder");
const entries = Object.entries(value);
const [nameErrors, setNameErrors] = useState<Record<number, string>>({});
return (
@@ -150,7 +150,7 @@ export function RequestBodyEditor({
allowParams: boolean;
onChange(value: unknown): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const t = useI18n("customWidget.workbench.builder");
const serialized = value === undefined ? "" : JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
@@ -3,7 +3,7 @@
import { Accordion, ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
@@ -19,7 +19,7 @@ export function CustomWidgetRequestsEditor({
form: CustomWidgetWorkbenchForm;
onRename(currentId: string, nextId: string): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const t = useI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.requests);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const sources = parseJson(form.values.sources);
@@ -3,12 +3,12 @@
import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core";
import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) {
const t = useScopedI18n("customWidget.workbench.saveError");
const t = useI18n("customWidget.workbench.saveError");
if (issues.length === 0) return null;
const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
@@ -5,7 +5,7 @@ import { IconKey, IconTrash } from "@tabler/icons-react";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
@@ -51,7 +51,8 @@ export function CustomWidgetSourceField({
onClearSecret,
onRemove,
}: CustomWidgetSourceFieldProps) {
const t = useScopedI18n("customWidget.workbench.sources");
const t = useI18n("customWidget.workbench.sources");
const tSecret = useI18n("customWidget.secret");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
@@ -118,7 +119,7 @@ export function CustomWidgetSourceField({
<Group key={kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={t(`secret.${kind}`)}
label={tSecret(kind)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
@@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
@@ -21,8 +21,8 @@ export function CustomWidgetSourcesEditor({
form: CustomWidgetWorkbenchForm;
definitionId?: string;
}) {
const t = useScopedI18n("customWidget.workbench.sources");
const w = useScopedI18n("customWidget.workbench");
const t = useI18n("customWidget.workbench.sources");
const w = useI18n("customWidget.workbench");
const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
@@ -3,7 +3,7 @@
import { Tabs } from "@mantine/core";
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
const tabs = {
@@ -13,7 +13,7 @@ const tabs = {
/** Switches between the widgets you have installed and the community Workshop. */
export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
const t = useScopedI18n("customWidget.page.tabs");
const t = useI18n("customWidget.page.tabs");
return (
<Tabs value={tabs[active]}>
@@ -5,7 +5,7 @@ import { Component } from "react";
import { Alert, Button, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface FormErrorBoundaryState {
error: Error | null;
@@ -35,7 +35,8 @@ export class FormErrorBoundary extends Component<FormErrorBoundaryProps, FormErr
}
function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
const t = useScopedI18n("customWidget");
const t = useI18n("customWidget");
const tCommon = useI18n("common");
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md">
<Stack gap="xs">
@@ -46,7 +47,7 @@ function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }
{error.message}
</Text>
<Button size="xs" variant="light" color="red" onClick={reset}>
{t("editor.errorBoundary.retry")}
{tCommon("action.tryAgain")}
</Button>
</Stack>
</Alert>
@@ -11,13 +11,14 @@ import {
} from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
@@ -38,7 +39,7 @@ export const ImportCustomWidgetButton = () => {
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.import"),
title: tCommon("action.import"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
@@ -47,7 +48,7 @@ export const ImportCustomWidgetButton = () => {
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport, t]);
}, [queueImport, t, tCommon]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -61,13 +62,13 @@ export const ImportCustomWidgetButton = () => {
queueImport(result.widget);
} catch (error) {
showErrorNotification({
title: t("action.import"),
title: tCommon("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"),
});
}
});
reader.addEventListener("error", () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
showErrorNotification({ title: tCommon("action.import"), message: t("notification.importError") });
});
reader.readAsText(file);
event.target.value = "";
@@ -81,7 +82,7 @@ export const ImportCustomWidgetButton = () => {
onClick={() => fileInputRef.current?.click()}
title={t("action.pasteImportHint")}
>
{t("action.import")}
{tCommon("action.import")}
</MobileAffixButton>
<input
ref={fileInputRef}
@@ -9,7 +9,9 @@ import { JsonPreviewEditor } from "./_json-preview-editor";
const editorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/translation/client", () => ({
useI18n: (namespace: string) => (key: string) => `${namespace}.${key}`,
}));
vi.mock("./_code-editor", () => ({
CodeEditor: (props: { error?: string; onChange(value: string): void }) => {
editorProps(props);
@@ -41,6 +43,8 @@ describe("preview JSON editor", () => {
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
expect(onChange).toHaveBeenCalledWith({});
expect(editorProps).toHaveBeenLastCalledWith(expect.objectContaining({ error: "invalidJson" }));
expect(editorProps).toHaveBeenLastCalledWith(
expect.objectContaining({ error: "customWidget.workbench.builder.invalidJson" }),
);
});
});
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
@@ -18,7 +18,7 @@ export function JsonPreviewEditor({
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const t = useI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [invalid, setInvalid] = useState(false);
@@ -8,7 +8,7 @@ import { clientApi } from "@homarr/api/client";
import { formatCustomWidgetImportIssues } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import {
applyCustomWidgetAiResponse,
@@ -36,14 +36,17 @@ interface FormActionsInput {
}
export function useCustomWidgetFormActions(input: FormActionsInput) {
const t = useScopedI18n("customWidget");
const w = useScopedI18n("customWidget.workbench");
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const w = useI18n("customWidget.workbench");
const utils = clientApi.useUtils();
const createMutation = clientApi.customWidget.create.useMutation();
const updateMutation = clientApi.customWidget.update.useMutation();
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
const [previewPending, setPreviewPending] = useState(false);
let actionLabel = tCommon("action.save");
if (input.mode === "create") actionLabel = tCommon("action.create");
const save = input.form.onSubmit(async (values) => {
clearSaveIssues(input.form, saveIssues);
@@ -54,7 +57,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
input.form,
definition.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })),
setSaveIssues,
t("action.save"),
actionLabel,
(count) => w("saveError.more", { count }),
);
return;
@@ -65,7 +68,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
await utils.customWidget.list.invalidate();
showSuccessNotification({
title: t("action.create"),
title: tCommon("action.create"),
message: t("notification.created", { name: values.name }),
});
input.form.setInitialValues(values);
@@ -86,18 +89,16 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
input.form.setInitialValues(values);
input.form.resetDirty();
showSuccessNotification({
title: t("action.save"),
title: tCommon("action.save"),
message: t("notification.updated", { name: values.name }),
});
}
} catch (error) {
const issues = extractCustomWidgetSaveIssues(error);
if (issues.length > 0) {
reportSaveIssues(input.form, issues, setSaveIssues, t("action.save"), (count) =>
w("saveError.more", { count }),
);
reportSaveIssues(input.form, issues, setSaveIssues, actionLabel, (count) => w("saveError.more", { count }));
} else {
showErrorNotification({ title: t("action.save"), message: t("notification.updateError") });
showErrorNotification({ title: actionLabel, message: t("notification.updateError") });
}
}
});
@@ -8,7 +8,8 @@ import { useI18n } from "@homarr/translation/client";
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
export function useUnsavedChangesGuard(isDirty: boolean) {
const t = useI18n();
const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
const tCommon = useI18n("common");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const dirtyRef = useRef(isDirty);
@@ -16,9 +17,9 @@ export function useUnsavedChangesGuard(isDirty: boolean) {
dirtyRef.current = isDirty;
confirmNavigationRef.current = (href) => {
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
confirmProps: { children: t("common.action.discard") },
title: tConfirmLeave("title"),
children: tConfirmLeave("message"),
confirmProps: { children: tCommon("action.discard") },
onConfirm: () => router.push(href),
});
};
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { Container, Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { CustomWidgetBetaBanner } from "../_beta-banner";
@@ -14,7 +14,7 @@ export default async function NewCustomWidgetPage() {
if (!session || !session.user.permissions.includes("admin")) {
redirect("/manage/custom-widgets");
}
const t = await getScopedI18n("customWidget");
const t = await getI18n("customWidget");
return (
<>
@@ -4,7 +4,7 @@ import { IconExternalLink, IconPlus } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
@@ -21,11 +21,12 @@ export default async function CustomWidgetsPage() {
redirect(session ? "/" : "/auth/login");
}
const definitions = await api.customWidget.list();
const t = await getScopedI18n("customWidget");
const t = await getI18n("customWidget");
const [tCommon, tEntities] = await Promise.all([getI18n("common"), getI18n("common.entity")]);
return (
<ManagePageLayout
title={t("page.list.title")}
title={tEntities("customWidgets")}
primaryAction={
<Group gap="xs">
<MobileAffixButton
@@ -40,7 +41,7 @@ export default async function CustomWidgetsPage() {
</MobileAffixButton>
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")}
{tCommon("action.create")}
</MobileAffixButton>
</Group>
}
@@ -26,7 +26,7 @@ import {
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
@@ -45,7 +45,8 @@ import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop
const listHref = "/manage/custom-widgets";
export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const tCommon = useI18n("common");
const session = useWorkshopSession();
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
@@ -231,7 +232,7 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
}
>
<Button component={Link} href={listHref} variant="default">
{t("publish.cancel")}
{tCommon("action.cancel")}
</Button>
<Button
size="md"
@@ -23,7 +23,7 @@ import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconF
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema";
@@ -44,7 +44,7 @@ const browseHref = "/manage/custom-widgets/workshop";
const installedHref = "/manage/custom-widgets";
export function WorkshopDetail({ id }: { id: string }) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const router = useRouter();
const session = useWorkshopSession();
const [reportOpened, reportControls] = useDisclosure(false);
@@ -5,7 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ActionIcon, Button, Group, Select, Stack, Tooltip } from "@mantine/core";
import { IconBuildingStore, IconExternalLink } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { useWorkshopQuery } from "@homarr/workshop/backend";
@@ -26,7 +26,8 @@ interface WorkshopBrowseProps {
}
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const tCommon = useI18n("common.action");
const session = useWorkshopSession();
const query = useWorkshopQuery(session.client, {
page,
@@ -78,7 +79,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
title={t("emptyFilteredTitle")}
description={t("emptyFilteredDescription", { search })}
action={{
label: t("clearSearch"),
label: tCommon("clearSearch"),
href: "/manage/custom-widgets/workshop",
}}
/>
@@ -122,7 +123,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
}
function WorkshopSortSelect({ value }: { value: "top" | "newest" }) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { Button, Center, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
export default function ManageErrorPage({
error,
@@ -13,8 +13,8 @@ export default function ManageErrorPage({
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
const tError = useScopedI18n("management.error");
const t = useI18n();
const tError = useI18n("management.error");
const tCommon = useI18n("common");
useEffect(() => {
console.error(error);
@@ -34,7 +34,7 @@ export default function ManageErrorPage({
</Text>
)}
<Button variant="light" onClick={unstable_retry}>
{t("common.action.tryAgain")}
{tCommon("action.tryAgain")}
</Button>
</Stack>
</Center>
@@ -15,7 +15,7 @@ interface Props {
}
export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => {
const t = useI18n();
const tIntegration = useI18n("integration");
const utils = clientApi.useUtils();
const { data } = clientApi.integration.getIntegrationPermissions.useQuery(
{
@@ -49,7 +49,7 @@ export const IntegrationAccessSettings = ({ integration, initialPermissions }: P
},
groupPermissionMapping: integrationPermissionsMap,
}}
translate={(key) => t(`integration.permission.${key}`)}
translate={(key) => tIntegration(`permission.${key}` as never)}
query={{
data,
invalidate: () => utils.integration.getIntegrationPermissions.invalidate(),
@@ -25,7 +25,8 @@ interface SecretCardProps {
}
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const t = useI18n();
const tIntegration = useI18n("integration");
const tCommon = useI18n("common");
const { isPublic } = integrationSecretKindObject[secret.kind];
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
const [editMode, setEditMode] = useState(false);
@@ -40,20 +41,20 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
<Avatar>
<KindIcon size={16} />
</Avatar>
<Text fw={500}>{t(`integration.secrets.kind.${secret.kind}.label`)}</Text>
<Text fw={500}>{tIntegration(`secrets.kind.${secret.kind}.label` as never)}</Text>
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group>
<Group>
{secret.updatedAt ? (
<Text c="gray.6" size="sm">
{t("integration.secrets.lastUpdated", {
{tIntegration("secrets.lastUpdated", {
date: dayjs().to(dayjs(secret.updatedAt)),
})}
</Text>
) : (
<Tooltip label={t("integration.secrets.notSet.tooltip")} position="left">
<Tooltip label={tIntegration("secrets.notSet.tooltip")} position="left">
<Badge color="orange" variant="light" size="sm">
{t("integration.secrets.notSet.label")}
{tIntegration("secrets.notSet.label")}
</Badge>
</Tooltip>
)}
@@ -62,7 +63,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
color="gray"
variant="subtle"
onClick={togglePublicSecretDisplay}
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
aria-label={tCommon(publicSecretDisplayOpened ? "action.hide" : "action.show")}
>
<DisplayIcon size={16} stroke={1.5} />
</ActionIcon>
@@ -80,7 +81,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
setEditMode(false);
}}
>
{editMode ? t("common.action.cancel") : t("common.action.edit")}
{editMode ? tCommon("action.cancel") : tCommon("action.edit")}
</Button>
</Group>
</Group>
@@ -29,14 +29,14 @@ export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
};
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n();
const tIntegration = useI18n("integration");
const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind];
if (multiline) {
return (
<Textarea
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
autosize
@@ -48,7 +48,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
return (
<TextInput
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
/>
@@ -56,7 +56,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
};
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n();
const tIntegration = useI18n("integration");
const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind];
@@ -64,8 +64,8 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
return (
<Textarea
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
description={t("integration.secrets.secureNotice")}
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
description={tIntegration("secrets.secureNotice")}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
autosize
@@ -77,8 +77,8 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
return (
<PasswordInput
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
description={t("integration.secrets.secureNotice")}
label={props.label ?? tIntegration(`secrets.kind.${kind}.label` as never)}
description={tIntegration("secrets.secureNotice")}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
/>
@@ -3,7 +3,7 @@ import { SegmentedControl } from "@mantine/core";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface FormType {
secrets: { kind: IntegrationSecretKind; value: string | null }[];
@@ -20,7 +20,7 @@ export const SecretKindsSegmentedControl = <TFormType extends FormType>({
secretKinds,
form,
}: SecretKindsSegmentedControlProps<TFormType>) => {
const t = useScopedI18n("integration.secrets");
const t = useI18n("integration.secrets");
const defaultValue = defaultKinds?.length === 0 ? "empty" : defaultKinds?.join("-");
const secretKindGroups = secretKinds.map((kinds) => ({
@@ -14,7 +14,7 @@ interface IntegrationTestConnectionErrorProps {
}
export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestConnectionErrorProps) => {
const t = useI18n();
const tIntegration = useI18n("integration");
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
return (
@@ -22,17 +22,17 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
<Stack>
<Stack gap="sm">
<Text size="lg" fw={500} c="red.8">
{t(`integration.testConnection.error.${error.type}.title`)}
{tIntegration(`testConnection.error.${error.type}.title` as never)}
</Text>
{error.type !== "request" && error.type !== "certificate" && error.type !== "statusCode" ? (
<Text size="md">{t(`integration.testConnection.error.${error.type}.description`)}</Text>
<Text size="md">{tIntegration(`testConnection.error.${error.type}.description` as never)}</Text>
) : null}
{error.type === "request" ? (
<Text size="md">
{t(
`integration.testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
{tIntegration(
`testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
)}
</Text>
) : null}
@@ -40,7 +40,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
{error.type === "statusCode" ? (
error.data.reason === "other" ? (
<Text size="md">
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
{tIntegration.rich("testConnection.error.statusCode.otherDescription", {
statusCode: error.data.statusCode.toString(),
url: () => (
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
@@ -51,8 +51,8 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
</Text>
) : (
<Text size="md">
{t.rich("integration.testConnection.error.statusCode.description", {
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
{tIntegration.rich("testConnection.error.statusCode.description", {
reason: tIntegration(`testConnection.error.statusCode.reason.${error.data.reason}` as never),
statusCode: error.data.statusCode.toString(),
url: () => (
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
@@ -71,7 +71,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
<Accordion variant="contained">
<Accordion.Item value="cause">
<Accordion.Control icon={<IconSubtask size={16} stroke={1.5} />}>
{t("integration.testConnection.error.common.cause.title")}
{tIntegration("testConnection.error.common.cause.title")}
</Accordion.Control>
<Accordion.Panel>
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
@@ -9,7 +9,7 @@ import { createDocumentationLink } from "@homarr/definitions";
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useCurrentLocale, useI18n, useScopedI18n } from "@homarr/translation/client";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
@@ -19,7 +19,7 @@ interface CertificateErrorDetailsProps {
}
export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsProps) => {
const tError = useScopedI18n("integration.testConnection.error");
const tError = useI18n("integration.testConnection.error");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const [showRetryButton, setShowRetryButton] = useState(false);
@@ -160,46 +160,35 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
};
const NotEnoughPermissionsAlert = () => {
const t = useI18n();
const t = useI18n("integration.testConnection.error.certificate.alert.permission");
return (
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("integration.testConnection.error.certificate.alert.permission.title")}
color="yellow"
>
{t("integration.testConnection.error.certificate.alert.permission.message")}
<Alert icon={<IconAlertTriangle size={16} />} title={t("title")} color="yellow">
{t("message")}
</Alert>
);
};
const HostnameMismatchAlert = () => {
const t = useI18n();
const t = useI18n("integration.testConnection.error.certificate.alert.hostnameMismatch");
return (
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("integration.testConnection.error.certificate.alert.hostnameMismatch.title")}
color="yellow"
>
{t("integration.testConnection.error.certificate.alert.hostnameMismatch.message")}
<Alert icon={<IconAlertTriangle size={16} />} title={t("title")} color="yellow">
{t("message")}
</Alert>
);
};
const CertificateExtractAlert = () => {
const t = useI18n();
const t = useI18n("integration.testConnection.error.certificate.alert.extract");
const tCommon = useI18n("common");
return (
<Alert
icon={<IconExclamationCircle size={16} />}
title={t("integration.testConnection.error.certificate.alert.extract.title")}
color="red"
>
{t.rich("integration.testConnection.error.certificate.alert.extract.message", {
<Alert icon={<IconExclamationCircle size={16} />} title={t("title")} color="red">
{t.rich("message", {
docsLink: () => (
<Anchor
href={createDocumentationLink("/docs/management/certificates", "#obtaining-certificates")}
target="_blank"
>
{t("common.here")}
{tCommon("here")}
</Anchor>
),
})}
@@ -214,8 +203,8 @@ interface CertificateDetailsProps {
export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps) => {
const { openModal } = useModalAction(PemContentModal);
const locale = useCurrentLocale();
const tDetails = useScopedI18n("integration.testConnection.error.certificate.details");
const tCertificateField = useScopedI18n("certificate.field");
const tDetails = useI18n("integration.testConnection.error.certificate.details");
const tCertificateField = useI18n("certificate.field");
return (
<Card>
@@ -291,7 +280,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
};
const PemContentModal = createModal<{ content: string }>(({ actions, innerProps }) => {
const t = useI18n();
const tCommon = useI18n("common");
return (
<Stack>
@@ -318,7 +307,7 @@ const PemContentModal = createModal<{ content: string }>(({ actions, innerProps
</Card>
<Button variant="light" color="gray" onClick={actions.closeModal}>
{t("common.action.close")}
{tCommon("action.close")}
</Button>
</Stack>
);
@@ -7,15 +7,15 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface DeleteIntegrationActionButtonProps {
integration: { id: string; name: string };
}
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete");
const tList = useScopedI18n("integration.page.list.action");
const t = useI18n("integration.page.delete");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
@@ -36,7 +36,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations");
@@ -44,7 +44,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
@@ -53,7 +53,7 @@ export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegration
},
});
}}
aria-label={tList("delete", { name: integration.name })}
aria-label={tCommon("action.deleteNamed", { name: integration.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
@@ -10,7 +10,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
import { getAllSecretKindOptions, getDefaultSecretKinds, invariantTechnicalLabels } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
@@ -56,7 +56,8 @@ export const EditIntegrationForm = ({
onSuccess,
formRef,
}: EditIntegrationFormProps) => {
const t = useI18n();
const tCommon = useI18n("common");
const tIntegration = useI18n("integration");
const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind);
@@ -83,8 +84,8 @@ export const EditIntegrationForm = ({
const { mutateAsync, isPending } = clientApi.integration.update.useMutation({
onError() {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
title: tCommon("notification.update.error"),
message: tIntegration("page.edit.notification.error.message"),
});
},
});
@@ -103,8 +104,8 @@ export const EditIntegrationForm = ({
: values.url;
} catch {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
title: tCommon("notification.update.error"),
message: tIntegration("page.edit.notification.error.message"),
});
return false;
}
@@ -124,16 +125,16 @@ export const EditIntegrationForm = ({
if (data?.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
title: tCommon("notification.update.error"),
message: tIntegration("page.edit.notification.error.message"),
});
requestAnimationFrame(() => errorRef.current?.focus());
return false;
}
showSuccessNotification({
title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"),
title: tCommon("notification.update.success"),
message: tIntegration("page.edit.notification.success.message"),
});
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
onSuccess?.();
@@ -142,7 +143,18 @@ export const EditIntegrationForm = ({
}
return true;
},
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget],
[
hasUrlSecret,
hideButtons,
integration.id,
mutateAsync,
onSuccess,
router,
tCommon,
tIntegration,
utils.integration,
utils.widget,
],
);
useImperativeHandle(
@@ -172,13 +184,13 @@ export const EditIntegrationForm = ({
const formFields = (
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput withAsterisk label={tCommon("field.name")} {...form.getInputProps("name")} />
{hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
<TextInput withAsterisk label={invariantTechnicalLabels.url} {...form.getInputProps("url")} />
)}
<Fieldset legend={t("integration.secrets.title")}>
<Fieldset legend={tIntegration("secrets.title")}>
<Stack gap="sm">
{allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
@@ -196,8 +208,8 @@ export const EditIntegrationForm = ({
return resolve(true);
}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
title: tIntegration("secrets.reset.title"),
children: tIntegration("secrets.reset.message"),
onCancel: () => resolve(false),
onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
@@ -208,7 +220,7 @@ export const EditIntegrationForm = ({
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
label={tIntegration(`secrets.kind.${secret.kind}.newLabel` as never)}
key={secret.kind}
kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)}
@@ -227,7 +239,7 @@ export const EditIntegrationForm = ({
))}
{form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
<Text c={"blue"}>{tIntegration("secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
</Stack>
@@ -244,10 +256,10 @@ export const EditIntegrationForm = ({
{!hideButtons && (
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
{tCommon("action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}>
{t("integration.testConnection.action.edit")}
{tIntegration("testConnection.action.edit")}
</Button>
</Group>
)}
@@ -270,7 +282,8 @@ interface IntegrationAppSelectProps {
const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
const tIntegration = useI18n("integration");
const tCommon = useI18n("common");
const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
@@ -281,7 +294,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
withCreate: canCreateApps,
},
{
title: t("integration.page.edit.app.action.select"),
title: tIntegration("page.edit.app.action.select"),
},
);
@@ -294,13 +307,13 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
fullWidth
onClick={handleChange}
>
{t("integration.page.edit.app.action.add")}
{tIntegration("page.edit.app.action.add")}
</Button>
);
}
return (
<Fieldset legend={t("integration.field.app.sectionTitle")}>
<Fieldset legend={tIntegration("field.app.sectionTitle")}>
<Group justify="space-between">
<Group gap="sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -323,7 +336,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
leftSection={<IconUnlink size={16} stroke={1.5} />}
onClick={() => onChange(null)}
>
{t("integration.page.edit.app.action.remove")}
{tIntegration("page.edit.app.action.remove")}
</Button>
<Button
variant="subtle"
@@ -331,7 +344,7 @@ const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
leftSection={<IconPencil size={16} stroke={1.5} />}
onClick={handleChange}
>
{t("common.action.change")}
{tCommon("action.change")}
</Button>
</ButtonGroup>
</Group>
@@ -2,7 +2,7 @@ import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getIntegrationName } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -16,7 +16,7 @@ interface EditIntegrationPageProps {
export default async function EditIntegrationPage(props: EditIntegrationPageProps) {
const params = await props.params;
const editT = await getScopedI18n("integration.page.edit");
const editT = await getI18n("integration.page.edit");
const t = await getI18n();
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
@@ -31,6 +31,7 @@ import {
getIntegrationApiKeyUrl,
getIntegrationDefaultUrl,
getIntegrationName,
invariantTechnicalLabels,
integrationDefs,
} from "@homarr/definitions";
import type { GetInputPropsReturnType, UseFormReturnType } from "@homarr/form";
@@ -65,7 +66,8 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
);
export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => {
const t = useI18n();
const tCommon = useI18n("common");
const tIntegration = useI18n("integration");
const secretKinds = getAllSecretKindOptions(kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const { data: session } = useSession();
@@ -74,7 +76,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
? formSchema
: formSchema.superRefine((values, context) => {
if (!values.hasApp || values.appId !== null) return;
context.addIssue({ code: "custom", message: t("common.zod.errors.required"), path: ["appId"] });
context.addIssue({ code: "custom", message: tCommon("zod.errors.required"), path: ["appId"] });
});
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
@@ -116,15 +118,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
if (data && "error" in data && data.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
title: tCommon("notification.create.error"),
message: tIntegration("page.create.notification.error.message"),
});
return;
}
showSuccessNotification({
title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"),
title: tCommon("notification.create.success"),
message: tIntegration("page.create.notification.success.message"),
});
onSuccess(data && "integration" in data ? data : undefined);
@@ -132,8 +134,8 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
const onMutationError = () => {
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
title: tCommon("notification.create.error"),
message: tIntegration("page.create.notification.error.message"),
});
};
@@ -166,13 +168,13 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
<TextInput withAsterisk label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} />
{hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
<TextInput withAsterisk label={invariantTechnicalLabels.url} {...form.getInputProps("url")} />
)}
<Fieldset legend={t("integration.secrets.title")}>
<Fieldset legend={tIntegration("secrets.title")}>
<Stack gap="sm">
{secretKinds.length > 1 && <SecretKindsSegmentedControl secretKinds={secretKinds} form={form} />}
{form.values.secrets.map(({ kind }, index) => (
@@ -185,7 +187,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
))}
{form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
<Text c={"blue"}>{tIntegration("secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
<ApiKeySettingsLink kind={kind} url={form.values.url} />
@@ -196,8 +198,8 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
{supportsSearchEngine && (
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", {
label={tIntegration("field.attemptSearchEngineCreation.label")}
description={tIntegration("field.attemptSearchEngineCreation.description", {
kind: getIntegrationName(kind),
})}
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
@@ -209,15 +211,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
<Group justify="end" align="center">
{onCancel ? (
<Button variant="default" onClick={onCancel}>
{t("common.action.backToOverview")}
{tCommon("action.backToOverview")}
</Button>
) : (
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
{tCommon("action.backToOverview")}
</Button>
)}
<Button type="submit" loading={isCreatePending}>
{t("integration.testConnection.action.create")}
{tIntegration("testConnection.action.create")}
</Button>
</Group>
</Stack>
@@ -228,7 +230,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
type FormType = z.infer<typeof formSchema>;
const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; canCreateApps: boolean }) => {
const t = useI18n();
const tIntegration = useI18n("integration");
const checkboxInputProps = form.getInputProps("hasApp", { type: "checkbox" });
return (
@@ -242,20 +244,18 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
checkboxInputProps.onChange(event);
});
}}
label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")}
description={t(
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
)}
label={tIntegration(canCreateApps ? "field.createApp.label" : "field.linkApp.label")}
description={tIntegration(canCreateApps ? "field.createApp.description" : "field.linkApp.description")}
/>
<Collapse expanded={form.values.hasApp}>
<Fieldset legend={t("integration.field.app.sectionTitle")}>
<Fieldset legend={tIntegration("field.app.sectionTitle")}>
<Stack gap="sm">
{canCreateApps && (
<SegmentedControl
data={(["new", "existing"] as const).map((value) => ({
value,
label: t(`integration.page.create.app.option.${value}.title`),
label: tIntegration(`page.create.app.option.${value}.title` as never),
}))}
value={form.values.appHref === null ? "existing" : "new"}
onChange={(value) => {
@@ -272,10 +272,10 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
{typeof form.values.appHref === "string" && canCreateApps ? (
<TextInput
placeholder={t("integration.field.appHref.placeholder")}
placeholder={tIntegration("field.appHref.placeholder")}
withAsterisk
label={t("integration.page.create.app.option.new.url.label")}
description={t("integration.page.create.app.option.new.url.description")}
label={tIntegration("page.create.app.option.new.url.label")}
description={tIntegration("page.create.app.option.new.url.description")}
{...form.getInputProps("appHref")}
/>
) : (
@@ -302,7 +302,7 @@ const normalizeUrl = (raw: string): string | null => {
};
const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => {
const t = useI18n();
const tIntegration = useI18n("integration");
const apiKeyUrl = getIntegrationApiKeyUrl(url, kind);
if (!apiKeyUrl) return null;
@@ -315,7 +315,7 @@ const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string
<Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm">
<Group gap={4}>
<IconKey size={14} stroke={1.5} />
<Text size="sm">{t("integration.field.apiKeySettings.label")}</Text>
<Text size="sm">{tIntegration("field.apiKeySettings.label")}</Text>
<IconExternalLink size={14} stroke={1.5} />
</Group>
</Anchor>
@@ -332,14 +332,14 @@ type IntegrationAppSelectProps = Modify<
const IntegrationAppSelect = ({ value, ...props }: IntegrationAppSelectProps) => {
const { data, isPending } = clientApi.app.selectable.useQuery();
const t = useI18n();
const tIntegration = useI18n("integration");
const appMap = new Map(data?.map((app) => [app.id, app] as const));
return (
<Select
withAsterisk
label={t("integration.page.create.app.option.existing.label")}
label={tIntegration("page.create.app.option.existing.label")}
searchable
clearable
leftSection={
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { auth } from "@homarr/auth/next";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -30,7 +30,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
if (!result.success) {
const t = await getScopedI18n("integration");
const t = await getI18n("integration");
return (
<>
<DynamicBreadcrumb />
@@ -44,7 +44,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
);
}
const tCreate = await getScopedI18n("integration.page.create");
const tCreate = await getI18n("integration.page.create");
const currentKind = result.data;
@@ -8,7 +8,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getSafeApplicationUrl } from "@homarr/common";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { IntegrationAvatar, Link, SearchInput } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
@@ -37,7 +37,8 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
} = getIntegrationsSectionAccess(session, integrations);
if (!canAccess) notFound();
const t = await getScopedI18n("integration");
const t = await getI18n("integration");
const tCommon = await getI18n("common");
// Without integration-full-all only the integrations that were explicitly delegated to the user
// are manageable, so the list never shows an integration they cannot open.
const manageableIntegrations = integrations.filter(
@@ -61,7 +62,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const page = (
<ManageCollectionPage
title={t("page.list.title")}
title={tCommon("entity.integrations")}
ariaLabel={t("page.list.ariaLabel")}
itemCount={filteredIntegrations.length}
emptyState={
@@ -77,7 +78,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
icon={IconPlugX}
title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/integrations" }}
action={{ label: tCommon("action.clearSearch"), href: "/manage/integrations" }}
/>
) : (
<NoResults
@@ -139,7 +140,7 @@ interface IntegrationItemProps {
}
const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
const t = await getScopedI18n("integration");
const tCommon = await getI18n("common");
const kindName = getIntegrationName(integration.kind);
const safeUrl = getSafeApplicationUrl(integration.url);
@@ -178,7 +179,7 @@ const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
variant="subtle"
color="gray"
size={44}
aria-label={t("page.list.action.edit", { name: integration.name })}
aria-label={tCommon("action.editNamed", { name: integration.name })}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
+16 -15
View File
@@ -35,7 +35,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
@@ -61,8 +61,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
return false;
}
});
const [t, session, shouldRunManageTour] = await Promise.all([
getScopedI18n("management.navbar"),
const [t, tEntities, session, shouldRunManageTour] = await Promise.all([
getI18n("management.navbar"),
getI18n("common.entity"),
sessionPromise,
shouldRunManageTourPromise,
]);
@@ -78,13 +79,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
icon: IconLayoutDashboardFilled,
href: "/manage/boards",
label: t("items.boards"),
label: tEntities("boards"),
"data-onboarding-tour-id": "manage-boards",
},
{
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
label: tEntities("apps"),
hidden: !appsAccess.canAccess,
iconProps: {
strokeWidth: 2.5,
@@ -94,20 +95,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
label: tEntities("integrations"),
hidden: !integrationsAccess.canAccess,
"data-onboarding-tour-id": "manage-integrations",
},
{
icon: IconApi,
href: "/manage/custom-widgets",
label: t("items.customWidgets"),
label: tEntities("customWidgets"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
label: t("items.searchEngies"),
label: tEntities("searchEngines"),
hidden: !session?.user.permissions.includes("search-engine-create"),
iconProps: {
strokeWidth: 2.5,
@@ -117,13 +118,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
label: tEntities("media"),
hidden: !session?.user.permissions.includes("media-upload"),
"data-onboarding-tour-id": "manage-medias",
},
{
icon: IconUserFilled,
label: t("items.users.label"),
label: tEntities("users"),
hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-users",
items: [
@@ -133,13 +134,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/users",
},
{
label: t("items.users.items.invites"),
label: tEntities("invites"),
icon: IconMailForward,
href: "/manage/users/invites",
hidden: !isProviderEnabled("credentials"),
},
{
label: t("items.users.items.groups"),
label: tEntities("groups"),
icon: IconUsersGroup,
href: "/manage/users/groups",
},
@@ -170,19 +171,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.logs"),
label: tEntities("logs"),
icon: IconBrandTablerFilled,
href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"),
},
{
label: t("items.tools.items.certificates"),
label: tEntities("certificates"),
icon: IconCertificate,
href: "/manage/tools/certificates",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.tasks"),
label: tEntities("tasks"),
icon: IconClipboardListFilled,
href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
@@ -11,7 +11,7 @@ interface CopyMediaProps {
}
export const CopyMedia = ({ media }: CopyMediaProps) => {
const t = useI18n();
const t = useI18n("media");
const url = typeof window !== "undefined" ? `${window.location.origin}/api/user-medias/${media.id}` : "";
@@ -23,7 +23,7 @@ export const CopyMedia = ({ media }: CopyMediaProps) => {
return (
<CopyButton value={url}>
{({ copy, copied }) => (
<Tooltip label={t("media.action.copy.label")} openDelay={500}>
<Tooltip label={t("action.copy.label")} openDelay={500}>
<ActionIcon onClick={copy} color={copied ? "teal" : "gray"} variant="subtle">
{copied ? <IconCheck size={16} stroke={1.5} /> : <IconCopy size={16} stroke={1.5} />}
</ActionIcon>
@@ -15,13 +15,13 @@ interface DeleteMediaProps {
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const t = useI18n("media");
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
const onClick = () => {
openConfirmModal({
title: t("media.action.delete.label"),
children: t.rich("media.action.delete.description", { bName: () => <b>{media.name}</b> }),
title: t("action.delete.label"),
children: t.rich("action.delete.description", { bName: () => <b>{media.name}</b> }),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({ id: media.id });
@@ -31,7 +31,7 @@ export const DeleteMedia = ({ media }: DeleteMediaProps) => {
};
return (
<Tooltip label={t("media.action.delete.label")} openDelay={500}>
<Tooltip label={t("action.delete.label")} openDelay={500}>
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
@@ -8,7 +8,7 @@ import { UploadMedia } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
export const UploadMediaButton = () => {
const t = useI18n();
const t = useI18n("media");
const onSettledAsync = async () => {
await revalidatePathActionAsync("/manage/medias");
};
@@ -17,7 +17,7 @@ export const UploadMediaButton = () => {
<UploadMedia onSettled={onSettledAsync} multiple>
{({ onClick, loading }) => (
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}
{t("action.upload.label")}
</Button>
)}
</UploadMedia>
@@ -54,14 +54,15 @@ export default async function MediaListPage(props: MediaListPageProps) {
return notFound();
}
const t = await getI18n();
const tMedia = await getI18n("media");
const [tCommon, tEntities] = await Promise.all([getI18n("common"), getI18n("common.entity")]);
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
const canUpload = session.user.permissions.includes("media-upload");
return (
<ManagePageLayout
title={t("media.plural")}
title={tEntities("media")}
primaryAction={
canUpload ? (
<ManageMobilePrimaryAction>
@@ -72,8 +73,8 @@ export default async function MediaListPage(props: MediaListPageProps) {
toolbar={
<Group>
<SearchInput
placeholder={`${t("media.search")}...`}
ariaLabel={t("media.search")}
placeholder={`${tMedia("search")}...`}
ariaLabel={tMedia("search")}
defaultValue={searchParams.search}
/>
{session.user.permissions.includes("media-view-all") && (
@@ -84,15 +85,15 @@ export default async function MediaListPage(props: MediaListPageProps) {
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
floatingPrimaryAction={canUpload}
>
{medias.length === 0 && <NoResults icon={IconPhoto} title={t("media.noResults.title")} />}
{medias.length === 0 && <NoResults icon={IconPhoto} title={tMedia("noResults.title")} />}
{medias.length > 0 && (
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh></TableTh>
<TableTh>{t("media.field.name")}</TableTh>
<TableTh>{t("media.field.size")}</TableTh>
<TableTh>{t("media.field.creator")}</TableTh>
<TableTh>{tCommon("field.name")}</TableTh>
<TableTh>{tMedia("field.size")}</TableTh>
<TableTh>{tMedia("field.creator")}</TableTh>
<TableTh></TableTh>
</TableTr>
</TableThead>
@@ -113,7 +114,7 @@ interface RowProps {
const Row = async ({ media }: RowProps) => {
const session = await auth();
const t = await getI18n();
const tMedia = await getI18n("media");
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
return (
@@ -145,7 +146,7 @@ const Row = async ({ media }: RowProps) => {
<TableTd w={64}>
<Group wrap="nowrap" gap="xs">
<CopyMedia media={media} />
<Tooltip label={t("media.action.open.label")} openDelay={500}>
<Tooltip label={tMedia("action.open.label")} openDelay={500}>
<ActionIcon
component="a"
href={createLocalImageUrl(media.id)}
@@ -1,10 +1,10 @@
import { Anchor, Center, Stack, Text, Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
export default async function NotFound() {
const t = await getScopedI18n("management.notFound");
const t = await getI18n("management.notFound");
return (
<Center h="100%">
<Stack align="center">
+41 -24
View File
@@ -3,7 +3,7 @@ import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -11,7 +11,7 @@ import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner";
export async function generateMetadata(): Promise<Metadata> {
const t = await getScopedI18n("management");
const t = await getI18n("management");
return {
title: createMetaTitle(t("metaTitle")),
@@ -20,7 +20,21 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function ManagementPage() {
const statistics = await api.home.getStats();
const t = await getScopedI18n("management.page.home");
const t = await getI18n("management.page.home");
const tEntities = await getI18n("common.entity");
const statisticEntities = {
app: "apps",
board: "boards",
group: "groups",
integration: "integrations",
invite: "invites",
media: "media",
searchEngine: "searchEngines",
user: "users",
} as const;
const getStatisticTitle = (titleKey: (typeof statistics)[number]["titleKey"]) => {
return tEntities(statisticEntities[titleKey]);
};
return (
<>
@@ -28,29 +42,32 @@ export default async function ManagementPage() {
<HeroBanner />
<Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{statistics.map((statistic) => (
<Card
component={Link}
href={statistic.path}
key={statistic.path}
aria-label={`${statistic.count} ${t(`statistic.${statistic.titleKey}`)}`}
>
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Text size="xl" fw={700}>
{statistic.count}
</Text>
<Stack gap={0}>
<Text c="dimmed" size="xs">
{t(`statisticLabel.${statistic.subtitleKey}`)}
{statistics.map((statistic) => {
const title = getStatisticTitle(statistic.titleKey);
return (
<Card
component={Link}
href={statistic.path}
key={statistic.path}
aria-label={`${statistic.count} ${title}`}
>
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Text size="xl" fw={700}>
{statistic.count}
</Text>
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
</Stack>
<Stack gap={0}>
<Text c="dimmed" size="xs">
{t(`statisticLabel.${statistic.subtitleKey}`)}
</Text>
<Text fw={600}>{title}</Text>
</Stack>
</Group>
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
</Group>
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
</Group>
</Card>
))}
</Card>
);
})}
</SimpleGrid>
</>
);
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import type { TranslationFunction } from "@homarr/translation";
import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { searchEngineManageSchema } from "@homarr/validation/search-engine";
@@ -17,7 +17,7 @@ import { searchEngineManageSchema } from "@homarr/validation/search-engine";
type FormType = z.infer<typeof searchEngineManageSchema>;
interface SearchEngineFormProps {
submitButtonTranslation: (t: TranslationFunction) => string;
submitButtonTranslation: (t: ScopedTranslationFunction<"common">) => string;
initialValues?: FormType;
handleSubmit: (values: FormType) => void;
isPending: boolean;
@@ -26,7 +26,8 @@ interface SearchEngineFormProps {
export const SearchEngineForm = (props: SearchEngineFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
const t = useI18n();
const t = useI18n("search.engine");
const tCommon = useI18n("common");
const [integrationData] = clientApi.integration.allThatSupportSearch.useSuspenseQuery();
@@ -46,14 +47,14 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
<Stack>
<Grid>
<Grid.Col span={{ base: 12, md: 8, lg: 9, xl: 10 }}>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("search.engine.field.name.label")} />
<TextInput {...form.getInputProps("name")} withAsterisk label={tCommon("field.name")} />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4, lg: 3, xl: 2 }}>
<TextInput
{...form.getInputProps("short")}
disabled={disableShort}
withAsterisk
label={t("search.engine.field.short.label")}
label={t("field.short.label")}
/>
</Grid.Col>
</Grid>
@@ -62,12 +63,12 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<Fieldset legend={t("page.edit.configControl")}>
<SegmentedControl
data={searchEngineTypes.map(
(type) =>
({
label: t(`search.engine.page.edit.searchEngineType.${type}`),
label: t(`page.edit.searchEngineType.${type}` as never),
value: type,
}) satisfies SegmentedControlItem,
)}
@@ -76,11 +77,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
{form.values.type === "generic" && (
<TextInput
{...form.getInputProps("urlTemplate")}
withAsterisk
label={t("search.engine.field.urlTemplate.label")}
/>
<TextInput {...form.getInputProps("urlTemplate")} withAsterisk label={t("field.urlTemplate.label")} />
)}
{form.values.type === "fromIntegration" && (
@@ -95,14 +92,14 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
)}
</Fieldset>
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
<Textarea {...form.getInputProps("description")} label={t("field.description.label")} />
<Group justify="end">
<Button variant="default" component={Link} href="/manage/search-engines">
{t("common.action.backToOverview")}
{tCommon("action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}>
{submitButtonTranslation(t)}
{submitButtonTranslation(tCommon)}
</Button>
</Group>
</Stack>
@@ -9,15 +9,15 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface SearchEngineDeleteButtonProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
}
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
const t = useScopedI18n("search.engine.page.delete");
const tList = useScopedI18n("search.engine.page.list.action");
const t = useI18n("search.engine.page.delete");
const actionT = useI18n("common.action");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
@@ -57,7 +57,7 @@ export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteBut
color="red"
size={44}
onClick={onClick}
aria-label={tList("delete", { name: searchEngine.name })}
aria-label={actionT("deleteNamed", { name: searchEngine.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
@@ -8,8 +8,8 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { searchEngineManageSchema } from "@homarr/validation/search-engine";
import { SearchEngineForm } from "../../_form";
@@ -19,13 +19,14 @@ interface SearchEngineEditFormProps {
}
export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps) => {
const t = useScopedI18n("search.engine.page.edit.notification");
const t = useI18n("search.engine.page.edit.notification");
const tCommon = useI18n("common");
const router = useRouter();
const { mutate, isPending } = clientApi.searchEngine.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
title: tCommon("notification.update.success"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/search-engines").then(() => {
@@ -34,7 +35,7 @@ export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps
},
onError: () => {
showErrorNotification({
title: t("error.title"),
title: tCommon("notification.update.error"),
message: t("error.message"),
});
},
@@ -50,7 +51,7 @@ export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps
[mutate, searchEngine.id],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
const submitButtonTranslation = useCallback((t: ScopedTranslationFunction<"common">) => t("action.save"), []);
return (
<SearchEngineForm
@@ -22,13 +22,13 @@ export default async function SearchEngineEditPage(props: SearchEngineEditPagePr
}
const searchEngine = await api.searchEngine.byId({ id: params.id });
const t = await getI18n();
const t = await getI18n("search.engine.page.edit");
return (
<ManageContainer>
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, searchEngine.name]])} nonInteractable={["edit"]} />
<Stack>
<Title>{t("search.engine.page.edit.title")}</Title>
<Title>{t("title")}</Title>
<SearchEngineEditForm searchEngine={searchEngine} />
</Stack>
</ManageContainer>
@@ -7,14 +7,14 @@ import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { searchEngineManageSchema } from "@homarr/validation/search-engine";
import { SearchEngineForm } from "../_form";
export const SearchEngineNewForm = () => {
const t = useScopedI18n("search.engine.page.create.notification");
const t = useI18n("search.engine.page.create.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.searchEngine.create.useMutation({
@@ -41,7 +41,7 @@ export const SearchEngineNewForm = () => {
[mutate],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
const submitButtonTranslation = useCallback((t: ScopedTranslationFunction<"common">) => t("action.create"), []);
return (
<SearchEngineForm
@@ -15,13 +15,13 @@ export default async function SearchEngineNewPage() {
notFound();
}
const t = await getI18n();
const t = await getI18n("search.engine.page.create");
return (
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{t("search.engine.page.create.title")}</Title>
<Title>{t("title")}</Title>
<SearchEngineNewForm />
</Stack>
</ManageContainer>
@@ -8,7 +8,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getSafeApplicationUrl } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
@@ -32,7 +32,8 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
const t = await getScopedI18n("search.engine");
const t = await getI18n("search.engine");
const tCommon = await getI18n("common");
const canCreate = session.user.permissions.includes("search-engine-create");
const canModify = session.user.permissions.includes("search-engine-modify-all");
const canDelete = session.user.permissions.includes("search-engine-full-all");
@@ -40,7 +41,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
return (
<ManageCollectionPage
title={t("page.list.title")}
title={tCommon("entity.searchEngines")}
ariaLabel={t("page.list.ariaLabel")}
itemCount={searchEngines.length}
emptyState={
@@ -49,7 +50,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
icon={IconSearch}
title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/search-engines" }}
action={{ label: tCommon("action.clearSearch"), href: "/manage/search-engines" }}
/>
) : (
<NoResults
@@ -105,7 +106,8 @@ interface SearchEngineItemProps {
}
const SearchEngineItem = async ({ searchEngine, canModify, canDelete }: SearchEngineItemProps) => {
const t = await getScopedI18n("search.engine");
const t = await getI18n("search.engine");
const actionT = await getI18n("common.action");
const isIntegration = searchEngine.type === "fromIntegration";
const previewUrl =
searchEngine.type === "generic" && searchEngine.urlTemplate !== null
@@ -158,7 +160,7 @@ const SearchEngineItem = async ({ searchEngine, canModify, canDelete }: SearchEn
variant="subtle"
color="gray"
size={44}
aria-label={t("page.list.action.edit", { name: searchEngine.name })}
aria-label={actionT("editNamed", { name: searchEngine.name })}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
@@ -5,7 +5,7 @@ import { IconExternalLink } from "@tabler/icons-react";
import { createDocumentationLink } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
@@ -16,7 +16,7 @@ interface AnalyticsSettingsProps {
}
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section");
const t = useI18n("management.page.settings.section");
return (
<SectionCard title={t("analytics.title")}>
@@ -6,7 +6,7 @@ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
@@ -17,7 +17,8 @@ interface AppearanceSettingsFormProps {
}
export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => {
const tAppearance = useScopedI18n("management.page.settings.section.appearance");
const tAppearance = useI18n("management.page.settings.section.appearance");
const tColorScheme = useI18n("common.colorScheme.options");
return (
<SectionCard title={tAppearance("title")}>
@@ -25,7 +26,7 @@ export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) =>
label={tAppearance("defaultColorScheme.label")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tAppearance(`defaultColorScheme.options.${scheme}`),
label: tColorScheme(scheme),
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={AppearanceCustomOption}
@@ -4,7 +4,7 @@ import { Switch, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { BoardSelect } from "~/components/board/board-select";
import { SectionCard } from "~/components/manage/section-card";
@@ -16,10 +16,11 @@ interface BoardSettingsFormProps {
}
export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormProps) => {
const tBoard = useScopedI18n("management.page.settings.section.board");
const tBoard = useI18n("management.page.settings.section.board");
const tEntities = useI18n("common.entity");
return (
<SectionCard title={tBoard("title")}>
<SectionCard title={tEntities("boards")}>
<BoardSelect
label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")}
@@ -3,7 +3,7 @@
import { Text } from "@mantine/core";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
@@ -14,7 +14,7 @@ interface CrawlingAndIndexingSettingsProps {
}
export const CrawlingAndIndexingSettings = ({ form }: CrawlingAndIndexingSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
const t = useI18n("management.page.settings.section.crawlingAndIndexing");
return (
<SectionCard title={t("title")}>
@@ -2,7 +2,7 @@
import type { UseFormReturnType } from "@homarr/form";
import type { SupportedLanguage } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { LanguageCombobox } from "~/components/language/language-combobox";
import { SectionCard } from "~/components/manage/section-card";
@@ -13,7 +13,7 @@ interface CultureSettingsFormProps {
}
export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
const tCulture = useScopedI18n("management.page.settings.section.culture");
const tCulture = useI18n("management.page.settings.section.culture");
const localeInputProps = form.getInputProps("defaultLocale");
return (
@@ -4,7 +4,7 @@ import { Select } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
@@ -15,7 +15,7 @@ interface SearchSettingsFormProps {
}
export const SearchSettingsForm = ({ form, selectableSearchEngines }: SearchSettingsFormProps) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const tSearch = useI18n("management.page.settings.section.search");
return (
<SectionCard title={tSearch("title")}>
@@ -12,7 +12,7 @@ import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { AnalyticsSettings } from "./analytics.settings";
@@ -64,8 +64,8 @@ interface SettingsFormProps {
}
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const tCommon = useI18n("common");
const tSettings = useI18n("management.page.settings");
const initialValues = buildInitialValues(initialData);
const initialValuesRef = useRef(initialValues);
@@ -81,7 +81,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
const saveSettingsMutation = clientApi.serverSettings.saveSettings.useMutation({
onError(error) {
showErrorNotification({ title: t("common.notification.update.error"), message: error.message });
showErrorNotification({ title: tCommon("notification.update.error"), message: error.message });
},
});
@@ -156,7 +156,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
form.resetDirty();
await revalidatePathActionAsync("/manage/settings");
showSuccessNotification({
title: t("common.notification.update.success"),
title: tCommon("notification.update.success"),
message: tSettings("notification.success.message"),
});
} finally {
@@ -183,10 +183,10 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isSubmitting} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
{tCommon("action.discard")}
</Button>
<Button loading={isSubmitting} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
{tCommon("action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
@@ -3,7 +3,7 @@
import { Switch } from "@mantine/core";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
@@ -13,10 +13,11 @@ interface UserSettingsFormProps {
}
export const UserSettingsForm = ({ form }: UserSettingsFormProps) => {
const tUser = useScopedI18n("management.page.settings.section.user");
const tUser = useI18n("management.page.settings.section.user");
const tEntities = useI18n("common.entity");
return (
<SectionCard title={tUser("title")}>
<SectionCard title={tEntities("users")}>
<Switch
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
label={tUser("enableGravatar.label")}
@@ -2,13 +2,13 @@ import { notFound } from "next/navigation";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { SettingsForm } from "./_components/settings-form";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const t = await getI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
@@ -27,7 +27,7 @@ export default async function SettingsPage() {
api.serverSettings.getAll(),
api.board.getPublicBoards(),
api.searchEngine.getSelectable({ withIntegrations: false }),
getScopedI18n("management.page.settings"),
getI18n("management.page.settings"),
]);
return (
@@ -9,8 +9,9 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
@@ -36,7 +37,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
},
});
const t = useScopedI18n("management.page.tool.api.tab.apiKey");
const t = useI18n("management.page.tool.api.tab.apiKey");
const handleDelete = useCallback(
(id: string) => {
openConfirmModal({
@@ -55,7 +56,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
() => [
{
accessorKey: "id",
header: t("table.header.id"),
header: invariantTechnicalLabels.id,
},
{
accessorKey: "user",
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff. Show more