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

@@ -3,7 +3,7 @@ import { Group, Stack, Tabs } from "@mantine/core";
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { CountBadge } from "@homarr/ui";
@@ -178,7 +178,7 @@ interface TabItemProps {
}
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
const t = useScopedI18n("permission");
const t = useI18n("permission");
return (
<Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}>
@@ -18,7 +18,7 @@ interface AccessSelectRowProps {
}
export const AccessSelectRow = ({ itemContent, permission, index, handleCountChange }: AccessSelectRowProps) => {
const tRoot = useI18n();
const tCommon = useI18n("common");
const { icons, getSelectData } = useAccessContext();
const form = useFormContext();
@@ -48,7 +48,7 @@ export const AccessSelectRow = ({ itemContent, permission, index, handleCountCha
/>
<Button size="xs" variant="subtle" onClick={handleRemove}>
{tRoot("common.action.remove")}
{tCommon("action.remove")}
</Button>
</Flex>
</TableTd>
@@ -3,7 +3,7 @@ import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, T
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { AccessQueryData } from "./access-settings";
@@ -25,8 +25,8 @@ export const GroupAccessForm = <TPermission extends string>({
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
);
const { openModal } = useModalAction(GroupSelectModal);
const t = useI18n();
const tPermissions = useScopedI18n("permission");
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
items: accessQueryData.groups.map(({ group, permission }) => ({
@@ -80,10 +80,10 @@ export const GroupAccessForm = <TPermission extends string>({
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{t("common.action.add")}
{tCommon("action.add")}
</Button>
<Button type="submit" loading={isPending}>
{t("permission.action.saveGroup")}
{tPermissions("action.saveGroup")}
</Button>
</Group>
</Stack>
@@ -19,7 +19,8 @@ interface GroupSelectFormType {
}
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
withPermissions: innerProps.withPermissions,
});
@@ -39,18 +40,18 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("groupId")}
label={t("group.action.select.label")}
label={tGroup("label")}
clearable
searchable
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={t("group.action.select.notFound")}
nothingFoundMessage={tGroup("notFound")}
limit={5}
data={groups
?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
@@ -58,7 +59,7 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
@@ -2,7 +2,7 @@ import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine
import type { GroupPermissionKey } from "@homarr/definitions";
import { getPermissionsWithChildren } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { AccessQueryData } from "./access-settings";
import { AccessDisplayRow } from "./access-table-rows";
@@ -19,7 +19,7 @@ export const InheritAccessTable = <TPermission extends string>({
mapPermissions,
fullAccessGroupPermission,
}: InheritTableProps<TPermission>) => {
const tPermissions = useScopedI18n("permission");
const tPermissions = useI18n("permission");
return (
<Stack pt="sm">
<Table>
@@ -3,7 +3,7 @@ import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThe
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
import type { AccessQueryData } from "./access-settings";
@@ -42,8 +42,8 @@ export const UsersAccessForm = <TPermission extends string>({
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
);
const { openModal } = useModalAction(UserSelectModal);
const t = useI18n();
const tPermissions = useScopedI18n("permission");
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
items: accessQueryData.users.map(({ user, permission }) => ({
@@ -102,10 +102,10 @@ export const UsersAccessForm = <TPermission extends string>({
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{t("common.action.add")}
{tCommon("action.add")}
</Button>
<Button type="submit" loading={isPending}>
{t("permission.action.saveUser")}
{tPermissions("action.saveUser")}
</Button>
</Group>
</Stack>
@@ -23,7 +23,8 @@ interface UserSelectFormType {
}
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({
providers: innerProps.allowedProviders,
});
@@ -44,7 +45,7 @@ export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps })
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
const currentUser = users?.find((user) => user.id === form.values.userId);
return (
@@ -52,13 +53,13 @@ export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps })
<Stack>
<Select
{...form.getInputProps("userId")}
label={t("user.action.select.label")}
label={tUser("label")}
searchable
clearable
leftSection={
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
}
nothingFoundMessage={t("user.action.select.notFound")}
nothingFoundMessage={tUser("notFound")}
renderOption={createRenderOption(users ?? [])}
limit={5}
data={users
@@ -67,7 +68,7 @@ export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps })
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
@@ -40,7 +40,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import {
@@ -97,7 +97,8 @@ export const AssistantConfigureBoardSettingsTool = ({
status,
toolCallId,
}: ToolCallMessagePartProps<ConfigureBoardSettingsArgs, ConfigureBoardSettingsResult>) => {
const t = useScopedI18n("common.assistant.configureBoardSettings");
const t = useI18n("assistant.configureBoardSettings");
const actionT = useI18n("common.action");
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
const boardId = args?.boardId ?? "";
const settings = clientApi.board.getBoardSettings.useQuery(
@@ -150,7 +151,7 @@ export const AssistantConfigureBoardSettingsTool = ({
<Stack gap="sm">
<Text size="sm">{t("loadErrorDescription")}</Text>
<Button variant="light" color="red" size="compact-sm" w="fit-content" onClick={() => settings.refetch()}>
{t("retry")}
{actionT("tryAgain")}
</Button>
</Stack>
</Alert>
@@ -199,7 +200,8 @@ const BoardSettingsForm = ({
onSubmit: (changes: AssistantBoardSettingsChanges) => void;
onCancel: () => void;
}) => {
const t = useScopedI18n("common.assistant.configureBoardSettings");
const t = useI18n("assistant.configureBoardSettings");
const actionT = useI18n("common.action");
const boardT = useI18n();
const [submitting, setSubmitting] = useState(false);
const currentValues = useMemo(() => toCurrentValues(current), [current]);
@@ -297,7 +299,7 @@ const BoardSettingsForm = ({
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
onClick={copy}
>
{copied ? t("copied") : t("copy")}
{copied ? t("copied") : actionT("copy")}
</Button>
</Tooltip>
)}
@@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import dynamic from "next/dynamic";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { AssistantWidgetRendererProvider } from "@homarr/widgets/assistant/context";
import { AssistantContext } from "./assistant-context";
@@ -73,7 +73,7 @@ const unavailableMessageKeys = {
} as const;
export const AssistantGate = ({ availability, children }: AssistantGateProps) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
if (availability === "enabled") {
return <EnabledAssistantRoot>{children}</EnabledAssistantRoot>;
@@ -26,7 +26,7 @@ import {
import type { z } from "zod/v4";
import { AppForm } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
import { getAssistantAskUserOptionKind } from "./assistant-ask-user";
@@ -54,7 +54,7 @@ export const getAssistantAppFormValues = (
) => (hasCompleteAssistantToolArguments(status) ? toAssistantAppValues(args) : null);
export const AssistantHumanToolError = () => {
const t = useScopedI18n("common.assistant.toolPreparationError");
const t = useI18n("assistant.toolPreparationError");
return (
<Alert color="red" variant="light" title={t("title")} icon={<IconAlertTriangle size={18} />}>
{t("description")}
@@ -68,7 +68,7 @@ export const AssistantAskUserTool = ({
addResult,
status,
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
const t = useScopedI18n("common.assistant.askUser");
const t = useI18n("assistant.askUser");
const [showOther, setShowOther] = useState(false);
const [other, setOther] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -272,7 +272,7 @@ export const AssistantConfigureAppTool = ({
status,
toolCallId,
}: ToolCallMessagePartProps<ConfigureAppArgs, AppValues>) => {
const t = useScopedI18n("common.assistant.configureApp");
const t = useI18n("assistant.configureApp");
const [submitting, setSubmitting] = useState(false);
const initialValues = getAssistantAppFormValues(args, status);
const autoConfirming = useAssistantAutomaticAction({
@@ -131,7 +131,7 @@ import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useConfirmModal } from "@homarr/modals";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
@@ -218,7 +218,7 @@ const ContextDirectiveChip = ({
label,
iconUrl,
}: DirectiveChipProps & { iconUrl?: string }) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt;
const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl);
const translationKey = getAssistantDirectiveTranslationKey(directiveType);
@@ -291,7 +291,7 @@ const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">
);
const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => {
const t = useScopedI18n("common.assistant.image");
const t = useI18n("assistant.image");
const safeSource = getSafeAssistantMarkdownImageSource(src);
if (!safeSource) return null;
@@ -343,7 +343,7 @@ const AssistantSyntaxHighlighter = ({ code, components, language }: SyntaxHighli
};
const MermaidDiagram = ({ code, components }: SyntaxHighlighterProps) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const colorScheme = useComputedColorScheme("light");
const diagramId = `assistant-mermaid-${useId().replaceAll(/[^A-Za-z0-9_-]/gu, "")}`;
const [rendered, setRendered] = useState<{ code: string; svg?: string; failed?: boolean }>({ code });
@@ -541,7 +541,7 @@ const AttachmentPreview = () => {
};
const Attachment = ({ removable = false }: { removable?: boolean }) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const attachment = useAuiState((state) => state.attachment);
return (
<AttachmentPrimitive.Root className={classes.attachment}>
@@ -583,7 +583,7 @@ const ReasoningPart = (_props: ReasoningMessagePartProps) => (
);
const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
if (status.type !== "running") return null;
return (
@@ -622,7 +622,7 @@ const SourcePart = (source: SourceMessagePartProps) => {
};
const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const downloadable = data.startsWith("data:");
const displayName = filename ?? mimeType;
return (
@@ -649,7 +649,7 @@ const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => {
};
const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const source = getSafeAssistantAttachmentImageSource(
image,
typeof window === "undefined" ? undefined : window.location.origin,
@@ -814,7 +814,7 @@ const getToolResultNavigation = (result: unknown) => {
};
const ToolResultNavigation = ({ result }: { result: unknown }) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const router = useRouter();
const links = getToolResultNavigation(result);
@@ -870,7 +870,7 @@ const ToolPart = ({
respondToApproval,
timing,
}: ToolCallMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const compact = useContext(AgentTraceToolContext);
const [opened, setOpened] = useState(false);
const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null);
@@ -1048,7 +1048,7 @@ const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => (
);
const AssistantChainOfThought = ({ children }: { children?: ReactNode }) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const contentId = useId();
const { collapsed: preferredCollapsed, setCollapsed: setPreferredCollapsed } = useContext(ReasoningVisibilityContext);
const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed);
@@ -1083,7 +1083,7 @@ const AssistantChainOfThought = ({ children }: { children?: ReactNode }) => {
};
const BranchPicker = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
return (
<BranchPickerPrimitive.Root hideWhenSingleBranch className={classes.branchPicker}>
<BranchPickerPrimitive.Previous asChild>
@@ -1116,7 +1116,7 @@ const downloadAssistantMarkdown = (markdown: string, filename: string) => {
};
const AssistantMessageActions = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const aui = useAui();
const exportMessage = () => {
const message = aui.message().getState();
@@ -1129,7 +1129,6 @@ const AssistantMessageActions = () => {
});
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
};
return (
<Box className={classes.messageActions}>
<ActionBarPrimitive.Root
@@ -1211,7 +1210,7 @@ const AssistantMessageActions = () => {
};
const UserMessageActions = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
return (
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className={classes.userActions}>
<Group gap={2}>
@@ -1235,14 +1234,15 @@ const UserMessageActions = () => {
};
const EditComposer = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const actionT = useI18n("common.action");
return (
<ComposerPrimitive.Root className={classes.editComposer}>
<ComposerPrimitive.Input className={classes.editComposerInput} rows={2} aria-label={t("editMessage")} />
<Group gap="xs" justify="flex-end">
<ComposerPrimitive.Cancel asChild>
<Button size="compact-sm" variant="default">
{t("cancelEdit")}
{actionT("cancel")}
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
@@ -1361,7 +1361,7 @@ const ConversationTurn = ({
threadId?: string;
turn: AssistantConversationTurnUsage;
}) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const detailsId = useId();
const [opened, setOpened] = useState(false);
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
@@ -1657,7 +1657,7 @@ const ConversationTurn = ({
};
const ConversationContextBreakdown = ({ breakdown }: { breakdown: AssistantContextBreakdown }) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const hasContext = breakdown.percentage !== undefined;
const categories = [
{ label: t("usage.input"), value: breakdown.inputTokens },
@@ -1715,7 +1715,7 @@ const ConversationContextBreakdown = ({ breakdown }: { breakdown: AssistantConte
};
const ConversationContext = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const [opened, setOpened] = useState(false);
const messages = useAuiState((state) => state.thread.messages);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
@@ -1847,7 +1847,8 @@ const ConversationContext = () => {
);
};
const RuntimeError = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const actionT = useI18n("common.action");
return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className={classes.messageError}>
@@ -1868,7 +1869,7 @@ const RuntimeError = () => {
w="fit-content"
leftSection={<IconRefresh size={14} />}
>
{t("responseError.retry")}
{actionT("tryAgain")}
</Button>
</ActionBarPrimitive.Reload>
</Stack>
@@ -1879,7 +1880,7 @@ const RuntimeError = () => {
};
const WebSearchActivity = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const metadata = useAuiState((state) => state.message.metadata);
const telemetry = getAssistantTelemetry(metadata);
if (!telemetry) return null;
@@ -1997,7 +1998,8 @@ const assistantThreadMessageComponents = { UserMessage, AssistantMessage };
const HistorySelectContext = createContext<() => void>(() => undefined);
const ThreadListItem = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const actionT = useI18n("common.action");
const aui = useAui();
const { openConfirmModal } = useConfirmModal();
const onSelect = useContext(HistorySelectContext);
@@ -2057,7 +2059,7 @@ const ThreadListItem = () => {
title: t("deleteConversation.title"),
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
confirmProps: { color: "red" },
labels: { confirm: t("delete"), cancel: t("cancel") },
labels: { confirm: actionT("delete"), cancel: actionT("cancel") },
onConfirm: async () => {
try {
await aui.threadListItem().delete();
@@ -2103,7 +2105,7 @@ const ThreadListItem = () => {
variant="subtle"
color="gray"
size="sm"
aria-label={t("cancel")}
aria-label={actionT("cancel")}
disabled={renaming}
onClick={() => setRenameOpened(false)}
>
@@ -2154,8 +2156,8 @@ const ThreadListItem = () => {
variant="subtle"
color="red"
size="sm"
title={t("delete")}
aria-label={t("delete")}
title={actionT("delete")}
aria-label={actionT("delete")}
onClick={deleteConversation}
>
<IconTrash size={14} />
@@ -2179,7 +2181,7 @@ const ThreadListItem = () => {
};
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase();
const threadItems = useAuiState((state) => state.threads.threadItems);
@@ -2233,7 +2235,7 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
};
const ConversationHistory = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const [opened, setOpened] = useState(false);
return (
<Popover
@@ -2272,7 +2274,7 @@ const ConversationHistory = () => {
};
const AutoApprovalControl = () => {
const t = useScopedI18n("common.assistant.autoApproval");
const t = useI18n("assistant.autoApproval");
const [opened, setOpened] = useState(false);
const { enabled, setEnabled } = useAssistantAutoApproval();
@@ -2331,7 +2333,7 @@ const ViewRefreshAction = ({
isRefreshing,
onRefresh,
}: Pick<AssistantConversationControls, "isRefreshing" | "onRefresh">) => {
const t = useScopedI18n("common.assistant.refresh");
const t = useI18n("assistant.refresh");
const refresh = async () => {
try {
@@ -2360,7 +2362,7 @@ const ViewRefreshAction = ({
};
const EmptyThread = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
return (
<ThreadPrimitive.Empty>
<Box className={classes.empty}>
@@ -2461,7 +2463,7 @@ const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: numbe
};
const ComposerTriggers = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const aui = useAui();
const mentionViewportRef = useRef<HTMLDivElement>(null);
const slashViewportRef = useRef<HTMLDivElement>(null);
@@ -2604,7 +2606,7 @@ const RuntimeControls = ({
onModelChange,
onReasoningChange,
}: ComposerProps) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const [modelSearch, setModelSearch] = useState("");
const selectedModel = models.find((model) => model.id === modelId);
const normalizedModelSearch = modelSearch.trim().toLocaleLowerCase();
@@ -2759,7 +2761,7 @@ const providerQuotaColors = {
} as const;
const HomarrProviderQuota = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const locale = useCurrentIntlLocale();
const preferences = useAssistantPreferences();
const quota = preferences.quota;
@@ -2882,7 +2884,7 @@ const HomarrProviderQuota = () => {
};
const Composer = (props: ComposerProps) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const preferences = useAssistantPreferences();
const running = useAuiState((state) => state.thread.isRunning);
const hasPendingAction = props.pendingAction !== undefined;
@@ -2987,7 +2989,7 @@ const Composer = (props: ComposerProps) => {
};
const usePendingActionCopy = (action: AssistantPendingAction | undefined) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
if (!action) return undefined;
if (action.kind === "question") {
return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") };
@@ -3004,7 +3006,7 @@ const usePendingActionCopy = (action: AssistantPendingAction | undefined) => {
};
const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const copy = usePendingActionCopy(pendingAction);
if (!copy || pendingAction?.kind === "question") return null;
@@ -3039,7 +3041,7 @@ const PendingQuestionDock = ({
pendingAction: AssistantPendingAction | undefined;
setTarget: (target: HTMLDivElement | null) => void;
}) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
if (pendingAction?.kind !== "question") return null;
return (
@@ -3073,7 +3075,7 @@ const AssistantActivityBar = ({
| "onDismissActivity"
| "activityDismissed"
>) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const pendingCopy = usePendingActionCopy(pendingAction);
const needsApproval = pendingAction !== undefined || latestStatus?.type === "requires-action";
const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled";
@@ -3161,7 +3163,7 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
}
const AssistantPanelIdentity = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const threadTitle = useAuiState((state) => state.threadListItem.title);
return (
@@ -3197,7 +3199,7 @@ export const AssistantConversationSurface = ({
onMinimize,
onDismiss,
}: AssistantConversationSurfaceProps) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const reducedMotion = useReducedMotion();
const [questionPortalTarget, setQuestionPortalTarget] = useState<HTMLDivElement | null>(null);
const [reasoningCollapsed, setReasoningCollapsed] = useState(false);
@@ -3343,7 +3345,7 @@ export const AssistantPanel = ({
onModelChange,
onReasoningChange,
}: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const aui = useAui();
const previousFocusRef = useRef<HTMLElement | null>(null);
const panelRef = useRef<HTMLDialogElement>(null);
@@ -30,7 +30,7 @@ import { createId } from "@homarr/common";
import { assistantHomarrProviderTokenHeader, hotkeys } from "@homarr/definitions";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
import { closeSpotlight } from "@homarr/spotlight/store";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
import { createWorkshopClient } from "~/components/workshop/workshop-client";
@@ -135,7 +135,7 @@ const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapt
};
const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, {
staleTime: 10 * 60_000,
});
@@ -454,7 +454,7 @@ const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapte
});
const AssistantThreadRuntime = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const aui = useAui();
const localThreadId = useAuiState((state) => state.threadListItem.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
@@ -652,7 +652,7 @@ const AssistantPreferenceSync = () => {
};
const AssistantRuntimeEvents = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
useAuiEvent("composer.attachmentAddError", ({ message }) => {
showErrorNotification({
title: t("attachments.errorTitle"),
@@ -679,7 +679,7 @@ const getNotificationKey = (message: ThreadMessage | undefined) => {
};
const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const [opened, setOpened] = useState(false);
const [activityDismissed, setActivityDismissed] = useState(true);
const [unreadCount, setUnreadCount] = useState(0);
@@ -34,7 +34,7 @@ import {
} from "./assistant-runtime-provider";
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/translation/client", () => ({ useI18n: () => (key: string) => key }));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -6,7 +6,7 @@ import type { AssistantRuntime, AttachmentAdapter, ThreadListRuntime, Toolkit }
import { AssistantRuntimeProvider, INTERNAL, Tools, useAui, useAuiEvent } from "@assistant-ui/react";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface AssistantRuntimeProviderWithToolsProps extends PropsWithChildren {
runtime: AssistantRuntime;
@@ -437,7 +437,7 @@ const getClipboardAttachmentName = (file: File, index: number) => {
};
const AssistantComposerSurfaceEvents = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
useAuiEvent("composer.attachmentAddError", ({ message }) => {
showErrorNotification({ title: t("attachments.errorTitle"), message });
});
@@ -445,7 +445,7 @@ const AssistantComposerSurfaceEvents = () => {
};
export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const aui = useAui();
const instanceId = useId();
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
@@ -3,14 +3,14 @@
import { useMemo } from "react";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
/**
* Keeps the assistant discoverable in the spotlight when it is unavailable, without loading the
* assistant runtime. Lives apart from `assistant-provider` so the disabled path stays cheap.
*/
export const useRegisterAssistantSpotlightPlaceholder = (description: string) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
const spotlightItem = useMemo(
() => ({
id: "homarr-assistant",
@@ -3,7 +3,7 @@
import { Group, Kbd, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconArrowUp, IconRobot } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface AssistantPromptInteractionOptions {
sendPrompt: (prompt: string) => boolean;
@@ -13,7 +13,7 @@ interface AssistantPromptInteractionOptions {
}
const AssistantPromptDetail = () => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
return (
<Group px="md" py="sm" wrap="nowrap">
<ThemeIcon color="red" variant="light" radius="xl">
@@ -30,7 +30,7 @@ const AssistantPromptDetail = () => {
};
const AssistantPromptAction = ({ hasPrompt }: { hasPrompt: boolean }) => {
const t = useScopedI18n("common.assistant");
const t = useI18n("assistant");
return (
<Group w="100%" px="md" py="xs" justify="space-between" wrap="nowrap" opacity={hasPrompt ? 1 : 0.6}>
<Group gap="sm" wrap="nowrap">
@@ -7,11 +7,12 @@ import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react"
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { getWidgetName } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -66,7 +67,8 @@ export const AssistantConfigureWidgetTool = ({
status,
toolCallId,
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
const t = useScopedI18n("common.assistant.configureWidget");
const t = useI18n("assistant.configureWidget");
const actionT = useI18n("common.action");
const fullT = useI18n();
const { data: session } = useSession();
const settings = useSettings();
@@ -116,7 +118,7 @@ export const AssistantConfigureWidgetTool = ({
{cancelled ? t("cancelled") : t("ready")}
</Text>
<Text size="sm" fw={600} truncate>
{args?.kind ? fullT(`widget.${args.kind}.name`) : t("title")}
{args?.kind ? getWidgetName(args.kind, fullT) : t("title")}
</Text>
</Box>
</Box>
@@ -149,7 +151,7 @@ export const AssistantConfigureWidgetTool = ({
<Stack gap="sm">
<Text size="sm">{t("loadErrorDescription")}</Text>
<Button variant="light" color="red" size="compact-sm" w="fit-content" onClick={() => integrations.refetch()}>
{t("retry")}
{actionT("tryAgain")}
</Button>
</Stack>
</Alert>
@@ -174,7 +176,7 @@ export const AssistantConfigureWidgetTool = ({
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(args.kind, titleT)}`,
},
);
@@ -246,7 +248,7 @@ export const AssistantConfigureWidgetTool = ({
<WidgetIcon size={20} />
</ThemeIcon>
<Box miw={0} flex={1}>
<Text fw={700}>{fullT(`widget.${args.kind}.name`)}</Text>
<Text fw={700}>{getWidgetName(args.kind, fullT)}</Text>
<Text size="sm" c="dimmed">
{args.summary}
</Text>
@@ -3,7 +3,7 @@
import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core";
import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { BackupAnalysis } from "./types";
import { PREVIEW_TABLE_KEYS } from "./types";
@@ -13,10 +13,25 @@ interface BackupPreviewPanelProps {
}
export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.preview");
const t = useI18n("management.page.tool.backup.restore.preview");
const tEntities = useI18n("common.entity");
const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString();
const pendingCount = analysis.migrations.pending.length;
const hasPendingMigrations = pendingCount > 0;
const entityKeys = {
app: "apps",
board: "boards",
integration: "integrations",
item: undefined,
media: "media",
search_engine: "searchEngines",
user: "users",
} as const;
const getEntityLabel = (key: (typeof PREVIEW_TABLE_KEYS)[number]) => {
const entityKey = entityKeys[key];
if (entityKey) return tEntities(entityKey);
return t(`entities.${key}` as never);
};
return (
<Stack gap="md">
@@ -47,7 +62,7 @@ export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
<Table.Tbody>
{PREVIEW_TABLE_KEYS.map((key) => (
<Table.Tr key={key}>
<Table.Td>{t(`entities.${key}` as never)}</Table.Td>
<Table.Td>{getEntityLabel(key)}</Table.Td>
<Table.Td ta="right">
<Group gap={4} justify="flex-end">
{(analysis.counts[key] ?? 0) > 0 && <IconCheck size={14} color="var(--mantine-color-green-6)" />}
@@ -64,7 +79,7 @@ export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
{analysis.boardNames.length > 0 && (
<Group gap="xs" wrap="wrap">
<Text size="sm" fw={500}>
{t("boards")}:
{tEntities("boards")}:
</Text>
{analysis.boardNames.map((name) => (
<Badge key={name} variant="light" size="sm">
@@ -8,7 +8,7 @@ import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } fro
import "@mantine/dropzone/styles.css"; // oxlint-disable-line import/no-unassigned-import
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { RestoreStep } from "./types";
import { BackupPreviewPanel } from "./backup-preview-panel";
@@ -103,7 +103,8 @@ export const waitForServerReadinessAsync = async ({
};
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
const t = useScopedI18n("management.page.tool.backup.restore");
const t = useI18n("management.page.tool.backup.restore");
const tCommon = useI18n("common");
const [file, setFile] = useState<FileWithPath | null>(null);
const [step, setStep] = useState<RestoreStep>("upload");
const [importError, setImportError] = useState<string | null>(null);
@@ -330,7 +331,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
</Alert>
<Group>
<Button variant="subtle" onClick={handleClear}>
{t("tryAgain")}
{tCommon("action.tryAgain")}
</Button>
</Group>
</Stack>
@@ -3,7 +3,7 @@
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { MigrationProgress } from "./use-backup-analysis";
@@ -12,7 +12,7 @@ interface MigrationProgressPanelProps {
}
export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.migration");
const t = useI18n("management.page.tool.backup.restore.migration");
return (
<Paper p="md" radius="md" bg="var(--mantine-color-dark-7)">
@@ -2,7 +2,7 @@
import { IconDatabaseImport } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
@@ -13,17 +13,19 @@ interface RestoreConfirmationProps {
}
export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.confirm");
const t = useI18n("management.page.tool.backup.restore.confirm");
const tBackup = useI18n("management.page.tool.backup");
const tCommon = useI18n("common");
return (
<DangerousActionConfirmation
title={t("title")}
warningTitle={t("warningTitle")}
warningBody={t("warningBody")}
typePrompt={t("typePrompt", { phrase: "I understand" })}
typePrompt={tBackup("confirm.typePrompt", { phrase: "I understand" })}
submitLabel={t("submit")}
submitIcon={<IconDatabaseImport size={16} />}
cancelLabel={t("cancel")}
cancelLabel={tCommon("action.cancel")}
onConfirm={onConfirm}
onCancel={onCancel}
disabled={disabled}
@@ -7,16 +7,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@homarr/translation/client", () => {
const translations: Record<string, string> = {
"progress.title": "Restoring database...",
"progress.restarting": "Restarting server",
"timeout.title": "Server did not come back online",
"timeout.message": "Retry the connection check or refresh this page manually.",
tryAgain: "Try again",
"docker.action.refresh.label": "Refresh",
"management.page.tool.backup.restore.progress.title": "Restoring database...",
"management.page.tool.backup.restore.progress.restarting": "Restarting server",
"management.page.tool.backup.restore.timeout.title": "Server did not come back online",
"management.page.tool.backup.restore.timeout.message": "Retry the connection check or refresh this page manually.",
"common.action.tryAgain": "Try again",
"common.action.refresh": "Refresh",
};
return {
useScopedI18n: () => (key: string) => translations[key] ?? key,
useI18n: () => (key: string) => translations[key] ?? key,
useI18n: (namespace?: string) => (key: string) => {
const fullKey = namespace ? `${namespace}.${key}` : key;
return translations[fullKey] ?? key;
},
};
});
@@ -4,7 +4,7 @@ import { Button, Group, Loader, Paper, Stack, Text, ThemeIcon } from "@mantine/c
import { useReducedMotion } from "@mantine/hooks";
import { IconAlertTriangle, IconCheck, IconDatabaseImport, IconRefresh } from "@tabler/icons-react";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
export type RestoreProgressStatus = "restoring" | "restarting" | "timedOut";
@@ -16,8 +16,8 @@ interface RestoreProgressPanelProps {
}
export const RestoreProgressPanel = ({ active, status, onRetry, onReload }: RestoreProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore");
const tRoot = useI18n();
const t = useI18n("management.page.tool.backup.restore");
const tCommon = useI18n("common");
const reduceMotion = useReducedMotion();
if (!active) return null;
@@ -66,10 +66,10 @@ export const RestoreProgressPanel = ({ active, status, onRetry, onReload }: Rest
{isTimedOut ? (
<Group>
<Button variant="light" onClick={onRetry} leftSection={<IconRefresh size={16} />}>
{t("tryAgain")}
{tCommon("action.tryAgain")}
</Button>
<Button variant="default" onClick={onReload}>
{tRoot("docker.action.refresh.label")}
{tCommon("action.refresh")}
</Button>
</Group>
) : null}
@@ -3,7 +3,7 @@
import { useCallback, useRef, useState } from "react";
import { useTiks } from "@rexa-developer/tiks/react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
import { PREVIEW_TABLE_KEYS } from "./types";
@@ -60,7 +60,7 @@ export interface MigrationProgress {
}
export const useBackupAnalysis = () => {
const t = useScopedI18n("management.page.tool.backup.restore");
const t = useI18n("management.page.tool.backup.restore");
const sounds = useTiks({ theme: "soft", volume: 0.12, respectReducedMotion: true });
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
const [loading, setLoading] = useState(false);
@@ -5,7 +5,7 @@ import { IconBox, IconLayoutDashboard, IconPlug, IconResize } from "@tabler/icon
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { useBoardAddActions } from "./use-board-add-actions";
import classes from "./board-empty-state.module.css";
@@ -14,7 +14,7 @@ export const BoardEmptyState = () => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions();
const t = useScopedI18n("board.emptyState");
const t = useI18n("board.emptyState");
const hasBoardContent = board.items.length > 0 || board.sections.some((section) => section.kind !== "empty");
if (!isEditMode || hasBoardContent) return null;
@@ -7,7 +7,7 @@ import { IconCheck, IconChevronDown, IconChecklist } from "@tabler/icons-react";
import { useIntegrationsWithUseAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { getBoardSetupProgress } from "./board-setup-progress";
@@ -23,7 +23,8 @@ export const BoardSetupChecklist = () => {
key: `homarr-board-setup-checklist-${board.id}`,
defaultValue: false,
});
const t = useScopedI18n("board.setupChecklist");
const t = useI18n("board.setupChecklist");
const tCommon = useI18n("common.action");
const trackSetup = useSetupAnalytics();
const progress = getBoardSetupProgress({
itemKinds: board.items.map((item) => item.kind),
@@ -74,14 +75,14 @@ export const BoardSetupChecklist = () => {
complete={progress.steps.content}
label={t("step.content.label")}
description={t("step.content.description")}
actionLabel={t("step.content.action")}
actionLabel={tCommon("add")}
onAction={addWidget}
/>
<ChecklistStep
complete={progress.steps.app}
label={t("step.app.label")}
description={t("step.app.description")}
actionLabel={t("step.app.action")}
actionLabel={tCommon("add")}
onAction={addApp}
/>
<ChecklistStep
@@ -13,6 +13,7 @@ import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { WidgetError } from "@homarr/widgets/errors";
@@ -178,7 +179,7 @@ const LoadedBoardItemContent = ({
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
const { width: viewportWidth, height: viewportHeight } = viewportSize;
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = t(`widget.${item.kind}.name`);
const widgetName = getWidgetName(item.kind, t);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`;
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
@@ -4,9 +4,10 @@ import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@table
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { SectionItem } from "~/app/[locale]/boards/_types";
@@ -34,7 +35,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const { data: session } = useSession();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const tItem = useI18n("item");
const t = useI18n();
const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal();
@@ -43,7 +44,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
useItemActions();
const { integrations: integrationData, section } = useSectionContext();
const settings = useSettings();
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
// Reset error boundary on next render if item has been edited
useEffect(() => {
@@ -92,7 +93,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
},
{
title(translate) {
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
return `${translate("item.edit.title")} - ${getWidgetName(item.kind, translate)}`;
},
},
);
@@ -6,7 +6,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getRootSectionLane } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout";
@@ -53,9 +53,9 @@ type MoveTargetCandidate = Omit<MoveTarget, "label"> & {
};
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tLandmark = useScopedI18n("board.landmark");
const tContainer = useScopedI18n("section.container");
const tMoveResize = useScopedI18n("item.moveResize");
const tLandmark = useI18n("board.landmark");
const tContainer = useI18n("section.container");
const tMoveResize = useI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry),
@@ -24,7 +24,6 @@ vi.mock("@homarr/translation/client", () => ({
};
return translations[key] ?? key;
},
useScopedI18n: () => (key: string) => key,
}));
vi.mock("@homarr/definitions", async () => {
@@ -28,6 +28,7 @@ import {
getBoardLaneColumnCount,
getIconUrl,
getIntegrationName,
getWidgetName,
getRootSectionLane,
widgetIntegrationSupport,
widgetKinds,
@@ -144,14 +145,24 @@ const ItemSelectModalContent = ({
widgetKinds
.filter((kind) => kind !== "customApi")
.map((kind) => {
let description: string;
if (kind === "mediaMissing") {
description = t("widget.mediaMissing.description", {
radarr: getIntegrationName("radarr"),
sonarr: getIntegrationName("sonarr"),
});
} else {
description = t(`widget.${kind}.description`);
}
return {
kind,
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
(integration) => integration !== "mock",
),
icon: widgetCatalogIcons[kind],
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
name: getWidgetName(kind, t),
description,
};
})
.sort((itemA, itemB) => {
@@ -324,7 +335,7 @@ const ItemSelectModalContent = ({
targetSectionId: effectiveSectionId ?? undefined,
advancedOptions,
});
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return;
trackSetup("widget-completed", {
entryPoint: "board",
intent: kind,
@@ -337,7 +348,7 @@ const ItemSelectModalContent = ({
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
},
);
};
@@ -458,7 +469,7 @@ const ItemSelectModalContent = ({
<Group gap="xs">
<IconApi size={14} />
<Text size="xs" fw={600}>
{t("customWidget.page.list.title")}
{t("common.entity.customWidgets")}
</Text>
</Group>
}
@@ -643,7 +654,7 @@ const ItemSelectModalFrame = ({
canCreateIntegration={canCreateIntegration}
initialSearch={
innerProps.initialWidgetKind
? t(`widget.${innerProps.initialWidgetKind}.name`)
? getWidgetName(innerProps.initialWidgetKind, t)
: innerProps.initialIntegrationKind
? getIntegrationName(innerProps.initialIntegrationKind)
: undefined
@@ -16,12 +16,14 @@ import { useTimeAgo } from "@homarr/common";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { getWidgetName } from "@homarr/definitions";
import { translateIfNecessary } from "@homarr/translation";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useAdvancedFocus } from "../advanced-focus/context";
@@ -51,8 +53,9 @@ export const WidgetContextMenu = ({
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { updateAndPersistBoard } = usePersistBoard(board);
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const tMenu = useI18n("item.menu.label");
const tCommon = useI18n("common.action");
const settings = useSettings();
const { openModal } = useModalAction(LazyWidgetEditModal);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
@@ -189,7 +192,9 @@ export const WidgetContextMenu = ({
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
{
title: (fn: any) => `${fn("item.edit.title")} - ${getWidgetName(item.kind, fn)}`,
},
);
}, [
definition,
@@ -250,8 +255,7 @@ export const WidgetContextMenu = ({
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
{t(`${getWidgetOptionTranslationNamespace(item.kind, key)}.label` as never)}
</Menu.CheckboxItem>
))}
</>
@@ -286,7 +290,7 @@ export const WidgetContextMenu = ({
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")}
{tCommon("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
matchesQuery={matchesWidgetQuery}
@@ -5,7 +5,7 @@ import { Avatar, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions";
import { getIconUrl, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { loadWidgetDefinition } from "@homarr/widgets/manifest";
@@ -34,7 +34,7 @@ const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProp
const definition = use(loadWidgetDefinition(item.kind));
const WidgetIcon = definition.icon;
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const displayName = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
const connectedIntegrations = (integrations ?? []).filter((integration) =>
item.integrationIds.includes(integration.id),
@@ -17,7 +17,7 @@ interface InnerProps {
export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }) => {
const utils = clientApi.useUtils();
const t = useI18n();
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.board.renameBoard.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({
@@ -50,20 +50,20 @@ export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput label={t("board.field.name.label")} {...form.getInputProps("name")} data-autofocus />
<TextInput label={tCommon("field.name")} {...form.getInputProps("name")} data-autofocus />
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.confirm")}
{tCommon("action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("board.setting.section.dangerZone.action.rename.modal.title"),
defaultTitle: (t) => t("board.setting.section.dangerZone.action.rename.label"),
});
type FormType = Omit<z.infer<typeof boardRenameSchema>, "id">;
@@ -5,7 +5,7 @@ import combineClasses from "clsx";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { SectionGrid } from "./grid/section-grid";
@@ -25,8 +25,8 @@ interface Props {
export const BoardContainerSection = ({ section }: Props) => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const t = useScopedI18n("section.container");
const tAll = useI18n();
const t = useI18n("section.container");
const tSection = useI18n("section");
const options = section.options;
const { open: openAllInNewTabs, isLoading: areAppsLoading } = useOpenSectionApps(
section.id,
@@ -137,7 +137,7 @@ export const BoardContainerSection = ({ section }: Props) => {
radius="sm"
loading={areAppsLoading}
onClick={openAllInNewTabs}
aria-label={tAll("section.action.openAllInNewTabsFor", { name: label })}
aria-label={tSection("action.openAllInNewTabsFor", { name: label })}
>
<IconExternalLink size={16} />
</ActionIcon>
@@ -15,7 +15,7 @@ interface ModalProps {
}
export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const tSection = useI18n("section");
const theme = useMantineTheme();
const form = useZodForm(containerSectionOptionsSchema.unwrap(), {
mode: "controlled",
@@ -30,28 +30,28 @@ export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps
})}
>
<Stack>
<TextInput label={t("section.container.option.title.label")} data-autofocus {...form.getInputProps("title")} />
<TextInput label={tSection("container.option.title.label")} data-autofocus {...form.getInputProps("title")} />
<Switch
label={t("section.option.showLabel.label")}
description={t("section.option.showLabel.description")}
label={tSection("option.showLabel.label")}
description={tSection("option.showLabel.description")}
{...form.getInputProps("showLabel", { type: "checkbox" })}
/>
<Switch
label={t("section.option.collapsible.label")}
description={t("section.option.collapsible.description")}
label={tSection("option.collapsible.label")}
description={tSection("option.collapsible.description")}
{...form.getInputProps("collapsible", { type: "checkbox" })}
/>
<Switch
label={t("section.option.showOpenAll.label")}
description={t("section.option.showOpenAll.description")}
label={tSection("option.showOpenAll.label")}
description={tSection("option.showOpenAll.description")}
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
/>
<TextMultiSelect
label={t("section.container.option.customCssClasses.label")}
label={tSection("container.option.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
/>
<ColorInput
label={t("section.container.option.borderColor.label")}
label={tSection("container.option.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
@@ -3,7 +3,7 @@ import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-reac
import { useEditMode } from "@homarr/boards/edit-mode";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item";
@@ -13,8 +13,8 @@ import { useContainerActions } from "./container-actions";
import { ContainerEditModal } from "./container-edit-modal";
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
const tContainer = useScopedI18n("section.container");
const tItem = useScopedI18n("item");
const tContainer = useI18n("section.container");
const tItem = useI18n("item");
const { openModal } = useModalAction(ContainerEditModal);
const openMoveModal = useOpenItemMoveModal();
const { updateContainer, removeContainer } = useContainerActions();
@@ -1,5 +1,5 @@
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { SectionGrid } from "./grid/section-grid";
@@ -16,7 +16,7 @@ export const BoardEmptySection = ({ section, columnCount, requestedRowCount, rai
const { items, innerSections } = useSectionItems(section.id);
const totalLength = items.length + innerSections.length;
const [isEditMode] = useEditMode();
const t = useScopedI18n("board.landmark");
const t = useI18n("board.landmark");
if (totalLength === 0 && !isEditMode && requestedRowCount === 0 && railPlacement !== "main") return null;
@@ -7,6 +7,7 @@ import combineClasses from "clsx";
import { useCurrentLayout } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
@@ -76,7 +77,7 @@ export const FixedGridItem = ({
});
const displayName =
item.type === "item"
? item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`)
? item.advancedOptions.title?.trim() || getWidgetName(item.kind, t)
: item.options.title.trim() || t("section.container.untitled");
const accessibleLabel = t("item.moveResize.entryLabel", {
name: displayName,
@@ -36,6 +36,7 @@ import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDro
import { IconGripHorizontal, IconGripVertical } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
@@ -743,7 +744,7 @@ export default function GridEditor({
[...items, ...innerSections].map((entry) => [
entry.id,
entry.type === "item"
? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`)
? entry.advancedOptions.title?.trim() || getWidgetName(entry.kind, t)
: entry.options.title.trim() || t("section.container.untitled"),
]),
),
@@ -80,7 +80,7 @@ export const useOpenSectionApps = (sectionId: string, enabled: boolean) => {
const currentLayoutId = useCurrentLayout();
const settings = useSettings();
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const t = useI18n("section");
const appDefinition = use(loadWidgetDefinition("app"));
const appIds = useMemo(() => {
const items: Item[] = getSectionItemsForLayout(board, sectionId, currentLayoutId);
@@ -115,8 +115,8 @@ export const useOpenSectionApps = (sectionId: string, enabled: boolean) => {
if (openedWindow) continue;
openConfirmModal({
title: t("section.openAllInNewTabs.title"),
children: t("section.openAllInNewTabs.text"),
title: t("openAllInNewTabs.title"),
children: t("openAllInNewTabs.text"),
});
break;
}
@@ -11,7 +11,7 @@ import { usePersistBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/common";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types";
import { createItemCallback } from "~/components/board/items/actions/create-item";
@@ -42,8 +42,8 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { updateAndPersistBoard } = usePersistBoard(board);
const t = useI18n();
const tSelection = useScopedI18n("item.selection");
const tCommon = useI18n("common");
const tSelection = useI18n("item.selection");
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) =>
@@ -205,7 +205,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const notificationId = `batch-remove-${Date.now()}`;
showSuccessNotification({
id: notificationId,
title: t("common.success"),
title: tCommon("success"),
message: (
<Group justify="space-between" wrap="nowrap">
<Text size="sm">{tSelection("removed", { count })}</Text>
@@ -220,13 +220,13 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
notifications.hide(notificationId);
}}
>
{t("common.action.undo")}
{tCommon("action.undo")}
</Button>
</Group>
),
autoClose: 10_000,
});
}, [board.items, clearSelection, persistBoard, selectedItemIds, t, tSelection]);
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
const moveSelectedItemsToSection = useCallback(
(targetSectionId: string) => {
@@ -20,8 +20,8 @@ import { IconCheck, IconClipboard, IconCopy, IconFolderShare, IconTrash, IconX }
import { clientApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { EmptySection } from "~/app/[locale]/boards/_types";
@@ -43,7 +43,7 @@ export const BoardSelectionToolbar = () => {
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const t = useI18n();
const tSelection = useScopedI18n("item.selection");
const tSelection = useI18n("item.selection");
const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id));
const hasAppWidgets = selectedItems.some((item) => item.kind === "app");
@@ -71,7 +71,7 @@ export const BoardSelectionToolbar = () => {
const appId = (item.options as { appId?: string })?.appId;
const app = appId ? appMap.get(appId) : undefined;
if (!displayName) {
displayName = app?.name || t("widget.app.name");
displayName = app?.name || getWidgetName("app", t);
}
if (app?.iconUrl) {
iconUrl = app.iconUrl;
@@ -79,7 +79,7 @@ export const BoardSelectionToolbar = () => {
}
if (!displayName) {
displayName = t(`widget.${item.kind}.name`);
displayName = getWidgetName(item.kind, t);
}
return { displayName, iconUrl, IconComponent, kind: item.kind };
@@ -16,7 +16,6 @@ import { BoardSelectionProvider, useBoardSelection } from "./board-selection-con
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => key,
useScopedI18n: () => (key: string) => key,
}));
vi.mock("../audio/board-sounds", () => ({
@@ -32,12 +32,12 @@ export interface SetupMetricProperties {
* Never pass record IDs, names, URLs, search text, credentials, or provider responses here.
*/
export const useSetupAnalytics = () => {
const t = useI18n();
const tCommon = useI18n("common");
const { mutate } = clientApi.analytics.trackFeature.useMutation({
onError() {
showErrorNotification({
title: t("common.error"),
message: t("common.analyticsError"),
title: tCommon("error"),
message: tCommon("analyticsError"),
});
},
});
@@ -4,7 +4,7 @@ import type { ModalProps } from "@mantine/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetSourceSetupPanel, ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetImport } from "./use-custom-widget-import";
@@ -36,7 +36,7 @@ export function CustomWidgetImportDialog({
onClose,
onImported,
}: CustomWidgetImportDialogProps) {
const t = useScopedI18n("customWidget");
const t = useI18n("customWidget");
const importer = useCustomWidgetImport({
widget,
legacyId,
@@ -13,12 +13,9 @@ import {
CustomWidgetSourceSetupPanel,
isCustomWidgetSourceSetupReady,
} from "@homarr/custom-widgets/workbench";
import type {
CustomWidgetSourceSetupMessages,
CustomWidgetSourceSetupValue,
} from "@homarr/custom-widgets/workbench";
import type { CustomWidgetSourceSetupMessages, CustomWidgetSourceSetupValue } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface CustomWidgetSourceSetupDialogProps {
definitionId: string;
@@ -26,12 +23,9 @@ interface CustomWidgetSourceSetupDialogProps {
onClose(): void;
}
export function CustomWidgetSourceSetupDialog({
definitionId,
opened,
onClose,
}: CustomWidgetSourceSetupDialogProps) {
const t = useScopedI18n("customWidget");
export function CustomWidgetSourceSetupDialog({ definitionId, opened, onClose }: CustomWidgetSourceSetupDialogProps) {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
const definitionQuery = clientApi.customWidget.get.useQuery({ id: definitionId }, { enabled: opened });
@@ -60,7 +54,7 @@ export function CustomWidgetSourceSetupDialog({
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
credentialsOptional: t("sourceSetupDialog.credentialsRequired"),
configured: t("workbench.sources.configured"),
secret: (kind) => t(`importReview.credentials.field.${kind}`),
secret: (kind) => t(`secret.${kind}`),
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
};
@@ -123,7 +117,7 @@ export function CustomWidgetSourceSetupDialog({
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={saving}>
{t("sourceSetupDialog.cancel")}
{tCommon("action.cancel")}
</Button>
<Button
onClick={() => void save()}
@@ -18,7 +18,7 @@ import type {
ImportReviewContentMessages,
} from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface UseCustomWidgetImportOptions {
widget: HomarrCustomWidgetV2 | null;
@@ -33,7 +33,8 @@ interface UseCustomWidgetImportOptions {
* means the Workshop page and the file/clipboard dialog can never drift apart.
*/
export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCustomWidgetImportOptions) {
const t = useScopedI18n("customWidget");
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
@@ -75,7 +76,7 @@ export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCusto
const onSuccess = (result: { id: string }) => {
setSucceeded(true);
showSuccessNotification({
title: legacyId ? t("action.migrate") : t("action.import"),
title: legacyId ? t("action.migrate") : tCommon("action.import"),
message: legacyId ? t("notification.migrated") : t("notification.imported"),
});
void utils.customWidget.list.invalidate();
@@ -88,7 +89,7 @@ export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCusto
};
const onError = (error: { message?: string }) => {
showErrorNotification({
title: legacyId ? t("action.migrate") : t("action.import"),
title: legacyId ? t("action.migrate") : tCommon("action.import"),
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
});
};
@@ -146,7 +147,7 @@ export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCusto
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
credentialsOptional: t("importReview.sourceSetup.credentialsOptional"),
configured: t("workbench.sources.configured"),
secret: (kind) => t(`importReview.credentials.field.${kind}`),
secret: (kind) => t(`secret.${kind}`),
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
};
@@ -20,7 +20,8 @@ export const EmbeddedIntegrationEditForm = ({
handleRef,
onSuccess,
}: EmbeddedIntegrationEditFormProps) => {
const t = useI18n();
const tItem = useI18n("item.edit.integration");
const tCommon = useI18n("common");
const integrationFormRef = useRef<EditIntegrationFormHandle>(null);
const {
data: integration,
@@ -47,7 +48,7 @@ export const EmbeddedIntegrationEditForm = ({
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Stack gap="sm" aria-busy="true" aria-label={tItem("loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
@@ -57,11 +58,11 @@ export const EmbeddedIntegrationEditForm = ({
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Alert color="red" title={tCommon("error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Text size="sm">{tItem("loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
{tCommon("action.tryAgain")}
</Button>
</Stack>
</Alert>
@@ -82,7 +83,7 @@ export const EmbeddedIntegrationEditForm = ({
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
{tItem("propagationNotice")}
</Alert>
<EditIntegrationForm integration={integration} hideButtons formRef={integrationFormRef} onSuccess={onSuccess} />
</Stack>
@@ -9,7 +9,7 @@ import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, getWidgetKindsForIntegration } from "@homarr/definitions";
import { getIntegrationName, getWidgetKindsForIntegration, getWidgetName } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
@@ -27,6 +27,8 @@ interface IntegrationCompletionModalProps {
}
export const IntegrationCompletionModal = createModal<IntegrationCompletionModalProps>(({ actions, innerProps }) => {
const tIntegration = useI18n("integration");
const tCommon = useI18n("common");
const t = useI18n();
const board = useOptionalBoard();
const { data: session } = useSession();
@@ -88,10 +90,10 @@ export const IntegrationCompletionModal = createModal<IntegrationCompletionModal
</ThemeIcon>
<Stack gap={2}>
<Title order={3} size="h4">
{t("integration.completion.title", { name: innerProps.result.integration.name })}
{tIntegration("completion.title", { name: innerProps.result.integration.name })}
</Title>
<Text c="dimmed" size="sm">
{t("integration.completion.description")}
{tIntegration("completion.description")}
</Text>
</Stack>
</Group>
@@ -107,14 +109,14 @@ export const IntegrationCompletionModal = createModal<IntegrationCompletionModal
<List.Item>
<Group gap="xs">
<IconPlugConnected size={17} />
<Text size="sm">{t("integration.completion.connectionReady")}</Text>
<Text size="sm">{tIntegration("completion.connectionReady")}</Text>
</Group>
</List.Item>
{innerProps.result.appId && (
<List.Item>
<Group gap="xs">
<IconAppWindow size={17} />
<Text size="sm">{t("integration.completion.appReady")}</Text>
<Text size="sm">{tIntegration("completion.appReady")}</Text>
</Group>
</List.Item>
)}
@@ -122,7 +124,7 @@ export const IntegrationCompletionModal = createModal<IntegrationCompletionModal
<Group gap="xs">
<IconLayoutDashboard size={17} />
<Text size="sm">
{t("integration.completion.compatibleWidgets", { count: String(compatibleWidgets.length) })}
{tIntegration("completion.compatibleWidgets", { count: String(compatibleWidgets.length) })}
</Text>
</Group>
</List.Item>
@@ -134,17 +136,17 @@ export const IntegrationCompletionModal = createModal<IntegrationCompletionModal
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={600} size="sm">
{t("integration.completion.recipes.title")}
{tIntegration("completion.recipes.title")}
</Text>
<Text c="dimmed" size="xs">
{t("integration.completion.recipes.description")}
{tIntegration("completion.recipes.description")}
</Text>
</div>
<Tooltip label={t("integration.completion.recipes.dismiss")}>
<Tooltip label={tIntegration("completion.recipes.dismiss")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("integration.completion.recipes.dismiss")}
aria-label={tIntegration("completion.recipes.dismiss")}
onClick={() => setRecommendationsDismissed(true)}
>
<IconX size={16} />
@@ -155,13 +157,13 @@ export const IntegrationCompletionModal = createModal<IntegrationCompletionModal
<Group key={recommendation.widgetKind} justify="space-between" wrap="nowrap" gap="sm">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{t(`widget.${recommendation.widgetKind}.name`)}
{getWidgetName(recommendation.widgetKind, t)}
</Text>
<Text c="dimmed" size="xs" truncate>
{t(
{tIntegration(
recommendation.isNewlyAvailable
? "integration.completion.recipes.newConnectionReason"
: "integration.completion.recipes.existingConnectionReason",
? "completion.recipes.newConnectionReason"
: "completion.recipes.existingConnectionReason",
{
integration: recommendation.isNewlyAvailable
? innerProps.result.integration.name
@@ -171,7 +173,7 @@ export const IntegrationCompletionModal = createModal<IntegrationCompletionModal
</Text>
</Stack>
<Button variant="light" size="compact-xs" onClick={() => addRecommendedWidget(recommendation.widgetKind)}>
{t("integration.completion.recipes.add")}
{tCommon("action.add")}
</Button>
</Group>
))}
@@ -180,10 +182,10 @@ export const IntegrationCompletionModal = createModal<IntegrationCompletionModal
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.close")}
{tCommon("action.close")}
</Button>
{innerProps.boardId && compatibleWidgets.length > 0 && (
<Button onClick={addWidget}>{t("integration.completion.chooseWidget")}</Button>
<Button onClick={addWidget}>{tIntegration("completion.chooseWidget")}</Button>
)}
</Group>
</Stack>
@@ -27,7 +27,8 @@ interface IntegrationSelectModalProps {
}
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const tCommon = useI18n("common");
const tIntegration = useI18n("integration");
const directKind =
innerProps.initialKind ?? (innerProps.allowedKinds?.length === 1 ? innerProps.allowedKinds[0] : null);
const isDirectForm = Boolean(directKind);
@@ -72,8 +73,12 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
};
if (step === "form" && selectedKind) {
const HeaderIcon = isDirectForm ? IconX : IconArrowLeft;
const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous");
let HeaderIcon = IconArrowLeft;
let headerLabel = tCommon("action.previous");
if (isDirectForm) {
HeaderIcon = IconX;
headerLabel = tCommon("action.close");
}
return (
<ScrollArea.Autosize mah="80vh">
@@ -97,11 +102,11 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
if (isIntegrationDataError) {
return (
<Alert color="red" title={t("common.error")}>
<Alert color="red" title={tCommon("error")}>
<Stack gap="sm">
<Text size="sm">{t("integration.grid.loadError")}</Text>
<Text size="sm">{tIntegration("grid.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
{tCommon("action.tryAgain")}
</Button>
</Stack>
</Alert>
@@ -5,7 +5,7 @@ import { Indicator, Menu, Text } from "@mantine/core";
import { IconBellRinging } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface UpdateIndicatorProps extends PropsWithChildren {
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
@@ -29,7 +29,7 @@ interface AvailableUpdatesMenuItemProps {
}
export const AvailableUpdatesMenuItem = ({ availableUpdates }: AvailableUpdatesMenuItemProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const t = useI18n("common.userAvatar.menu");
if (availableUpdates === undefined || availableUpdates.length === 0) {
return null;
}
@@ -3,14 +3,14 @@
import { Fragment } from "react";
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface DangerZoneRootProps {
children: React.ReactNode[] | React.ReactNode;
}
export const DangerZoneRoot = ({ children }: DangerZoneRootProps) => {
const t = useScopedI18n("common");
const t = useI18n("common");
return (
<Stack gap="sm">
@@ -10,8 +10,9 @@ describe("DynamicBreadcrumb", () => {
// Dynamic ids (e.g. /manage/custom-widgets/workshop/<id>) have no message and are
// not mapped while their page loads; resolving them must not raise MISSING_MESSAGE.
expect(source).toContain("let label = pathnamePart");
expect(source).toContain("if (mappedValue !== undefined)");
expect(source).toContain("t.has(labelKey)");
expect(source).toContain("mappedValue ?? (t.has(labelKey) ? t(labelKey) : pathnamePart)");
expect(source).not.toMatch(/\{t\(`\$\{translationKey\}\.label`/u);
});
});
@@ -5,7 +5,7 @@ import { Anchor, Badge, Breadcrumbs, Text } from "@mantine/core";
import { IconHomeFilled } from "@tabler/icons-react";
import type { TranslationKeys } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface DynamicBreadcrumbProps {
customHome?: string | null;
@@ -15,6 +15,52 @@ interface DynamicBreadcrumbProps {
}
const categorySegments = new Set(["tools"]);
const commonActionLabels: Record<string, "create" | "edit"> = {
"manage.integrations.edit": "edit",
"manage.search-engines.edit": "edit",
"manage.apps.edit": "edit",
"manage.custom-widgets.edit": "edit",
"manage.users.create": "create",
};
const commonLabelLabels: Record<string, "new"> = {
"manage.integrations.new": "new",
"manage.search-engines.new": "new",
"manage.apps.new": "new",
"manage.custom-widgets.new": "new",
};
const commonEntityLabels: Record<
string,
| "apps"
| "boards"
| "certificates"
| "customWidgets"
| "groups"
| "integrations"
| "invites"
| "logs"
| "media"
| "searchEngines"
| "tasks"
| "users"
> = {
"manage.boards": "boards",
"manage.integrations": "integrations",
"manage.apps": "apps",
"manage.custom-widgets": "customWidgets",
"manage.medias": "media",
"manage.search-engines": "searchEngines",
"manage.users": "users",
"manage.users.groups": "groups",
"manage.users.invites": "invites",
"manage.tools.certificates": "certificates",
"manage.tools.docker.logs": "logs",
"manage.tools.logs": "logs",
"manage.tools.tasks": "tasks",
};
const invariantLabels: Record<string, "Docker" | "Kubernetes"> = {
"manage.tools.docker": "Docker",
"manage.tools.kubernetes": "Kubernetes",
};
/**
* Breadcrumb is client side rendered. Elements are automatically
@@ -32,8 +78,11 @@ export const DynamicBreadcrumb = ({
}: DynamicBreadcrumbProps) => {
const pathname = usePathname();
const pathnameParts = pathname.split("/").filter((part) => part.length > 0);
const t = useScopedI18n("navigationStructure");
const tNavbar = useScopedI18n("management.navbar.items");
const t = useI18n("navigationStructure");
const tNavbar = useI18n("management.navbar.items");
const tCommonAction = useI18n("common.action");
const tCommonEntity = useI18n("common.entity");
const tCommonLabel = useI18n("common.label");
const length = pathnameParts.filter((part) => part !== customHome).length;
@@ -69,7 +118,24 @@ export const DynamicBreadcrumb = ({
// their page is still loading. Fall back to the raw segment instead of
// raising MISSING_MESSAGE.
const labelKey = `${translationKey}.label` as TranslationKeys;
const label = mappedValue ?? (t.has(labelKey) ? t(labelKey) : pathnamePart);
const commonActionKey = commonActionLabels[translationKey];
const commonLabelKey = commonLabelLabels[translationKey];
const commonEntityKey = commonEntityLabels[translationKey];
const invariantLabel = invariantLabels[translationKey];
let label = pathnamePart;
if (mappedValue !== undefined) {
label = mappedValue;
} else if (commonActionKey) {
label = tCommonAction(commonActionKey);
} else if (commonLabelKey) {
label = tCommonLabel(commonLabelKey);
} else if (commonEntityKey) {
label = tCommonEntity(commonEntityKey);
} else if (invariantLabel) {
label = invariantLabel;
} else if (t.has(labelKey)) {
label = t(labelKey);
}
if (isNonInteractable) {
return <Text key={href}>{label}</Text>;
@@ -7,7 +7,7 @@ import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
@@ -15,7 +15,7 @@ import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
export const BoardTourProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("onboardingTour.board");
const t = useI18n("onboardingTour.board");
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const [started, setStarted] = useState(true);
@@ -15,7 +15,7 @@ import {
import { clientApi } from "@homarr/api/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
import { TourShell } from "./tour-shell";
@@ -46,7 +46,7 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
};
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const t = useScopedI18n("onboardingTour.manage");
const t = useI18n("onboardingTour.manage");
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const isMobile = useMediaQuery("(max-width: 48em)");
const pathname = usePathname();
@@ -77,7 +77,8 @@ const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
};
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
const t = useI18n();
const tCommon = useI18n("common.action");
const tTour = useI18n("onboardingTour");
const router = useRouter();
const forwardActionRef = useRef<(() => void) | null>(null);
@@ -154,11 +155,11 @@ export const TourShell = ({ steps, started, onEnd, stepRoutes, position, childre
const action = bindForwardAction(controller, () => {
navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
});
return <TourForwardButton label={t("onboardingTour.next")} onClick={action} />;
return <TourForwardButton label={tCommon("next")} onClick={action} />;
}}
endStepNavigation={(controller) => {
const action = bindForwardAction(controller, () => controller.endTour());
return <TourDoneButton label={t("onboardingTour.done")} onClick={action} />;
return <TourDoneButton label={tTour("done")} onClick={action} />;
}}
prevStepNavigation={(controller) => (
<Button
@@ -169,12 +170,12 @@ export const TourShell = ({ steps, started, onEnd, stepRoutes, position, childre
navigateAndAdvance(controller.currentStep?.id, prevStep?.id, () => controller.prevStep());
}}
>
{t("onboardingTour.prev")}
{tTour("prev")}
</Button>
)}
skipNavigation={(controller) => (
<Button size="sm" variant="subtle" color="gray" onClick={() => controller.skipTour()}>
{t("onboardingTour.skip")}
{tTour("skip")}
</Button>
)}
withPrevButton
@@ -13,7 +13,7 @@ interface TourStepContentProps {
}
export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
const t = useI18n();
const t = useI18n("onboardingTour");
return (
<Stack gap="sm">
@@ -31,7 +31,7 @@ export const TourStepContent = ({ description, documentationHref, icon }: TourSt
<Anchor href={documentationHref} target="_blank" size="xs" fw={500}>
<Group gap={4} wrap="nowrap">
<IconExternalLink size={14} />
{t("onboardingTour.learnMore")}
{t("learnMore")}
</Group>
</Anchor>
</Stack>
@@ -15,9 +15,9 @@ import {
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
@@ -40,7 +40,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
.join(" + ");
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const t = useI18n("common.userAvatar.menu");
const session = useSession();
const { logoutUrl } = useAuthContext();
@@ -110,7 +110,7 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{t("docker")}
{invariantTechnicalLabels.docker}
</Menu.Item>
)}
</>
@@ -4,7 +4,8 @@ import { useId } from "react";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
/** Read-only source view of a Workshop submission, used for both widget JSON and Custom CSS. */
export function WorkshopCodeViewer({
@@ -17,12 +18,13 @@ export function WorkshopCodeViewer({
height?: string;
}) {
const id = useId();
const t = useScopedI18n("customWidget.editor");
const workshopT = useScopedI18n("workshop");
const t = useI18n("customWidget.editor");
const tCommon = useI18n("common");
const workshopT = useI18n("workshop");
const messages: CustomWidgetEditorMessages = {
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"),
@@ -30,10 +32,10 @@ export function WorkshopCodeViewer({
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 }),
@@ -7,7 +7,7 @@ import { IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react"
import { modalSizeSelect, useConfirmModal } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { useWorkshopQuery } from "@homarr/workshop/backend";
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
import { validateWorkshopContent } from "@homarr/workshop/schema";
@@ -24,7 +24,7 @@ import { WorkshopVoteControl } from "./workshop-vote-control";
* board settings themselves are saved, so there is nothing to preview twice.
*/
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const [opened, controls] = useDisclosure(false);
return (
@@ -45,7 +45,7 @@ export function WorkshopCssImportButton({ onImport }: { onImport(css: string): v
}
function WorkshopCssPicker({ onImport }: { onImport(css: string): void }) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const session = useWorkshopSession();
const { openConfirmModal } = useConfirmModal();
const [search, setSearch] = useState("");
@@ -4,7 +4,7 @@ import { useState } from "react";
import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
import type { WorkshopReport } from "@homarr/workshop/schema";
@@ -19,7 +19,8 @@ interface WorkshopReportModalProps {
}
export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const tCommon = useI18n("common");
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
const [explanation, setExplanation] = useState("");
const report = useWorkshopReportMutation(client);
@@ -67,7 +68,7 @@ export function WorkshopReportModal({ client, submissionId, opened, onClose }: W
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
{t("cancel")}
{tCommon("action.cancel")}
</Button>
<Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}>
{t("reportSend")}
@@ -5,7 +5,7 @@ import { ActionIcon, Box, Group, Image, Modal, Paper, Tooltip, UnstyledButton }
import { useDisclosure } from "@mantine/hooks";
import { IconChevronLeft, IconChevronRight, IconZoomIn } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
interface WorkshopScreenshotsProps {
@@ -20,7 +20,7 @@ interface WorkshopScreenshotsProps {
* click-to-zoom lightbox, so people can actually see what they are installing.
*/
export function WorkshopScreenshots({ client, submissionId, title, screenshots }: WorkshopScreenshotsProps) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const [index, setIndex] = useState(0);
const [zoomOpened, zoomControls] = useDisclosure(false);
@@ -5,7 +5,7 @@ import { Button, Group, Text } from "@mantine/core";
import { IconBrandGithub } from "@tabler/icons-react";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { createWorkshopClient } from "./workshop-client";
@@ -15,7 +15,7 @@ import { createWorkshopClient } from "./workshop-client";
* never to install, so every surface can render this without gating its content.
*/
export function useWorkshopSession() {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [pending, setPending] = useState(false);
@@ -51,7 +51,7 @@ export function useWorkshopSession() {
export type WorkshopSession = ReturnType<typeof useWorkshopSession>;
export function WorkshopAccountButton({ session }: { session: WorkshopSession }) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
if (!session.user) {
return (
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import { Badge, Box, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconFlag, IconPhoto } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
@@ -19,7 +19,7 @@ interface WorkshopSubmissionCardProps {
}
export function WorkshopSubmissionCard({ client, item, actions }: WorkshopSubmissionCardProps) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const screenshot = item.screenshots[0];
return (
@@ -6,7 +6,7 @@ import type { StyleProp } from "@mantine/core";
import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
import type { UseQueryResult } from "@tanstack/react-query";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend, WorkshopPage } from "@homarr/workshop/backend";
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
@@ -33,7 +33,8 @@ export function WorkshopSubmissionGrid({
cols = { base: 1, sm: 2, lg: 3 },
renderActions,
}: WorkshopSubmissionGridProps) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const tCommon = useI18n("common");
if (query.isLoading) {
return (
@@ -57,7 +58,7 @@ export function WorkshopSubmissionGrid({
loading={query.isFetching}
onClick={() => void query.refetch()}
>
{t("retry")}
{tCommon("action.tryAgain")}
</Button>
</Stack>
</Alert>
@@ -4,7 +4,7 @@ import { ActionIcon, Group, Text, Tooltip } from "@mantine/core";
import { IconArrowBigDown, IconArrowBigDownFilled, IconArrowBigUp, IconArrowBigUpFilled } from "@tabler/icons-react";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopUserVotesQuery, useWorkshopVoteMutation } from "@homarr/workshop/backend";
@@ -22,7 +22,7 @@ interface WorkshopVoteControlProps {
* and clicking it again removes the vote.
*/
export function WorkshopVoteControl({ client, submissionId, score, canVote, size = "md" }: WorkshopVoteControlProps) {
const t = useScopedI18n("workshop");
const t = useI18n("workshop");
const vote = useWorkshopVoteMutation(client);
const userVotes = useWorkshopUserVotesQuery(client);
const ownVote = userVotes.data?.find((entry) => entry.submission === submissionId)?.value ?? null;