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

+17 -3
View File
@@ -1,7 +1,11 @@
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetOptionOfType, WidgetOptionType } from "../options";
import {
getWidgetOptionDescriptionTranslationNamespace,
getWidgetOptionTranslationNamespace,
} from "../option-translation";
export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
kind: WidgetKind;
@@ -25,7 +29,17 @@ type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "plac
* is defined for the option. The method does sadly not reconize issues with those definitions. So it does not yell at you when you somewhere show the label without having it defined in the translations.
*/
export const useWidgetInputTranslation = (kind: WidgetKind, property: string): UseWidgetInputTranslationReturnType => {
return useScopedI18n(
`widget.${kind}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
const inputNamespace = getWidgetOptionTranslationNamespace(kind, property);
const descriptionNamespace = getWidgetOptionDescriptionTranslationNamespace(kind, property);
const tInput = useI18n(
inputNamespace as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
) as unknown as UseWidgetInputTranslationReturnType;
const tDescription = useI18n(
(descriptionNamespace ?? inputNamespace) as never,
) as unknown as UseWidgetInputTranslationReturnType;
return (key) => {
if (key === "description" && descriptionNamespace) return tDescription("description");
return tInput(key);
};
};
@@ -5,16 +5,16 @@ import { Loader, Select } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetInputProps<"anchorNote">) => {
const t = useI18n();
const tCommon = useI18n("common");
const tWidget = useWidgetInputTranslation(kind, property);
const tAnchor = useScopedI18n("widget.anchorNote");
const tAnchor = useI18n("widget.anchorNote");
const form = useFormContext();
const integrationId = form.values.integrationIds[0];
@@ -46,7 +46,7 @@ export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetI
<Select
label={tWidget("label")}
description={options.withDescription ? tWidget("description") : undefined}
placeholder={integrationId ? t("common.select.placeholder") : tAnchor("integrationRequired")}
placeholder={integrationId ? tCommon("select.placeholder") : tAnchor("integrationRequired")}
nothingFoundMessage={integrationId ? tAnchor("noNotes") : tAnchor("integrationRequired")}
searchable
searchValue={search}
@@ -24,7 +24,8 @@ const renderHereLink = (children: React.ReactNode) => (
);
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
const t = useI18n();
const tCommon = useI18n("common");
const tApp = useI18n("widget.common.app");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { data: apps, isPending, refetch } = clientApi.app.selectable.useQuery();
@@ -44,7 +45,7 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
label={tInput("label")}
searchable
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("widget.common.app.noData")}
nothingFoundMessage={tApp("noData")}
renderOption={renderSelectOption}
data={
apps?.map((app) => ({
@@ -56,8 +57,8 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text component="span" size="xs">
{t.rich("widget.common.app.description", {
here: renderHereLink,
{tApp.rich("description", {
here: () => renderHereLink(tCommon("here")),
})}
</Text>
}
@@ -79,7 +80,7 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
})
}
>
{t("widget.common.app.quickCreate")}
{tApp("quickCreate")}
</Button>
)}
</SimpleGrid>
@@ -25,7 +25,7 @@ import { normalizeCustomWidgetOptions, validateCustomWidgetOptions } from "@homa
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
@@ -34,7 +34,7 @@ export const WidgetCustomWidgetConfigurationInput = ({
property,
}: CommonWidgetInputProps<"customWidgetConfiguration">) => {
const form = useFormContext();
const labels = useScopedI18n("widget.customApi.configuration");
const labels = useI18n("widget.customApi.configuration");
const board = useOptionalBoard();
const definitionId = typeof form.values.options.definitionId === "string" ? form.values.options.definitionId : "";
const configurationValue = form.values.options[property];
@@ -72,7 +72,7 @@ vi.mock("@homarr/boards/context", () => ({
}));
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) =>
useI18n: () => (key: string) =>
({
migrationRequired: "Migration required",
migrationDescription: "Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
@@ -19,7 +19,7 @@ import { IconAlertTriangle, IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
@@ -32,7 +32,7 @@ export const WidgetCustomWidgetSelectInput = ({
options,
}: CommonWidgetInputProps<"customWidgetSelect">) => {
const t = useWidgetInputTranslation(kind, property);
const labels = useScopedI18n("widget.customApi.picker");
const labels = useI18n("widget.customApi.picker");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const form = useFormContext();
@@ -24,7 +24,7 @@ import dayjs from "dayjs";
import { createId } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { formatWallDateTime, resolveWallDateTimeString } from "../countdown/recurrence";
import type { DateTimeEventRecurrence, OptionDateTimeEvent } from "../options";
@@ -314,4 +314,4 @@ type EventListTranslationKey =
type EventListTranslation = (key: EventListTranslationKey, values?: Record<string, string | number>) => string;
const useEventListTranslation = (kind: WidgetKind) =>
useScopedI18n(`widget.${kind}.dateTimeEventList` as never) as unknown as EventListTranslation;
useI18n(`widget.${kind}.dateTimeEventList` as never) as unknown as EventListTranslation;
@@ -2,7 +2,7 @@
import { Loader, MultiSelect } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -13,7 +13,8 @@ export const WidgetDynamicMultiSelectInput = ({
kind,
options,
}: CommonWidgetInputProps<"dynamicMultiSelect">) => {
const t = useScopedI18n("widget.dynamicSelect");
const t = useI18n("widget.common.select");
const tCommon = useI18n("common");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
@@ -36,7 +37,7 @@ export const WidgetDynamicMultiSelectInput = ({
searchable
clearable
maxValues={options.maxValues}
nothingFoundMessage={t("noResults")}
nothingFoundMessage={tCommon("noResults")}
data={data}
disabled={isError}
error={isError ? t("loadError") : inputProps.error}
@@ -6,7 +6,7 @@ import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck } from "@tabler/icons-react";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -25,7 +25,7 @@ export const WidgetDynamicSelectInput = ({
boardId,
}: CommonWidgetInputProps<"dynamicSelect">) => {
const t = useI18n();
const tSelect = useScopedI18n("widget.dynamicSelect");
const tCommon = useI18n("common");
const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
@@ -78,7 +78,7 @@ export const WidgetDynamicSelectInput = ({
searchValue={search}
onSearchChange={setSearch}
placeholder={tWidget("placeholder")}
nothingFoundMessage={tSelect("noResults")}
nothingFoundMessage={tCommon("noResults")}
leftSection={isPending && <Loader size="xs" />}
renderOption={({ option, checked }) => {
return (
@@ -2,20 +2,20 @@
import { MultiSelect, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
export const WidgetIntegrationMultiSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"integrationMultiSelect">) => {
const t = useIntegrationSelectTranslation();
const t = useI18n("widget.integrationSelect");
const tSelect = useI18n("widget.common.select");
const tCommon = useI18n("common");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -40,8 +40,8 @@ export const WidgetIntegrationMultiSelectInput = ({
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : tInput("placeholder");
const loadError = isError ? t("loadError") : undefined;
const placeholder = isPending ? tSelect("loading") : tInput("placeholder");
const loadError = isError ? tSelect("loadError") : undefined;
return (
<MultiSelect
@@ -51,7 +51,7 @@ export const WidgetIntegrationMultiSelectInput = ({
placeholder={placeholder}
searchable
clearable
nothingFoundMessage={t("noResults")}
nothingFoundMessage={tCommon("noResults")}
data={data}
disabled={isError}
error={loadError ?? inputProps.error}
@@ -2,20 +2,20 @@
import { Select, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
export const WidgetIntegrationSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"integrationSelect">) => {
const t = useIntegrationSelectTranslation();
const t = useI18n("widget.integrationSelect");
const tSelect = useI18n("widget.common.select");
const tCommon = useI18n("common");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -35,8 +35,8 @@ export const WidgetIntegrationSelectInput = ({
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : t("placeholder");
const error = isError ? t("loadError") : undefined;
const placeholder = isPending ? tSelect("loading") : t("placeholder");
const error = isError ? tSelect("loadError") : undefined;
return (
<Select
@@ -45,7 +45,7 @@ export const WidgetIntegrationSelectInput = ({
placeholder={placeholder}
clearable={options.clearable}
searchable={options.searchable}
nothingFoundMessage={t("noResults")}
nothingFoundMessage={tCommon("noResults")}
data={data}
disabled={isError}
error={error}
@@ -46,7 +46,7 @@ vi.mock("@homarr/modals", () => ({
vi.mock("@homarr/translation/client", () => ({
useCurrentIntlLocale: () => "en",
useScopedI18n: () => (key: string) => key,
useI18n: () => (key: string) => key,
}));
vi.mock("./common", async (importOriginal) => ({
@@ -21,7 +21,7 @@ import { IconClick, IconListSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { formatLocalizedCompactNumber } from "../common/locale";
import type { OptionLocation } from "../options";
@@ -31,7 +31,7 @@ import { useFormContext } from "./form";
export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInputProps<"location">) => {
const t = useWidgetInputTranslation(kind, property);
const tLocation = useScopedI18n("widget.common.location");
const tLocation = useI18n("widget.common.location");
const form = useFormContext();
const { openModal } = useModalAction(LocationSearchModal);
const fieldPath = `options.${property}`;
@@ -149,8 +149,8 @@ interface LocationSearchInnerProps {
}
const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, innerProps }) => {
const t = useScopedI18n("widget.common.location.table");
const tCommon = useScopedI18n("common");
const t = useI18n("widget.common.location.table");
const tCommon = useI18n("common");
const { data, isPending, error } = clientApi.location.searchCity.useQuery({
query: innerProps.query,
});
@@ -216,7 +216,7 @@ interface LocationSearchTableRowProps {
}
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
const t = useScopedI18n("widget.common.location.table");
const t = useI18n("widget.common.location.table");
const locale = useCurrentIntlLocale();
const onSelect = useCallback(() => {
onLocationSelect({
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { Combobox, Pill, PillsInput, useCombobox } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -9,7 +9,7 @@ import { useFormContext } from "./form";
export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetInputProps<"multiText">) => {
const t = useWidgetInputTranslation(kind, property);
const tCommon = useScopedI18n("common");
const tCommon = useI18n("common");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
@@ -48,7 +48,7 @@ import {
import { IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { MaskedImage } from "@homarr/ui";
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository";
@@ -69,7 +69,8 @@ export const WidgetMultiReleasesRepositoriesInput = ({
itemId,
}: CommonWidgetInputProps<"multiReleasesRepositories">) => {
const t = useWidgetInputTranslation(kind, property);
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const form = useFormContext();
const repositories = form.values.options[property] as ReleasesRepository[];
const { openModal: openEditModal } = useModalAction(RepositoryEditModal);
@@ -232,7 +233,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
leftSection={<IconEdit size="var(--mantine-font-size-sm)" />}
size="xs"
>
{tRepository("edit.label")}
{actionT("edit")}
</Button>
<ActionIcon variant="transparent" color="red" onClick={() => onRepositoryRemove(index)}>
@@ -267,7 +268,7 @@ const providersWithAuth: ReleaseProviderKind[] = [
];
const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repositories: ReleasesRepository[] }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
onSuccess: () => refetch(),
@@ -331,7 +332,8 @@ const ProviderTokenInput = ({
onSave: (value: string) => Promise<void>;
onDelete: () => Promise<void>;
}) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const [value, setValue] = useState("");
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
@@ -375,7 +377,7 @@ const ProviderTokenInput = ({
/>
{editing && value.trim() && (
<Button size="xs" onClick={handleSave} loading={saving}>
{tRepository("tokens.save")}
{actionT("save")}
</Button>
)}
{hasToken && !editing && (
@@ -412,7 +414,8 @@ interface RepositoryEditProps {
}
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
@@ -576,11 +579,11 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<Divider my={"sm"} />
<Group justify="flex-end">
<Button variant="default" onClick={handleCancel} color="gray.5">
{tRepository("editForm.cancel.label")}
{actionT("cancel")}
</Button>
<Button data-autofocus onClick={handleConfirm} loading={loading}>
{tRepository("editForm.confirm.label")}
{actionT("confirm")}
</Button>
</Group>
</Stack>
@@ -611,7 +614,7 @@ const ImportRepositorySelect = ({
disabled = false,
onImageSelectionChanged = undefined,
}: ImportRepositorySelectProps) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const provider = repository.provider;
return (
@@ -687,7 +690,8 @@ interface RepositoryImportProps {
}
const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const [loading, setLoading] = useState(false);
const [selectedImages, setSelectedImages] = useState([] as ReleasesRepositoryImport[]);
@@ -847,11 +851,11 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
<Group justify="flex-end">
<Button variant="default" onClick={actions.closeModal} color="gray.5">
{tRepository("editForm.cancel.label")}
{actionT("cancel")}
</Button>
<Button onClick={handleConfirm} loading={loading} disabled={selectedImages.length === 0}>
{tRepository("editForm.confirm.label")}
{actionT("confirm")}
</Button>
</Group>
</Stack>
@@ -22,7 +22,7 @@ import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react";
import { createId } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { OptionTimezone } from "../options";
import type { CommonWidgetInputProps } from "./common";
@@ -246,4 +246,4 @@ type TimezoneListTranslationKey =
type TimezoneListTranslation = (key: TimezoneListTranslationKey, values?: Record<string, string | number>) => string;
const useTimezoneListTranslation = (kind: WidgetKind) =>
useScopedI18n(`widget.${kind}.timezoneList` as never) as unknown as TimezoneListTranslation;
useI18n(`widget.${kind}.timezoneList` as never) as unknown as TimezoneListTranslation;
@@ -3,14 +3,15 @@
import { Select, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventName">) => {
const t = useScopedI18n("widget.umami.option.eventName");
const t = useI18n("widget.umami.option.eventName");
const tEvents = useI18n("widget.umami.option.events");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -25,7 +26,7 @@ export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps
if (!websiteId || integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureWebsiteFirst")}
{tEvents("configureWebsiteFirst")}
</Text>
);
}
@@ -36,9 +37,9 @@ export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps
<Select
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : undefined}
placeholder={isPending ? tEvents("loading") : undefined}
searchable
nothingFoundMessage={t("noEvents")}
nothingFoundMessage={tEvents("noEvents")}
data={data}
{...form.getInputProps(`options.${property}`)}
/>
@@ -3,14 +3,15 @@
import { MultiSelect, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventNames">) => {
const t = useScopedI18n("widget.umami.option.eventNames");
const t = useI18n("widget.umami.option.eventNames");
const tEvents = useI18n("widget.umami.option.events");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -25,7 +26,7 @@ export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInput
if (!websiteId || integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureWebsiteFirst")}
{tEvents("configureWebsiteFirst")}
</Text>
);
}
@@ -34,9 +35,9 @@ export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInput
<MultiSelect
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : t("placeholder")}
placeholder={isPending ? tEvents("loading") : t("placeholder")}
searchable
nothingFoundMessage={t("noEvents")}
nothingFoundMessage={tEvents("noEvents")}
data={eventNames ?? []}
{...form.getInputProps(`options.${property}`)}
/>
@@ -3,14 +3,14 @@
import { Select, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiWebsiteInput = ({ property, kind }: CommonWidgetInputProps<"umamiWebsite">) => {
const t = useScopedI18n("widget.umami.option.websiteId");
const t = useI18n("widget.umami.option.websiteId");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -6,7 +6,7 @@ import { Box, Group, Paper, ScrollArea, SimpleGrid, Stack, Text, VisuallyHidden
import { IconMapPin } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
@@ -28,7 +28,7 @@ import {
} from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
const t = useScopedI18n("widget.airQuality");
const t = useI18n("widget.airQuality");
const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude,
longitude: options.location.longitude,
@@ -66,7 +66,7 @@ const CompactAirQuality = ({
width,
}: AirQualityViewProps & { height: number; width: number }) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.airQuality");
const t = useI18n("widget.airQuality");
const layout = getCompactAirQualityLayout(width, height);
const standard = getAqiStandard(options.aqiStandard, locale);
const value = getAqiValue(airQuality.current, standard);
@@ -158,7 +158,7 @@ const CompactAirQuality = ({
const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps & { width: number }) => {
const chartId = useId();
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.airQuality");
const t = useI18n("widget.airQuality");
const standard = getAqiStandard(options.aqiStandard, locale);
const value = getAqiValue(airQuality.current, standard);
const category = getAqiCategory(value, standard);
@@ -358,7 +358,7 @@ const pollutantKeys: PollutantKey[] = ["pm2_5", "pm10", "ozone", "nitrogenDioxid
const pollenKeys: PollenKey[] = ["alder", "birch", "grass", "mugwort", "olive", "ragweed"];
const useAirQualityLabels = () => {
const t = useScopedI18n("widget.airQuality");
const t = useI18n("widget.airQuality");
return {
pollutants: {
pm2_5: t("pollutant.pm2_5"),
@@ -389,7 +389,7 @@ const formatDecimal = (value: number | null, locale: string) => {
return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
};
type ScopedAirQualityTranslator = ReturnType<typeof useScopedI18n<"widget.airQuality">>;
type ScopedAirQualityTranslator = ReturnType<typeof useI18n<"widget.airQuality">>;
const formatConcentration = (value: number | null, locale: string, t: ScopedAirQualityTranslator) =>
t("unit.concentration", { value: formatDecimal(value, locale) });
+11 -9
View File
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
@@ -42,7 +42,7 @@ export default function AnchorNoteWidget({
height,
displayMode,
}: WidgetComponentProps<"anchorNote">) {
const t = useScopedI18n("widget.anchorNote");
const t = useI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
return (
@@ -85,7 +85,9 @@ const AnchorNoteWidgetContent = ({
height,
isAdvanced,
}: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const t = useI18n("widget.anchorNote");
const tWidgetCommon = useI18n("widget.common");
const actionT = useI18n("common.action");
const locale = useCurrentIntlLocale();
const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({
integrationId,
@@ -206,7 +208,7 @@ const AnchorNoteWidgetContent = ({
)}
{!isEditing && (isAdvanced || options.showUpdatedAt) && (
<Text size="xs" c="dimmed">
{t("updatedAt", { date: updatedAtRelative })}
{tWidgetCommon("updatedAt", { date: updatedAtRelative })}
</Text>
)}
{!isEditing && isViewer && (
@@ -243,21 +245,21 @@ const AnchorNoteWidgetContent = ({
{isEditing ? (
<>
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
{t("save")}
{actionT("save")}
</Button>
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
{t("cancel")}
{actionT("cancel")}
</Button>
</>
) : isAdvanced ? (
<Button size="xs" variant="light" onClick={handleEdit} disabled={isUpdating}>
{t("edit")}
{actionT("edit")}
</Button>
) : (
<Tooltip label={t("edit")}>
<Tooltip label={actionT("edit")}>
<ActionIcon
className={actionTargetClasses.root}
aria-label={t("edit")}
aria-label={actionT("edit")}
size="md"
variant="light"
onClick={handleEdit}
+4 -3
View File
@@ -23,7 +23,8 @@ import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
const t = useI18n();
const tCommon = useI18n("common");
const tApp = useI18n("widget.app");
const settings = useSettings();
const board = useRequiredBoard();
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
@@ -121,14 +122,14 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
!settings.forceDisableStatus &&
!board.disableStatus &&
Boolean(app.pingUrl ?? app.href) ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${tCommon("action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
</AppLink>
{appQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={appQuery.error} label={t("widget.app.name")} />
<WidgetQueryErrorIndicator error={appQuery.error} label={tApp("name")} />
</Box>
)}
</Box>
+2 -2
View File
@@ -55,7 +55,7 @@ export const { definition, componentLoader } = createWidgetDefinition("app", {
options: [
{
label(t) {
return t("widget.app.option.layout.option.column");
return t("widget.common.layout.option.vertical");
},
value: "column",
icon: IconLayoutNavbarExpand,
@@ -69,7 +69,7 @@ export const { definition, componentLoader } = createWidgetDefinition("app", {
},
{
label(t) {
return t("widget.app.option.layout.option.row");
return t("widget.common.layout.option.horizontal");
},
value: "row",
icon: IconLayoutSidebarLeftExpand,
@@ -10,12 +10,12 @@ interface PingIndicatorProps {
}
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
const t = useI18n();
const t = useI18n("common");
const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
if (error) return <PingDot icon={IconX} color="red" tooltip={t("common.error")} />;
if (error) return <PingDot icon={IconX} color="red" tooltip={t("error")} />;
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("action.loading")}…`} />;
const isError = "error" in pingResult || pingResult.statusCode >= 500;
@@ -5,7 +5,7 @@ import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from
import { clientApi } from "@homarr/api/client";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
@@ -47,7 +47,8 @@ const ArchiveTeamWarriorWidgetContent = ({
height: number;
isAdvanced: boolean;
}) => {
const t = useScopedI18n("widget.archiveTeamWarrior");
const t = useI18n("widget.archiveTeamWarrior");
const tWidgetCommon = useI18n("widget.common");
const locale = useCurrentIntlLocale();
const [data, statusQuery] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
@@ -107,7 +108,7 @@ const ArchiveTeamWarriorWidgetContent = ({
{t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
</Text>
<Text size="xs" c="dimmed">
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
{tWidgetCommon("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
</Text>
</Group>
<Group gap="xs" wrap="wrap">
+2 -2
View File
@@ -3,14 +3,14 @@
import { Center, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconRobot } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { useAssistantWidgetRenderer } from "./context";
export default function AssistantWidget(props: WidgetComponentProps<"assistant">) {
const Renderer = useAssistantWidgetRenderer();
const t = useScopedI18n("widget.assistant.unavailable");
const t = useI18n("widget.assistant.unavailable");
if (Renderer) return <Renderer {...props} />;
@@ -4,7 +4,7 @@ import type { CSSProperties } from "react";
import { Text } from "@mantine/core";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
@@ -49,7 +49,7 @@ export function AudioStatsContent({
width,
height,
}: AudioStatsContentProps) {
const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const compact = options.compactMode ?? false;
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats, showAllStats), compact);
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
@@ -23,7 +23,8 @@ export default function AudioStatsWidget({
displayMode = "compact",
widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) {
const t = useI18n();
const tAudio = useI18n("widget.audioStats");
const tMediaServer = useI18n("widget.mediaServer");
const statsInput = { integrationId: integrationIds[0] ?? "" };
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
@@ -56,7 +57,7 @@ export default function AudioStatsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={statsError} label={t("widget.audioStats.name")} />
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
</Box>
{summary}
</Box>
@@ -74,8 +75,8 @@ export default function AudioStatsWidget({
<div style={{ minHeight: 150 }}>{summary}</div>
{(statsError || streamsError || hasStreamErrors) && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={statsError} label={t("widget.audioStats.name")} />
<WidgetQueryErrorIndicator error={streamsError} label={t("widget.mediaServer.name")} />
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
<WidgetQueryErrorIndicator error={streamsError} label={tMediaServer("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
+13 -4
View File
@@ -5,7 +5,8 @@ import { Text } from "@mantine/core";
import { IconAlertTriangle, IconBell, IconDeviceTv, IconMovie, IconPlugConnected } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
@@ -60,7 +61,7 @@ export default function BazarrWidget({
height,
displayMode,
}: WidgetComponentProps<"bazarr">) {
const t = useScopedI18n("widget.bazarr");
const t = useI18n("widget.bazarr");
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: Boolean(integrationIds[0]) },
@@ -106,6 +107,14 @@ export default function BazarrWidget({
{visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey];
const value = statValues[statKey];
let label: string;
if (statKey === "sonarrSignalr") {
label = `${getIntegrationName("sonarr")} SignalR`;
} else if (statKey === "radarrSignalr") {
label = `${getIntegrationName("radarr")} SignalR`;
} else {
label = t(statKey);
}
const isWarning =
typeof value === "number" &&
(statKey === "providers" || statKey === "status" || statKey === "announcements") &&
@@ -117,8 +126,8 @@ export default function BazarrWidget({
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>
{typeof value === "string" && value.trim() === "" ? "—" : value}
</span>
<span className={classes.statLabel} title={t(statKey)}>
{t(statKey)}
<span className={classes.statLabel} title={label}>
{label}
</span>
</div>
);
@@ -23,7 +23,7 @@ import type { LucideIcon } from "lucide-react";
import { Activity, Battery, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer } from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -58,7 +58,7 @@ export default function BeszelAlertsWidget({
displayMode,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const t = useI18n("widget.beszelAlerts");
const isAdvanced = displayMode === "advanced";
const alertsInput = useMemo(
() =>
+1 -1
View File
@@ -31,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelAle
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelAlerts.error.internalServerError"),
message: (t) => t("widget.beszel.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -1,7 +1,19 @@
"use client";
import type { MantineSize } from "@mantine/core";
import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text, UnstyledButton } from "@mantine/core";
import {
Badge,
Box,
Card,
Center,
Group,
Loader,
Progress,
SimpleGrid,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import {
Activity,
Battery,
@@ -20,8 +32,9 @@ import { IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
@@ -168,7 +181,8 @@ const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
interface SystemCardProps {
system: BeszelSystemRow & { rowKey: string };
options: WidgetComponentProps<"beszelSystemGrid">["options"];
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
t: ReturnType<typeof useI18n<"widget.beszel">>;
tCommon: ReturnType<typeof useI18n<"common">>;
size: SizeConfig;
maxMetrics: number;
itemRadius: MantineSize;
@@ -177,14 +191,25 @@ interface SystemCardProps {
onClick?: () => void;
}
const metricRenderers = [
type BeszelMetricRenderer = {
key: string;
render: (
system: BeszelSystemRow,
t: SystemCardProps["t"],
size: SizeConfig,
tCommon: SystemCardProps["tCommon"],
) => React.ReactNode;
visible: (system: BeszelSystemRow, options: SystemCardProps["options"], advanced: boolean) => boolean;
};
const metricRenderers: BeszelMetricRenderer[] = [
{
key: "showCpu",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="cpu"
icon={<Cpu size={sz.iconSize} />}
label={t("metric.cpu")}
label={invariantTechnicalLabels.cpu}
value={formatPercent(s.cpu)}
progress={{ value: s.cpu, color: thresholdColor(s.cpu) }}
size={sz}
@@ -228,7 +253,7 @@ const metricRenderers = [
<MetricRow
key="gpu"
icon={<Monitor size={sz.iconSize} />}
label={t("metric.gpu")}
label={invariantTechnicalLabels.gpu}
value={formatPercent(s.gpu)}
progress={{ value: s.gpu, color: thresholdColor(s.gpu) }}
size={sz}
@@ -299,11 +324,11 @@ const metricRenderers = [
},
{
key: "showServices",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
render: (s: BeszelSystemRow, _t: SystemCardProps["t"], sz: SizeConfig, tCommon: SystemCardProps["tCommon"]) => (
<MetricRow
key="svc"
icon={<Server size={sz.iconSize} />}
label={t("metric.services")}
label={tCommon("services")}
value={String(s.services)}
size={sz}
/>
@@ -345,6 +370,7 @@ const SystemCard = ({
system,
options,
t,
tCommon,
size,
maxMetrics,
itemRadius,
@@ -375,12 +401,7 @@ const SystemCard = ({
}}
>
{onClick && (
<UnstyledButton
type="button"
className={classes.cardAction}
aria-label={system.name}
onClick={onClick}
/>
<UnstyledButton type="button" className={classes.cardAction} aria-label={system.name} onClick={onClick} />
)}
<Group gap="xs" mb={2}>
<Badge
@@ -395,20 +416,20 @@ const SystemCard = ({
{isAdvanced && (
<SimpleGrid cols={2} spacing={4} verticalSpacing={2} mb={4}>
<SystemMetadata label={t("metadata.hostname")} value={system.hostname || "—"} />
<SystemMetadata label={t("metadata.os")} value={system.osName || "—"} />
<SystemMetadata label={t("metadata.cpuModel")} value={system.cpuModel || "—"} />
<SystemMetadata label={t("metadata.cores")} value={String(system.cores)} />
<SystemMetadata label={t("metric.hostname")} value={system.hostname || "—"} />
<SystemMetadata label={invariantTechnicalLabels.os} value={system.osName || "—"} />
<SystemMetadata label={t("metric.cpuModel")} value={system.cpuModel || "—"} />
<SystemMetadata label={t("metric.cores")} value={String(system.cores)} />
<SystemMetadata
label={t("metadata.memoryTotal")}
label={t("metric.memoryTotal")}
value={system.memoryTotal > 0 ? formatBytes(system.memoryTotal) : "—"}
/>
<SystemMetadata label={t("metadata.source")} value={integrationName} />
<SystemMetadata label={t("metric.source")} value={integrationName} />
</SimpleGrid>
)}
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
{visibleMetrics.map((m) => m.render(system, t, size))}
{visibleMetrics.map((m) => m.render(system, t, size, tCommon))}
</Stack>
{hiddenMetricCount > 0 && (
<Text size="xs" c="dimmed" ta="right">
@@ -438,7 +459,9 @@ export default function BeszelSystemGridWidget({
height,
displayMode,
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const t = useI18n("widget.beszel");
const tFeature = useI18n("widget.beszelSystemGrid");
const tCommon = useI18n("common");
const board = useRequiredBoard();
const { openModal } = useModalAction(BeszelSystemStatsModal);
@@ -462,7 +485,7 @@ export default function BeszelSystemGridWidget({
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<Center h="100%">
@@ -491,7 +514,7 @@ export default function BeszelSystemGridWidget({
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<Box
@@ -516,6 +539,7 @@ export default function BeszelSystemGridWidget({
system={system}
options={options}
t={t}
tCommon={tCommon}
size={size}
maxMetrics={maxMetrics}
itemRadius={board.itemRadius}
@@ -31,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelSystemGrid.error.internalServerError"),
message: (t) => t("widget.beszel.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -10,7 +10,7 @@ import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
@@ -33,7 +33,8 @@ export default function BeszelSystemStatsWidget({
setOptions,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const t = useI18n("widget.beszelSystemStats");
const tBeszel = useI18n("widget.beszel");
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
@@ -150,7 +151,7 @@ export default function BeszelSystemStatsWidget({
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
<Text size="sm" c="dimmed">
{t("empty.noSystems")}
{tBeszel("empty.noSystems")}
</Text>
</Stack>
</Center>
@@ -74,7 +74,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelSystemStats.error.internalServerError"),
message: (t) => t("widget.beszel.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -21,9 +21,10 @@ import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
@@ -124,7 +125,9 @@ export default function BeszelSystemTableWidget({
setOptions,
displayMode,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const t = useI18n("widget.beszelSystemTable");
const tBeszel = useI18n("widget.beszel");
const tCommon = useI18n("common");
const { openModal } = useModalAction(BeszelSystemStatsModal);
const board = useOptionalBoard();
const { data: session } = useSession();
@@ -201,7 +204,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
<Text inherit>{t("column.system")}</Text>
<Text inherit>{tBeszel("metric.system")}</Text>
</Group>
),
sortable: true,
@@ -218,7 +221,7 @@ export default function BeszelSystemTableWidget({
accessor: "integrationName",
width: 140,
ellipsis: true,
title: t("column.source"),
title: tBeszel("metric.source"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.integrationName}</Text>,
},
@@ -226,7 +229,7 @@ export default function BeszelSystemTableWidget({
accessor: "hostname",
width: 150,
ellipsis: true,
title: t("column.hostname"),
title: tBeszel("metric.hostname"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.hostname || "—"}</Text>,
},
@@ -234,7 +237,7 @@ export default function BeszelSystemTableWidget({
accessor: "osName",
width: 160,
ellipsis: true,
title: t("column.os"),
title: invariantTechnicalLabels.os,
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.osName || "—"}</Text>,
},
@@ -242,21 +245,21 @@ export default function BeszelSystemTableWidget({
accessor: "cpuModel",
width: 220,
ellipsis: true,
title: t("column.cpuModel"),
title: tBeszel("metric.cpuModel"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cpuModel || "—"}</Text>,
},
isAdvanced && {
accessor: "cores",
width: 80,
title: t("column.cores"),
title: tBeszel("metric.cores"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cores}</Text>,
},
isAdvanced && {
accessor: "memoryTotal",
width: 120,
title: t("column.memoryTotal"),
title: tBeszel("metric.memoryTotal"),
sortable: true,
render: (record) => (
<Text size={size.fontSize}>{record.memoryTotal > 0 ? formatBytes(record.memoryTotal) : "—"}</Text>
@@ -268,7 +271,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<Cpu size={size.iconSize} />
<Text inherit>{t("column.cpu")}</Text>
<Text inherit>{invariantTechnicalLabels.cpu}</Text>
</Group>
),
sortable: true,
@@ -280,7 +283,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<MemoryStick size={size.iconSize} />
<Text inherit>{t("column.memory")}</Text>
<Text inherit>{tBeszel("metric.memory")}</Text>
</Group>
),
sortable: true,
@@ -292,7 +295,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<HardDrive size={size.iconSize} />
<Text inherit>{t("column.disk")}</Text>
<Text inherit>{tBeszel("metric.disk")}</Text>
</Group>
),
sortable: true,
@@ -312,7 +315,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<Monitor size={size.iconSize} />
<Text inherit>{t("column.gpu")}</Text>
<Text inherit>{invariantTechnicalLabels.gpu}</Text>
</Group>
),
sortable: true,
@@ -324,7 +327,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
<Text inherit>{t("column.loadAvg")}</Text>
<Text inherit>{tBeszel("metric.loadAvg")}</Text>
</Group>
),
sortable: true,
@@ -346,7 +349,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<Network size={size.iconSize} />
<Text inherit>{t("column.net")}</Text>
<Text inherit>{tBeszel("metric.net")}</Text>
</Group>
),
sortable: true,
@@ -362,7 +365,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<Thermometer size={size.iconSize} />
<Text inherit>{t("column.temp")}</Text>
<Text inherit>{tBeszel("metric.temp")}</Text>
</Group>
),
sortable: true,
@@ -374,7 +377,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<Battery size={size.iconSize} />
<Text inherit>{t("column.battery")}</Text>
<Text inherit>{tBeszel("metric.battery")}</Text>
</Group>
),
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
@@ -385,7 +388,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
<Text inherit>{t("column.services")}</Text>
<Text inherit>{tCommon("services")}</Text>
</Group>
),
sortable: true,
@@ -397,7 +400,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
<Text inherit>{t("column.uptime")}</Text>
<Text inherit>{tBeszel("metric.uptime")}</Text>
</Group>
),
sortable: true,
@@ -409,7 +412,7 @@ export default function BeszelSystemTableWidget({
title: (
<Group gap={4} wrap="nowrap">
<Wifi size={size.iconSize} />
<Text inherit>{t("column.agent")}</Text>
<Text inherit>{tBeszel("metric.agent")}</Text>
</Group>
),
sortable: true,
@@ -418,7 +421,7 @@ export default function BeszelSystemTableWidget({
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [t, size, visibleMetricKeys, isAdvanced]);
}, [tBeszel, tCommon, size, visibleMetricKeys, isAdvanced]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
@@ -462,7 +465,7 @@ export default function BeszelSystemTableWidget({
columns={effectiveColumns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
noRecordsText={tBeszel("empty.noSystems")}
idAccessor="rowKey"
storeColumnsKey={storeKey}
onRowClick={isEditMode ? undefined : handleRowClick}
@@ -1,22 +1,25 @@
import { IconServerOff, IconTable } from "@tabler/icons-react";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import type { SelectOption } from "../_inputs/widget-select-input";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
const sortOptions = [
{ value: "name", label: "System" },
{ value: "cpu", label: "CPU" },
{ value: "memory", label: "Memory" },
{ value: "disk", label: "Disk" },
{ value: "gpu", label: "GPU" },
{ value: "loadAvg", label: "Load Avg" },
{ value: "netBytes", label: "Net" },
{ value: "temp", label: "Temp" },
{ value: "services", label: "Services" },
{ value: "uptime", label: "Uptime" },
{ value: "agentVersion", label: "Agent" },
];
{ value: "name", label: (t) => t("widget.beszel.metric.system") },
{ value: "cpu", label: invariantTechnicalLabels.cpu },
{ value: "memory", label: (t) => t("widget.beszel.metric.memory") },
{ value: "disk", label: (t) => t("widget.beszel.metric.disk") },
{ value: "gpu", label: invariantTechnicalLabels.gpu },
{ value: "loadAvg", label: (t) => t("widget.beszel.metric.loadAvg") },
{ value: "netBytes", label: (t) => t("widget.beszel.metric.net") },
{ value: "temp", label: (t) => t("widget.beszel.metric.temp") },
{ value: "services", label: (t) => t("common.services") },
{ value: "uptime", label: (t) => t("widget.beszel.metric.uptime") },
{ value: "agentVersion", label: (t) => t("widget.beszel.metric.agent") },
] satisfies SelectOption[];
const sortDirectionOptions = [
{ value: "asc", label: "Ascending" },
@@ -67,7 +70,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelSystemTable.error.internalServerError"),
message: (t) => t("widget.beszel.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Box, Group, Popover, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
@@ -21,7 +21,7 @@ const mountLabel = (path: string) =>
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
const t = useScopedI18n("common");
const t = useI18n("common");
const [opened, setOpened] = useState(false);
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
const filesystemUsageLabel = t("filesystemUsage", { count: filesystems.length + 1 });
@@ -5,8 +5,9 @@ import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@manti
import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { containerColors } from "./colors";
@@ -73,7 +74,8 @@ export function BeszelStatsView({
columns,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const t = useI18n("widget.beszelSystemStats");
const tBeszel = useI18n("widget.beszel");
const locale = useCurrentIntlLocale();
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
@@ -91,7 +93,7 @@ export function BeszelStatsView({
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
cpu: (s: { cpu: number }) => ({ [invariantTechnicalLabels.cpu]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
@@ -110,7 +112,7 @@ export function BeszelStatsView({
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
cpu: [{ name: invariantTechnicalLabels.cpu, color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
@@ -238,7 +240,7 @@ export function BeszelStatsView({
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{t("error.internalServerError")}
{tBeszel("error.internalServerError")}
</Text>
</Stack>
</Center>
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties } from "react";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { formatLocalizedDate } from "../../common/locale";
@@ -54,7 +54,7 @@ interface BeszelTooltipProps {
const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }: BeszelTooltipProps) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("common");
const t = useI18n("common");
const isActive = active && (payload?.length ?? 0) > 0;
if (!isActive) return null;
@@ -6,7 +6,7 @@ import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { SortableItemListInput } from "../options";
@@ -17,9 +17,9 @@ export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["Ad
removeItem,
values,
}) => {
const t = useScopedI18n("widget.bookmarks.option.items.apps");
const tCommon = useScopedI18n("common");
const tSelect = useScopedI18n("app.action.select");
const t = useI18n("widget.bookmarks.option.items.apps");
const tCommon = useI18n("common");
const tSelect = useI18n("app.action.select");
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
const appsById = new Map(apps.map((app) => [app.id, app]));
+6 -1
View File
@@ -20,7 +20,12 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
layout: factory.select({
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
label: (t) => {
if (value === "grid") return t("widget.common.layout.option.grid");
if (value === "row") return t("widget.common.layout.option.horizontal");
if (value === "column") return t("widget.common.layout.option.vertical");
return t("widget.bookmarks.option.layout.option.gridHorizontal.label");
},
})),
defaultValue: "column",
}),
@@ -95,7 +95,7 @@ const CalendarEventRows = ({
advanced,
}: Pick<CalendarEventListProps, "events" | "advanced"> & { locale: string }) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
const t = useI18n("widget.calendar");
return (
<>
{events.map((event, eventIndex) => (
@@ -145,7 +145,7 @@ const CalendarEventRows = ({
{event.metadata?.type === "radarr" && (
<Group wrap="nowrap">
<Text c="dimmed" size="sm">
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
{t(`option.releaseType.options.${event.metadata.releaseType}`)}
</Text>
</Group>
)}
@@ -154,7 +154,7 @@ const CalendarEventRows = ({
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Text c={"dimmed"} size={"sm"}>
{t("widget.calendar.duration.allDay")}
{t("duration.allDay")}
</Text>
) : (
<>
+5 -5
View File
@@ -11,7 +11,7 @@ import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
@@ -138,7 +138,7 @@ const CalendarBase = ({
options,
displayMode,
}: CalendarBaseProps) => {
const t = useI18n();
const t = useI18n("widget.calendar");
const locale = useCurrentIntlLocale();
const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard();
@@ -171,7 +171,7 @@ const CalendarBase = ({
failedIntegrations={failedIntegrations}
isPending={isPending}
queryError={queryError}
queryErrorLabel={t("widget.calendar.name")}
queryErrorLabel={t("name")}
isEditMode={isEditMode}
locale={locale}
month={month}
@@ -185,7 +185,7 @@ const CalendarBase = ({
{(failedIntegrations.length > 0 || Boolean(queryError)) && (
<Group px="xs" justify="flex-end">
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={t("widget.calendar.name")} />
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
</Group>
)}
<Calendar
@@ -287,7 +287,7 @@ const CalendarAgenda = ({
month,
setMonth,
}: CalendarAgendaProps) => {
const t = useScopedI18n("widget.calendar.advanced");
const t = useI18n("widget.calendar.advanced");
const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month);
return (
+9 -9
View File
@@ -15,7 +15,7 @@ import {
import { IconAlertTriangle, IconClock } from "@tabler/icons-react";
import type { Dayjs } from "dayjs";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import type { WidgetComponentProps } from "../definition";
@@ -47,7 +47,7 @@ export const AdvancedClockView = ({
primaryTimeZone,
primaryTimeZoneInvalid,
}: AdvancedClockViewProps) => {
const t = useScopedI18n("widget.clock");
const t = useI18n("widget.clock");
const primaryLabel = getPrimaryLabel(options, t);
const primary: WorldClockTime = {
id: "primary",
@@ -95,7 +95,7 @@ interface ModeProps {
const PrimaryClock = ({ now, options, primary }: Pick<ModeProps, "now" | "options" | "primary">) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.clock");
const t = useI18n("widget.clock");
const primaryTime = primary.zonedTime;
if (!primaryTime || primary.phase === null || primary.minuteOfDay === null) return null;
@@ -152,7 +152,7 @@ const PrimaryClock = ({ now, options, primary }: Pick<ModeProps, "now" | "option
};
const OverviewMode = ({ now, options, primary, cities }: ModeProps) => {
const t = useScopedI18n("widget.clock");
const t = useI18n("widget.clock");
if (cities.length === 0) return <EmptyCities />;
return (
@@ -218,7 +218,7 @@ const ClockTime = ({
compact = false,
}: Omit<ClockEntryProps, "primaryOffset"> & { primary?: boolean; compact?: boolean }) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.clock");
const t = useI18n("widget.clock");
if (!entry.zonedTime) return null;
let formattedTime = formatLocalizedTime(now, locale, {
hour12: clockTimeFormatUses12Hours(options.customTimeFormat, options.is24HourFormat),
@@ -257,7 +257,7 @@ const ClockTime = ({
const DayAndOffset = ({ entry, now, primaryOffset }: Pick<ClockEntryProps, "entry" | "now" | "primaryOffset">) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.clock");
const t = useI18n("widget.clock");
if (entry.dayDifference === null || entry.utcOffset === null) return null;
const dayRelation = getDayRelation(entry.dayDifference, t);
const date = formatLocalizedDate(now, locale, {
@@ -280,7 +280,7 @@ const DayAndOffset = ({ entry, now, primaryOffset }: Pick<ClockEntryProps, "entr
};
const InvalidTimeZone = ({ entry }: { entry: WorldClockTime }) => {
const t = useScopedI18n("widget.clock");
const t = useI18n("widget.clock");
return (
<Paper component="li" withBorder radius="md" p="md">
<Group gap="xs" wrap="nowrap">
@@ -297,7 +297,7 @@ const InvalidTimeZone = ({ entry }: { entry: WorldClockTime }) => {
};
const EmptyCities = () => {
const t = useScopedI18n("widget.clock");
const t = useI18n("widget.clock");
return (
<Paper withBorder radius="md" p="xl">
<Text c="dimmed" ta="center">
@@ -307,7 +307,7 @@ const EmptyCities = () => {
);
};
type ScopedClockTranslator = ReturnType<typeof useScopedI18n<"widget.clock">>;
type ScopedClockTranslator = ReturnType<typeof useI18n<"widget.clock">>;
const getDayRelation = (difference: number, t: ScopedClockTranslator) => {
if (difference < 0) return t("worldClock.yesterday");
@@ -1,6 +1,6 @@
import { Badge, Box, Group, Progress, Stack, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { TimeOfDayPhase } from "./world-clock";
@@ -36,7 +36,7 @@ export const TimeOfDayBar = ({
showTicks = false,
label,
}: TimeOfDayBarProps) => {
const t = useScopedI18n("widget.clock");
const t = useI18n("widget.clock");
const progress = Math.max(0, Math.min(100, (minuteOfDay / 1440) * 100));
const remainingMinutes = Math.max(0, 1440 - Math.floor(minuteOfDay));
const remainingHours = Math.floor(remainingMinutes / 60);
@@ -1,7 +1,7 @@
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
@@ -26,7 +26,7 @@ export const ClockWeatherSummary = ({
detailed,
}: ClockWeatherSummaryProps) => {
const t = useI18n();
const tCommon = useScopedI18n("common");
const tCommon = useI18n("common");
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const weather = weatherQuery.data;
+2 -2
View File
@@ -3,12 +3,12 @@ import { Center, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
export const WidgetEmptyState = () => {
const t = useI18n();
const t = useI18n("widget.common.error");
return (
<Center h="100%" w="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{t("widget.common.error.noData")}
{t("noData")}
</Text>
</Center>
);
@@ -3,7 +3,7 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface IntegrationResult {
integrationId: string;
@@ -16,7 +16,7 @@ interface IntegrationErrorIndicatorProps {
}
export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicatorProps) {
const t = useScopedI18n("common");
const t = useI18n("common");
const failed = results.filter((result) => Boolean(result.error));
if (failed.length === 0) return null;
@@ -8,7 +8,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, values?: { widget?: string }) => {
useI18n: () => (key: string, values?: { widget?: string }) => {
if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`;
if (key === "loading") return "Loading widget data";
return key;
@@ -3,7 +3,7 @@
import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface WidgetQueryErrorIndicatorProps {
error: unknown;
@@ -12,7 +12,7 @@ interface WidgetQueryErrorIndicatorProps {
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
const t = useScopedI18n("common.widgetQuery");
const t = useI18n("common.widgetQuery");
if (!error) return null;
const accessibleLabel = t("stale", { widget: label });
@@ -35,7 +35,7 @@ export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndi
}
export function WidgetQueryLoadingState() {
const t = useScopedI18n("common.widgetQuery");
const t = useI18n("common.widgetQuery");
return (
<Center component="output" h="100%" w="100%" p="sm" aria-live="polite">
<Loader size="sm" />
@@ -4,7 +4,7 @@ import { Accordion, Badge, Group, Stack, Text } from "@mantine/core";
import { IconCloud } from "@tabler/icons-react";
import type { CoolifyApplicationWithContext } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { getBadgeColor, parseStatus } from "./coolify-utils";
import { ResourceRow } from "./resource-row";
@@ -16,8 +16,8 @@ interface ApplicationsSectionProps {
}
export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const t = useI18n("widget.coolify");
const tCommon = useI18n("common");
const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length;
return (
+6 -5
View File
@@ -3,7 +3,8 @@
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
@@ -21,7 +22,7 @@ export default function CoolifyWidget({
height,
displayMode,
}: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
const t = useI18n("widget.coolify");
if (integrationIds.length === 0) {
return (
@@ -54,7 +55,7 @@ interface CoolifyContentProps {
}
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
const t = useScopedI18n("common");
const t = useI18n("common");
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
integrationIds,
});
@@ -137,7 +138,7 @@ const FailedSourceBadges = ({
sources: { integrationId: string; integrationName: string }[];
queryError: unknown;
}) => {
const t = useScopedI18n("widget.coolify");
const t = useI18n("widget.coolify");
if (sources.length === 0 && !queryError) return null;
return (
@@ -148,7 +149,7 @@ const FailedSourceBadges = ({
</Badge>
))}
<Box ml="auto">
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
<WidgetQueryErrorIndicator error={queryError} label={getIntegrationName("coolify")} />
</Box>
</Group>
);
@@ -5,7 +5,7 @@ import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } fr
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
@@ -25,8 +25,8 @@ interface InstanceCardProps {
}
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const t = useI18n("widget.coolify");
const tCommon = useI18n("common");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${cardKey}`,
@@ -172,7 +172,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
}
const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Date }) => {
const t = useScopedI18n("widget.coolify");
const t = useI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000);
return (
<Group
@@ -10,7 +10,7 @@ import {
IconLoader2,
} from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
@@ -35,7 +35,7 @@ interface ResourceRowProps {
}
export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) {
const t = useScopedI18n("widget.coolify");
const t = useI18n("widget.coolify");
const status = parseStatus(item.status ?? "");
const statusColor = getStatusColor(status);
const isTransitioning = status === "starting" || status === "restarting";
@@ -11,7 +11,7 @@ import {
} from "@tabler/icons-react";
import type { CoolifyServer } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
@@ -36,14 +36,17 @@ export function ServersSection({
showIp,
onToggleIp,
}: ServersSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const t = useI18n("widget.coolify");
const tCommon = useI18n("common");
const onlineServers = servers.filter(isCoolifyServerOnline).length;
return (
<Accordion.Item value="servers">
<Group gap={0} wrap="nowrap">
<Accordion.Control icon={isTiny ? null : <IconServer size="var(--mantine-font-size-md)" />} style={{ flex: 1, minWidth: 0 }}>
<Accordion.Control
icon={isTiny ? null : <IconServer size="var(--mantine-font-size-md)" />}
style={{ flex: 1, minWidth: 0 }}
>
<Group gap="xs">
<Text size="xs">{tCommon("servers")}</Text>
<Badge variant="dot" color={getBadgeColor(onlineServers, servers.length)} size="xs">
@@ -101,7 +104,7 @@ interface ServerRowProps {
}
function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: ServerRowProps) {
const t = useScopedI18n("widget.coolify");
const t = useI18n("widget.coolify");
const isBuildServer = server.settings?.is_build_server === true;
const reachability = getCoolifyServerState(server, "is_reachable");
const usability = getCoolifyServerState(server, "is_usable");
@@ -158,7 +161,7 @@ function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: Serv
}
const ServerStateBadge = ({ field, value }: { field: "reachability" | "usability"; value: boolean | undefined }) => {
const t = useScopedI18n("widget.coolify");
const t = useI18n("widget.coolify");
const status =
value === undefined
? t("status.unknown")
@@ -4,7 +4,7 @@ import { Accordion, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
import { IconStack2 } from "@tabler/icons-react";
import type { CoolifyServiceWithContext } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
@@ -19,8 +19,8 @@ interface ServicesSectionProps {
}
export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: ServicesSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const t = useI18n("widget.coolify");
const tCommon = useI18n("common");
const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length;
return (
@@ -65,7 +65,7 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi
type ServiceApplication = NonNullable<CoolifyServiceWithContext["applications"]>[number];
const ServiceApplicationRow = ({ application }: { application: ServiceApplication }) => {
const t = useScopedI18n("widget.coolify");
const t = useI18n("widget.coolify");
const status = parseStatus(application.status ?? "");
const publicUrl = getSafeApplicationUrl(cleanFqdn(application.fqdn));
const statusLabel =
@@ -5,7 +5,7 @@ import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@manti
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
@@ -32,7 +32,7 @@ export function SingleInstanceLayout({
widgetKey,
hideFooter,
}: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const t = useI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
defaultValue: false,
@@ -120,7 +120,7 @@ export function SingleInstanceLayout({
}
const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Date }) => {
const t = useScopedI18n("widget.coolify");
const t = useI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000);
return (
<Group
+5 -5
View File
@@ -17,7 +17,7 @@ import {
} from "@mantine/core";
import { IconBell, IconBellOff, IconHourglass } from "@tabler/icons-react";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import {
claimBrowserAlertOccurrence,
@@ -59,7 +59,7 @@ export default function CountdownWidget({
}: WidgetComponentProps<"countdown">) {
const now = useWidgetNow(options.showSeconds ? "second" : "minute");
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.countdown");
const t = useI18n("widget.countdown");
const storagePrefix = boardId && itemId ? `homarr:countdown:${boardId}:${itemId}` : undefined;
const [preferences, setPreferences] = useLocalWidgetState<AlertPreferences>({
key: storagePrefix ? `${storagePrefix}:alert-preferences` : undefined,
@@ -228,7 +228,7 @@ const CountdownValue = ({
showSeconds: boolean;
compact?: boolean;
}) => {
const t = useScopedI18n("widget.countdown");
const t = useI18n("widget.countdown");
let value = t("invalid.value");
if (state.completed) value = t("completed");
else if (state.nextOccurrence)
@@ -287,7 +287,7 @@ const EventSection = ({
showProgress: boolean;
columns: number;
}) => {
const t = useScopedI18n("widget.countdown");
const t = useI18n("widget.countdown");
if (states.length === 0) return null;
return (
<Stack gap="xs">
@@ -330,7 +330,7 @@ const AlertControls = ({
notificationPermission: ReturnType<typeof getBrowserNotificationPermission>;
onNotificationsChange: (enabled: boolean) => Promise<void>;
}) => {
const t = useScopedI18n("widget.countdown.alert");
const t = useI18n("widget.countdown.alert");
if (!persistent) return null;
return (
<Paper withBorder p="sm" radius="md">
@@ -6,7 +6,7 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
@@ -20,8 +20,8 @@ export default function CustomApiWidget({
isEditMode,
removeItem,
}: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
const t = useI18n("widget.customApi");
const tCustomJsx = useI18n("widget.customApi.customJsx");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const definitionId = options.definitionId;
@@ -25,7 +25,7 @@ import { SAFE_TABLER_ICON_NAMES } from "./jsx-icon-adapter";
const CUSTOM_WIDGET_COMPONENTS = createCustomWidgetComponents({ copy: "Copy", copied: "Copied" });
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
useI18n: () => (key: string, params?: Record<string, string>) =>
key === "templateWarnings" ? `${params?.count ?? "0"} template warning(s):` : key,
}));
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
@@ -12,9 +12,13 @@ import { InactiveWidgetDefinitionProvider, WidgetDefinitionProvider } from "./wi
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("widget.customApi.customJsx");
const t = useI18n("widget.customApi.customJsx");
const actionT = useI18n("common.action");
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
const components = useMemo(() => createCustomWidgetComponents({ copy: t("copy"), copied: t("copied") }), [t]);
const components = useMemo(
() => createCustomWidgetComponents({ copy: actionT("copy"), copied: t("copied") }),
[actionT, t],
);
const [queryState, setQueryState] = useState<Record<string, CustomWidgetPublishedQueryState>>({});
useEffect(
() => setQueryState({}),
@@ -12,7 +12,7 @@ import type {
import { CustomWidgetRuntimeProvider } from "@homarr/custom-widgets/runtime";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { useQueryClient } from "@tanstack/react-query";
interface WidgetDefinitionProviderProps {
@@ -36,7 +36,8 @@ const INACTIVE_PORT: CustomWidgetRuntimePort = {
};
const useRuntimeMessages = (): CustomWidgetRuntimeMessages => {
const t = useScopedI18n("widget.customApi.customJsx");
const t = useI18n("widget.customApi.customJsx");
const actionT = useI18n("common.action");
return {
requestIdRequired: t("requestIdRequired"),
unsavedPreview: t("unsavedPreview"),
@@ -51,7 +52,7 @@ const useRuntimeMessages = (): CustomWidgetRuntimeMessages => {
actionCompleted: t("actionCompleted"),
confirmDelete: t("confirmDelete"),
toggle: t("toggle"),
refresh: t("refresh"),
refresh: actionT("refresh"),
};
};
@@ -13,7 +13,8 @@ interface TimerModalProps {
}
const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: TimerModalProps) => {
const t = useI18n();
const t = useI18n("widget.dnsHoleControls");
const tCommon = useI18n("common");
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const hoursHandlers = useRef<NumberInputHandlers>(null);
@@ -41,12 +42,12 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
setHours(0);
setMinutes(0);
}}
title={t("widget.dnsHoleControls.controls.setTimer")}
title={t("controls.setTimer")}
>
<Flex direction="column" align="center" justify="center">
<Stack align="flex-end">
<Group>
<Text>{t("widget.dnsHoleControls.controls.hours")}</Text>
<Text>{tCommon("information.hours")}</Text>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
–
</ActionIcon>
@@ -65,7 +66,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
</ActionIcon>
</Group>
<Group>
<Text>{t("widget.dnsHoleControls.controls.minutes")}</Text>
<Text>{tCommon("information.minutes")}</Text>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
–
</ActionIcon>
@@ -85,7 +86,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
</Group>
</Stack>
<Text ta="center" c="dimmed" my={5}>
{t("widget.dnsHoleControls.controls.unlimited")}
{t("controls.unlimited")}
</Text>
<Button
variant="light"
@@ -95,7 +96,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
w="12rem"
onClick={handleSetTimer}
>
{t("widget.dnsHoleControls.controls.set")}
{t("controls.set")}
</Button>
</Flex>
</Modal>
@@ -28,7 +28,6 @@ import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useIntegrationConnected } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
@@ -123,7 +122,8 @@ export default function DnsHoleControlsWidget({
{ enabled: [] as string[], disabled: [] as string[] },
);
const t = useI18n();
const t = useI18n("widget.dnsHoleControls");
const tCommon = useI18n("common");
// Timer modal setup
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
@@ -146,7 +146,7 @@ export default function DnsHoleControlsWidget({
return (
<Stack h="100%" justify="center" align="center">
<Text c="dimmed" size="sm">
{t("common.action.loading")}
{tCommon("action.loading")}
</Text>
</Stack>
);
@@ -165,14 +165,14 @@ export default function DnsHoleControlsWidget({
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
<Group gap={0}>
<IntegrationErrorIndicator results={summaryResults} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("widget.dnsHoleControls.name")} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
</Group>
</Box>
{controlAllButtonsVisible && (
<Flex className="dns-hole-controls-buttons" gap="sm">
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
<Tooltip label={t("controls.enableAll")}>
<Button
aria-label={t("widget.dnsHoleControls.controls.enableAll")}
aria-label={t("controls.enableAll")}
size="xs"
p={0}
className="dns-hole-controls-enable-all-button"
@@ -188,9 +188,9 @@ export default function DnsHoleControlsWidget({
</Button>
</Tooltip>
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
<Tooltip label={t("controls.setTimer")}>
<Button
aria-label={t("widget.dnsHoleControls.controls.setTimer")}
aria-label={t("controls.setTimer")}
size="xs"
p={0}
className="dns-hole-controls-timer-all-button"
@@ -209,9 +209,9 @@ export default function DnsHoleControlsWidget({
</Button>
</Tooltip>
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
<Tooltip label={t("controls.disableAll")}>
<Button
aria-label={t("widget.dnsHoleControls.controls.disableAll")}
aria-label={t("controls.disableAll")}
size="xs"
p={0}
className="dns-hole-controls-disable-all-button"
@@ -256,13 +256,13 @@ export default function DnsHoleControlsWidget({
{bulkFailureCount > 0 && (
<Text size="xs" c="red" ta="center">
{t("widget.dnsHoleControls.error.bulkActionsFailed", { count: bulkFailureCount })}
{t("error.bulkActionsFailed", { count: bulkFailureCount })}
</Text>
)}
{actionError && (
<Tooltip label={t("widget.dnsHoleControls.error.internalServerError")}>
<Tooltip label={t("error.internalServerError")}>
<Text size="xs" c="red" ta="center" lineClamp={2} tabIndex={0}>
{t("widget.dnsHoleControls.error.internalServerError")}
{t("error.internalServerError")}
</Text>
</Tooltip>
)}
@@ -283,7 +283,7 @@ interface ControlsCardProps {
data: AvailableDnsSummaryResult;
setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void;
t: TranslationFunction;
t: ReturnType<typeof useI18n<"widget.dnsHoleControls">>;
hasIconColor: boolean;
rootWidth: number;
rootHeight: number;
@@ -371,7 +371,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
{!isEnabled ? (
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${t("widget.dnsHoleControls.controls.enableAll")}: ${data.integration.name}`}
aria-label={`${t("controls.enableAll")}: ${data.integration.name}`}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
@@ -383,7 +383,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
) : (
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${t("widget.dnsHoleControls.controls.disableAll")}: ${data.integration.name}`}
aria-label={`${t("controls.disableAll")}: ${data.integration.name}`}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
@@ -395,7 +395,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
)}
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
aria-label={`${t("controls.setTimer")}: ${data.integration.name}`}
onClick={() => {
setSelectedIntegrationIds([data.integration.id]);
open();
@@ -413,9 +413,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
{layout === "md" && (
<UnstyledButton
aria-label={`${
isEnabled
? t("widget.dnsHoleControls.controls.disableAll")
: t("widget.dnsHoleControls.controls.enableAll")
isEnabled ? t("controls.disableAll") : t("controls.enableAll")
}: ${data.integration.name}`}
className="dns-hole-controls-item-toggle-button"
disabled={!controlEnabled}
@@ -442,7 +440,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
}
>
{t(
`widget.dnsHoleControls.controls.${
`controls.${
!isConnected
? "disconnected"
: typeof isEnabled === "undefined"
@@ -450,7 +448,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
: isEnabled
? "enabled"
: "disabled"
}`,
}` as never,
)}
</Badge>
</UnstyledButton>
@@ -459,7 +457,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</Flex>
{layout === "md" && (
<ActionIcon
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
aria-label={`${t("controls.setTimer")}: ${data.integration.name}`}
className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)}
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
@@ -47,6 +47,9 @@ export default function DnsHoleSummaryWidget({
const { isPending } = summaryQuery;
const t = useI18n();
const tCommon = useI18n("common");
const tDns = useI18n("widget.dnsHoleSummary");
const tWidgetCommon = useI18n("widget.common");
const successfulSummaries = summaries.filter(({ summary }) => summary !== null);
const data = successfulSummaries.flatMap(({ summary }) => (summary ? [summary] : []));
@@ -57,7 +60,7 @@ export default function DnsHoleSummaryWidget({
return (
<Stack h="100%" justify="center" align="center">
<Text c="dimmed" size="sm">
{t("common.action.loading")}
{tCommon("action.loading")}
</Text>
</Stack>
);
@@ -68,7 +71,7 @@ export default function DnsHoleSummaryWidget({
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
<Group gap={0}>
<IntegrationErrorIndicator results={summaries} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("widget.dnsHoleSummary.name")} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={tDns("name")} />
</Group>
</Box>
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
@@ -95,7 +98,7 @@ export default function DnsHoleSummaryWidget({
))}
</AvatarGroup>
<Text fz="md" ta="center">
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
{tWidgetCommon("integrationDisconnected")}
</Text>
</Stack>
)}
@@ -110,7 +113,7 @@ export default function DnsHoleSummaryWidget({
color={summary?.status === "enabled" ? "green" : summary?.status === "disabled" ? "red" : "gray"}
style={{ flexShrink: 0 }}
>
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary?.status ?? "unknown"}` as never)}
{integration.name}: {tDns(`status.${summary?.status ?? "unknown"}` as never)}
</Badge>
))}
</Group>
@@ -20,7 +20,11 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind
layout: factory.select({
options: (["grid", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.dnsHoleSummary.option.layout.option.${value}.label`),
label: (t) => {
if (value === "grid") return t("widget.common.layout.option.grid");
if (value === "row") return t("widget.common.layout.option.horizontal");
return t("widget.common.layout.option.vertical");
},
})),
defaultValue: "grid",
}),
+18 -14
View File
@@ -31,6 +31,7 @@ import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
@@ -40,7 +41,7 @@ import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } fr
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -92,7 +93,7 @@ const getContainersQueryInput = (endpointIds: string[]) => {
};
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
const t = useI18n("docker.field.state.option");
return (
<Badge
@@ -108,7 +109,7 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
};
const ContainerStateDot = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
const t = useI18n("docker.field.state.option");
const label = t(state);
return (
@@ -129,14 +130,15 @@ const ContainerStateDot = ({ state }: { state: ContainerState }) => {
};
const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>,
t: ReturnType<typeof useI18n<"docker">>,
tCommon: ReturnType<typeof useI18n<"common">>,
handlers: ContainerActionHandlers,
sortingEnabled: boolean,
inlineState: boolean,
): DataTableColumn<DockerContainer>[] => [
{
accessor: "name",
title: t("field.name.label"),
title: tCommon("field.name"),
width: 180,
ellipsis: true,
sortable: sortingEnabled,
@@ -179,7 +181,7 @@ const createColumns = (
},
{
accessor: "cpuUsage",
title: t("field.stats.cpu.label"),
title: invariantTechnicalLabels.cpu,
width: 80,
sortable: sortingEnabled,
render: (container) => {
@@ -232,7 +234,7 @@ const compareContainers = (
};
const useContainerAction = (action: ContainerAction) => {
const t = useScopedI18n("docker.action");
const t = useI18n("docker.action");
const utils = clientApi.useUtils();
return clientApi.docker[`${action}All`].useMutation({
@@ -264,8 +266,9 @@ export default function DockerWidget({
itemId,
setOptions,
}: WidgetComponentProps<"dockerContainers">) {
const t = useScopedI18n("docker");
const tWidget = useScopedI18n("widget.dockerContainers");
const t = useI18n("docker");
const tCommon = useI18n("common");
const tWidget = useI18n("widget.dockerContainers");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const confirmRemoval = useDockerContainerRemovalConfirmation();
const board = useOptionalBoard();
@@ -384,10 +387,10 @@ export default function DockerWidget({
!isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state");
const columns = useMemo(() => {
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode;
return createColumns(t, actionHandlers, sortingEnabled, inlineState).filter(
return createColumns(t, tCommon, actionHandlers, sortingEnabled, inlineState).filter(
({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility],
);
}, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t]);
}, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t, tCommon]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
@@ -507,7 +510,7 @@ function ContainerMenuButton({
container: DockerContainer;
handlers: ContainerActionHandlers;
}) {
const t = useScopedI18n("docker.action");
const t = useI18n("docker.action");
const [opened, setOpened] = useState(false);
const close = () => setOpened(false);
@@ -548,7 +551,8 @@ function ContainerActionItems({
handlers: ContainerActionHandlers;
onClose: () => void;
}) {
const t = useScopedI18n("docker.action");
const t = useI18n("docker.action");
const tCommon = useI18n("common");
const stateAction = container.state === "running" ? "stop" : "start";
const StateIcon = stateAction === "stop" ? IconPlayerStop : IconPlayerPlay;
@@ -595,7 +599,7 @@ function ContainerActionItems({
disabled={!handlers.canUse(container, "remove")}
onClick={() => invokeAction("remove")}
>
{t("remove.label")}
{tCommon("action.remove")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
+7 -4
View File
@@ -2,6 +2,7 @@ import { IconBrandDocker, IconServerOff } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -16,13 +17,12 @@ const columnsList = [
const allColumnsList = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const columnTranslationKeyMap = {
name: "docker.field.name.label",
name: "common.field.name",
state: "docker.field.state.label",
host: "docker.field.host.label",
cpuUsage: "docker.field.stats.cpu.label",
memoryUsage: "docker.field.stats.memory.label",
actions: "docker.action.title",
} as const satisfies Record<(typeof allColumnsList)[number], string>;
} as const satisfies Record<Exclude<(typeof allColumnsList)[number], "cpuUsage">, string>;
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
@@ -49,7 +49,10 @@ export const { definition, componentLoader } = createWidgetDefinition("dockerCon
defaultValue: [...allColumnsList],
options: allColumnsList.map((value) => ({
value,
label: (t) => t(columnTranslationKeyMap[value]),
label: (t) => {
if (value === "cpuUsage") return invariantTechnicalLabels.cpu;
return t(columnTranslationKeyMap[value]);
},
})),
searchable: true,
}),
+17 -19
View File
@@ -50,7 +50,7 @@ import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/co
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { showErrorNotification } from "@homarr/notifications";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -69,7 +69,7 @@ import {
dayjs.extend(relativeTime);
type DownloadsT = ReturnType<typeof useScopedI18n<"widget.downloads">>;
type DownloadsT = ReturnType<typeof useI18n<"widget.downloads">>;
type DownloadState = ExtendedDownloadClientItem["state"];
interface SizeConfig {
@@ -315,7 +315,7 @@ export default function DownloadClientsWidget({
const availableItems = useMemo(() => currentItems?.filter((item) => item.data !== null) ?? [], [currentItems]);
const { isFetching } = downloadsQuery;
const t = useScopedI18n("widget.downloads");
const t = useI18n("widget.downloads");
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={currentItems ?? []} />
@@ -793,9 +793,7 @@ export default function DownloadClientsWidget({
animateOpacity: true,
transitionTimingFunction: "ease-out",
},
content: ({ record, collapse }) => (
<ExpandedRow item={record} collapse={collapse} />
),
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
}}
onScroll={() => {
if (contextMenu) closeContextMenu();
@@ -871,14 +869,8 @@ function buildHoverTooltip(record: ExtendedDownloadClientItem, t: DownloadsT): R
);
}
function ExpandedRow({
item,
collapse,
}: {
item: ExtendedDownloadClientItem;
collapse: () => void;
}) {
const t = useScopedI18n("widget.downloads");
function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) {
const t = useI18n("widget.downloads");
const locale = useCurrentIntlLocale();
const progressPercent = Math.floor(item.progress * 100);
const categoryDisplay = formatCategoryDisplay(item.category);
@@ -994,7 +986,7 @@ function GlobalStatsBar({
hasTorrents: boolean;
clients: ExtendedClientStatus[];
}) {
const t = useScopedI18n("widget.downloads");
const t = useI18n("widget.downloads");
let overallProgress = 0;
if (queueStats.totalSize > 0) overallProgress = queueStats.completedSize / queueStats.totalSize;
@@ -1214,7 +1206,7 @@ function WidgetFooter({
showStats,
toggleStats,
}: WidgetFooterProps) {
const t = useScopedI18n("widget.downloads");
const t = useI18n("widget.downloads");
const [filterOpen, { toggle: toggleFilter }] = useDisclosure(false);
const someInteract = clients.some(({ interact }) => interact);
const hasActiveFilter = clientFilter.length > 0 || statusFilter.length > 0;
@@ -1372,12 +1364,18 @@ function WidgetFooter({
{!showStats && (
<Group gap={2}>
<Text size="xs" fw={600} c="blue">
<IconDownload size="var(--mantine-font-size-xs)" style={{ verticalAlign: "middle", marginRight: 2 }} />
<IconDownload
size="var(--mantine-font-size-xs)"
style={{ verticalAlign: "middle", marginRight: 2 }}
/>
{formatByteRate(totalSpeed)}
</Text>
{totalUpSpeed > 0 && (
<Text size="xs" fw={600} c="green">
<IconUpload size="var(--mantine-font-size-xs)" style={{ verticalAlign: "middle", marginRight: 2 }} />
<IconUpload
size="var(--mantine-font-size-xs)"
style={{ verticalAlign: "middle", marginRight: 2 }}
/>
{formatByteRate(totalUpSpeed)}
</Text>
)}
@@ -1409,7 +1407,7 @@ function ClientIndicator({ integration }: { integration: ExtendedClientStatus["i
const isConnected = useIntegrationConnected(integration.updatedAt, { timeout: 30000 });
let tooltipLabel = integration.name;
const t = useScopedI18n("widget.downloads");
const t = useI18n("widget.downloads");
if (!isConnected) tooltipLabel = `${integration.name} ${t("disconnected")}`;
let avatarFilter: string | undefined;
@@ -17,6 +17,7 @@ export interface BaseWidgetErrorProps {
export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
const t = useI18n();
const tCommon = useI18n("common");
const { data: session } = useSession();
const [errorTimestamp] = useState(Date.now);
@@ -33,13 +34,13 @@ export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
ta="center"
size="sm"
>
{t("common.action.checkLogs")}
{tCommon("action.checkLogs")}
</Anchor>
)}
</Stack>
<Button onClick={props.onRetry} size="sm" variant="light">
{t("common.action.tryAgain")}
{tCommon("action.tryAgain")}
</Button>
</Stack>
);
+23 -25
View File
@@ -19,8 +19,8 @@ import { useLocalStorage } from "@mantine/hooks";
import { IconArrowBarDown, IconArrowBarUp, IconBrain, IconCpu, IconTopologyBus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import type { FirewallInterfacesSummary } from "@homarr/integrations";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
@@ -67,7 +67,8 @@ export default function FirewallWidget({
const isTiny = !isAdvanced && (width < 256 || height < 180);
const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100;
const showInterfaces = isAdvanced || (!isTiny && height >= 120);
const t = useI18n();
const t = useI18n("widget.firewall");
const tCommon = useI18n("common");
const handleSelect = useCallback((value: string | null) => {
setSelectedFirewall(value ?? "");
@@ -96,7 +97,7 @@ export default function FirewallWidget({
});
if (hasTotalFailure) {
throw new Error(t("widget.firewall.error.internalServerError"));
throw new Error(t("error.internalServerError"));
}
const firewallMetadata = new Map(
@@ -119,7 +120,7 @@ export default function FirewallWidget({
return (
<Center h="100%" p="sm">
<Text size="sm" c="dimmed" ta="center">
{isLoading ? t("common.action.loading") : t("widget.firewall.empty.noInterfaces")}
{isLoading ? tCommon("action.loading") : t("empty.noInterfaces")}
</Text>
</Center>
);
@@ -138,9 +139,9 @@ export default function FirewallWidget({
/>
<Group gap={4} wrap="nowrap">
{activeFirewall && firewallHasError(activeFirewall) && (
<Tooltip label={t("widget.firewall.error.internalServerError")}>
<Tooltip label={t("error.internalServerError")}>
<Badge color="red" variant="light" size="xs">
{t("common.error")}
{tCommon("error")}
</Badge>
</Tooltip>
)}
@@ -179,12 +180,13 @@ export default function FirewallWidget({
showInterfaces={showInterfaces}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={t("widget.firewall.error.internalServerError")}
noDataLabel={t("widget.firewall.empty.noInterfaces")}
loadingLabel={t("common.action.loading")}
errorBadgeLabel={t("common.error")}
interfacesLabel={t("widget.firewall.widget.interfaces.title")}
errorLabel={t("error.internalServerError")}
noDataLabel={t("empty.noInterfaces")}
loadingLabel={tCommon("action.loading")}
errorBadgeLabel={tCommon("error")}
interfacesLabel={t("widget.interfaces.title")}
t={t}
tCommon={tCommon}
/>
);
})}
@@ -214,7 +216,8 @@ interface FirewallPanelProps {
loadingLabel: string;
errorBadgeLabel: string;
interfacesLabel: string;
t: TranslationFunction;
t: ReturnType<typeof useI18n<"widget.firewall">>;
tCommon: ReturnType<typeof useI18n<"common">>;
}
const FirewallPanel = ({
@@ -238,6 +241,7 @@ const FirewallPanel = ({
errorBadgeLabel,
interfacesLabel,
t,
tCommon,
}: FirewallPanelProps) => (
<Paper withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0}>
<Stack gap="xs">
@@ -257,12 +261,12 @@ const FirewallPanel = ({
{hasError && (
<Tooltip label={errorLabel}>
<Badge color="red" variant="light" size="xs">
{t("common.error")}
{tCommon("error")}
</Badge>
</Tooltip>
)}
<Badge variant="outline" color="gray" size="xs">
{formatVersion(version ?? "", t("widget.firewall.versionUnknown"))}
{formatVersion(version ?? "", t("versionUnknown"))}
</Badge>
</Group>
</Group>
@@ -270,16 +274,10 @@ const FirewallPanel = ({
<Flex justify="center" align="center" wrap="wrap">
{cpu !== undefined && (
<MetricRing value={cpu} icon={IconCpu} size={ringSize} label={t("widget.firewall.widget.cpu")} t={t} />
<MetricRing value={cpu} icon={IconCpu} size={ringSize} label={invariantTechnicalLabels.cpu} t={t} />
)}
{memory !== undefined && (
<MetricRing
value={memory}
icon={IconBrain}
size={ringSize}
label={t("widget.firewall.widget.memory")}
t={t}
/>
<MetricRing value={memory} icon={IconBrain} size={ringSize} label={t("widget.memory")} t={t} />
)}
</Flex>
@@ -307,19 +305,19 @@ interface MetricRingProps {
icon: typeof IconCpu;
size: number;
label: string;
t: TranslationFunction;
t: ReturnType<typeof useI18n<"widget.firewall">>;
}
const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
const status = getMetricStatus(safeValue);
const statusLabel = t(`widget.firewall.status.${status}`);
const statusLabel = t(`status.${status}` as never);
const showIcon = size >= 64;
const showStatus = size >= 96;
return (
<RingProgress
aria-label={t("widget.firewall.metricAccessible", {
aria-label={t("metricAccessible", {
metric: label,
value: safeValue.toFixed(1),
status: statusLabel,
@@ -20,7 +20,7 @@ export interface FirewallIntegration {
}
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => {
const t = useI18n();
const t = useI18n("widget.firewall");
const version = firewallsVersionData.find(({ integration }) => integration.id === selectedFirewall)?.summary.version;
return (
@@ -33,7 +33,7 @@ export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny
style={{ minHeight: "24px", maxWidth: isTiny ? 72 : undefined }}
styles={{ label: { overflow: "hidden", textOverflow: "ellipsis" } }}
>
{formatVersion(version ?? "", t("widget.firewall.versionUnknown"))}
{formatVersion(version ?? "", t("versionUnknown"))}
</Badge>
</Box>
);
@@ -3,6 +3,7 @@ import { Accordion, Box, Center, Flex, Group, RingProgress, Stack, Text } from "
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
@@ -39,7 +40,7 @@ export const ClusterHealthMonitoring = ({
width,
displayMode,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n();
const t = useI18n("widget.healthMonitoring");
const healthQuery = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({ integrationId });
const healthData = getUsableWidgetQueryData(healthQuery);
const visibleSections = getClusterVisibleSections(displayMode, options.visibleClusterSections);
@@ -71,7 +72,7 @@ export const ClusterHealthMonitoring = ({
return (
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("widget.healthMonitoring.name")} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Box>
{(isAdvanced || options.showUptime) && !isTiny && (
<Group justify="center" wrap="nowrap">
@@ -102,7 +103,7 @@ export const ClusterHealthMonitoring = ({
{visibleSections.includes("node") && (
<ResourceAccordionItem
value="node"
title={t("widget.healthMonitoring.cluster.resource.node.name")}
title={t("cluster.resource.node.name")}
icon={IconServer}
badge={addBadgeColor({
activeCount: activeNodes,
@@ -118,7 +119,7 @@ export const ClusterHealthMonitoring = ({
{visibleSections.includes("qemu") && (
<ResourceAccordionItem
value="qemu"
title={t("widget.healthMonitoring.cluster.resource.qemu.name")}
title={t("cluster.resource.qemu.name")}
icon={IconDeviceLaptop}
badge={addBadgeColor({
activeCount: activeVMs,
@@ -134,7 +135,7 @@ export const ClusterHealthMonitoring = ({
{visibleSections.includes("lxc") && (
<ResourceAccordionItem
value="lxc"
title={t("widget.healthMonitoring.cluster.resource.lxc.name")}
title={t("cluster.resource.lxc.name")}
icon={IconCube}
badge={addBadgeColor({
activeCount: activeLXCs,
@@ -150,7 +151,7 @@ export const ClusterHealthMonitoring = ({
{visibleSections.includes("storage") && (
<ResourceAccordionItem
value="storage"
title={t("widget.healthMonitoring.cluster.resource.storage.name")}
title={t("cluster.resource.storage.name")}
icon={IconDatabase}
badge={addBadgeColor({
activeCount: activeStorage,
@@ -175,8 +176,6 @@ interface SummaryHeaderProps {
}
const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
const t = useI18n();
if (cpu.hidden && memory.hidden) return null;
return (
@@ -198,7 +197,7 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
<Stack align="center" justify="center" gap={0}>
{!isTiny && (
<Text fw={500} size="sm">
{t("widget.healthMonitoring.cluster.summary.cpu")}
{invariantTechnicalLabels.cpu}
</Text>
)}
<Text size="xs">{cpu.value.toFixed(1)}%</Text>
@@ -221,7 +220,7 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
<Stack align="center" justify="center" gap={0}>
{!isTiny && (
<Text size="sm" fw={500}>
{t("widget.healthMonitoring.cluster.summary.memory")}
{invariantTechnicalLabels.ram}
</Text>
)}
<Text size="xs">{memory.value.toFixed(1)}%</Text>
@@ -18,7 +18,7 @@ import duration from "dayjs/plugin/duration";
import { capitalize, formatBytes, formatBytesPair } from "@homarr/common";
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
dayjs.extend(duration);
@@ -46,7 +46,7 @@ export const ResourcePopover = ({ item, children }: PropsWithChildren<ResourcePo
};
export const ResourceTypeEntryDetails = ({ item }: { item: Resource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover");
const t = useI18n("widget.healthMonitoring.cluster.popover");
return (
<Stack gap={0}>
<Group wrap="nowrap" align="start" justify="apart">
@@ -92,7 +92,7 @@ const RightSection = ({ label, value }: RightSectionProps) => {
};
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const t = useI18n("widget.healthMonitoring.cluster.popover.detail");
const memory = formatBytesPair(item.memory.used, item.memory.total);
const storage = formatBytesPair(item.storage.used, item.storage.total);
return (
@@ -121,7 +121,7 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
};
const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const t = useI18n("widget.healthMonitoring.cluster.popover.detail");
const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
const storage = formatBytesPair(item.used, item.total);
return (
@@ -188,7 +188,7 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
};
const StorageType = ({ item }: { item: StorageResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail.storageType");
const t = useI18n("widget.healthMonitoring.cluster.popover.detail.storageType");
if (item.isShared) {
return <Badge color="blue">{t("shared")}</Badge>;
} else {
@@ -11,6 +11,7 @@ import {
Text,
} from "@mantine/core";
import { invariantTechnicalLabels } from "@homarr/definitions";
import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
@@ -23,27 +24,27 @@ interface ResourceTableProps {
}
export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
const t = useI18n();
const t = useI18n("widget.healthMonitoring");
return (
<Table highlightOnHover>
<TableThead>
<TableTr fz="xs">
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.name")}
{t("cluster.table.header.name")}
</TableTh>
{!isTiny && type !== "storage" ? (
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.cpu")}
{invariantTechnicalLabels.cpu}
</TableTh>
) : null}
{!isTiny && type !== "storage" ? (
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.memory")}
{invariantTechnicalLabels.ram}
</TableTh>
) : null}
{!isTiny && type === "storage" ? (
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.node")}
{t("cluster.table.header.node")}
</TableTh>
) : null}
</TableTr>
@@ -17,7 +17,7 @@ dayjs.extend(duration);
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const integrations = getUsableWidgetQueryData(clientApi.integration.byIds.useQuery(props.integrationIds)) ?? [];
const t = useI18n();
const t = useI18n("widget.healthMonitoring");
const { clusterIntegrationIds, systemIntegrationIds } = partitionHealthMonitoringIntegrations(integrations);
@@ -68,10 +68,10 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
<Tabs defaultValue={props.options.defaultTab} variant="outline">
<Tabs.List grow>
<Tabs.Tab value="system" fz="xs">
<b>{t("widget.healthMonitoring.tab.system")}</b>
<b>{t("tab.system")}</b>
</Tabs.Tab>
<Tabs.Tab value="cluster" fz="xs">
<b>{t("widget.healthMonitoring.tab.cluster")}</b>
<b>{t("tab.cluster")}</b>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="system">
@@ -38,7 +38,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation";
import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
@@ -63,7 +63,7 @@ export const SystemHealthMonitoring = ({
displayMode,
withScrollArea = true,
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
const t = useI18n();
const t = useI18n("widget.healthMonitoring");
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
const healthResults = getUsableWidgetQueryData(healthQuery) ?? [];
const healthData = healthResults.filter(
@@ -81,7 +81,7 @@ export const SystemHealthMonitoring = ({
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={healthResults} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("widget.healthMonitoring.name")} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
@@ -155,7 +155,7 @@ export const SystemHealthMonitoring = ({
size="sm"
radius={board.itemRadius}
onClick={() => setOpenedIntegrationId(integrationId)}
aria-label={t("widget.healthMonitoring.popover.information")}
aria-label={t("popover.information")}
>
<IconInfoCircle className="health-monitoring-information-icon" size={30} />
</ActionIcon>
@@ -164,7 +164,7 @@ export const SystemHealthMonitoring = ({
opened={openedIntegrationId === integrationId}
onClose={() => setOpenedIntegrationId(null)}
size="auto"
title={t("widget.healthMonitoring.popover.information")}
title={t("popover.information")}
centered
>
<Stack gap="10px" className="health-monitoring-modal-stack">
@@ -253,10 +253,10 @@ export const SystemHealthMonitoring = ({
</Progress.Root>
<Group justify="space-between" gap={8} wrap="nowrap">
<Text className="health-monitoring-disk-use-value" size="xs" c="dimmed">
{t("widget.healthMonitoring.popover.used")} {formatFileSize(disk.used)}
{t("popover.used")} {formatFileSize(disk.used)}
</Text>
<Text className="health-monitoring-disk-available-value" size="xs" c="dimmed">
{formatFileSize(disk.available)} {t("widget.healthMonitoring.popover.available")}
{formatFileSize(disk.available)} {t("popover.available")}
</Text>
</Group>
</Stack>
@@ -283,7 +283,7 @@ const SystemInformationList = ({
}: {
healthInfo: HealthInfo;
memoryUsage: ReturnType<typeof formatMemoryUsage>;
t: TranslationFunction;
t: ScopedTranslationFunction<"widget.healthMonitoring">;
compact?: boolean;
}) => (
<List
@@ -293,48 +293,46 @@ const SystemInformationList = ({
size={compact ? "sm" : undefined}
>
<List.Item className="health-monitoring-information-processor" icon={<IconCpu2 size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
{t("popover.processor", { cpuModelName: healthInfo.cpuModelName })}
</List.Item>
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
{t("popover.memory", { memory: memoryUsage.memTotal.GB })}
</List.Item>
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.memoryAvailable", {
{t("popover.memoryAvailable", {
memoryAvailable: memoryUsage.memFree.GB,
percent: String(memoryUsage.memFree.percent),
})}
</List.Item>
<List.Item className="health-monitoring-information-version" icon={<IconVersions size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.version", { version: healthInfo.version })}
{t("popover.version", { version: healthInfo.version })}
</List.Item>
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={compact ? 18 : 30} />}>
{formatUptime(healthInfo.uptime, t)}
</List.Item>
{healthInfo.loadAverage && (
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.loadAverage")}: {healthInfo.loadAverage["1min"]}% /{" "}
{healthInfo.loadAverage["5min"]}% / {healthInfo.loadAverage["15min"]}%
{t("popover.loadAverage")}: {healthInfo.loadAverage["1min"]}% / {healthInfo.loadAverage["5min"]}% /{" "}
{healthInfo.loadAverage["15min"]}%
</List.Item>
)}
<List.Item className="health-monitoring-information-updates" icon={<IconPackages size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.updatesAvailable", { count: healthInfo.availablePkgUpdates })}
{t("popover.updatesAvailable", { count: healthInfo.availablePkgUpdates })}
</List.Item>
<List.Item className="health-monitoring-information-reboot" icon={<IconRefreshAlert size={compact ? 18 : 30} />}>
{healthInfo.rebootRequired
? t("widget.healthMonitoring.popover.rebootRequired")
: t("widget.healthMonitoring.popover.rebootNotRequired")}
{healthInfo.rebootRequired ? t("popover.rebootRequired") : t("popover.rebootNotRequired")}
</List.Item>
</List>
);
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
export const formatUptime = (uptimeInSeconds: number, t: ScopedTranslationFunction<"widget.healthMonitoring">) => {
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
const months = uptimeDuration.months();
const days = uptimeDuration.days();
const hours = uptimeDuration.hours();
const minutes = uptimeDuration.minutes();
return t("widget.healthMonitoring.popover.uptime", {
return t("popover.uptime", {
months: String(months),
days: String(days),
hours: String(hours),
+6 -6
View File
@@ -11,7 +11,7 @@ import { getSafeApplicationUrl } from "../common/application-url";
import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const t = useI18n("widget.iframe");
const { embedUrl, allowScrolling, ...permissions } = options;
const safeEmbedUrl = getSafeApplicationUrl(embedUrl);
const allowedPermissions = getAllowedPermissions(permissions);
@@ -35,7 +35,7 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
>
<Text>{t("widget.iframe.error.noBrowerSupport")}</Text>
<Text>{t("error.noBrowerSupport")}</Text>
</iframe>
</Box>
</Stack>
@@ -49,24 +49,24 @@ export const isSupportedProtocol = (url: string) => {
};
const NoUrl = () => {
const t = useI18n();
const t = useI18n("widget.iframe");
return (
<Stack align="center" justify="center" h="100%">
<IconBrowserOff />
<Title order={4}>{t("widget.iframe.error.noUrl")}</Title>
<Title order={4}>{t("error.noUrl")}</Title>
</Stack>
);
};
const UnsupportedProtocol = () => {
const t = useI18n();
const t = useI18n("widget.iframe");
return (
<Stack align="center" justify="center" h="100%">
<IconProtocol />
<Title order={4} ta="center">
{t("widget.iframe.error.unsupportedProtocol", {
{t("error.unsupportedProtocol", {
supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "),
})}
</Title>
+1 -1
View File
@@ -28,7 +28,7 @@ export const { definition, componentLoader } = createWidgetDefinition("iframe",
? [
{
key: "open-iframe",
label: "widget.app.option.openInNewTab.label",
label: "widget.common.openInNewTab.label",
icon: IconExternalLink,
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
},
@@ -14,7 +14,7 @@ import {
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
@@ -32,7 +32,7 @@ export default function ImmichAlbumCarouselWidget({
}: WidgetComponentProps<"immich-albumCarousel">) {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [paused, setPaused] = useState(false);
const t = useI18n();
const t = useI18n("widget.immich-albumCarousel");
const albumQuery = clientApi.widget.immich.getAlbum.useQuery(
{
@@ -72,7 +72,7 @@ export default function ImmichAlbumCarouselWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 3 }}>
<WidgetQueryErrorIndicator error={albumQuery.error} label={t("widget.immich-albumCarousel.name")} />
<WidgetQueryErrorIndicator error={albumQuery.error} label={t("name")} />
</Box>
{album.assets.length === 0 || photoAssets.length === 0 ? (
<NoPhotosInAlbum />
@@ -133,7 +133,7 @@ function Carousel({
paused,
setPaused,
}: CarouselProps) {
const t = useScopedI18n("widget.immich-albumCarousel");
const t = useI18n("widget.immich-albumCarousel");
const locale = useCurrentIntlLocale();
const reduceMotion = useReducedMotion();
@@ -189,7 +189,11 @@ function Carousel({
size={advanced ? 40 : 32}
onClick={() => setPaused((value) => !value)}
>
{paused ? <IconPlayerPlay size="var(--mantine-font-size-lg)" /> : <IconPlayerPause size="var(--mantine-font-size-lg)" />}
{paused ? (
<IconPlayerPlay size="var(--mantine-font-size-lg)" />
) : (
<IconPlayerPause size="var(--mantine-font-size-lg)" />
)}
</ActionIcon>
{advanced && (
<ActionIcon
@@ -254,13 +258,13 @@ function Carousel({
}
function NoPhotosInAlbum() {
const t = useI18n();
const t = useI18n("widget.immich-albumCarousel");
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertCircle size={32} />
<Text size="sm" fw={500}>
{t("widget.immich-albumCarousel.noPhotos")}
{t("noPhotos")}
</Text>
</Stack>
</Center>
@@ -2,7 +2,7 @@ import { IconChevronLeft, IconChevronRight, IconPhoto, IconPlayerPause } from "@
import z from "zod";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -15,7 +15,7 @@ const createOptions = () =>
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const t = useScopedI18n("widget.immich-albumCarousel");
const t = useI18n("widget.immich-albumCarousel");
const {
data = [],
isPending,
@@ -34,7 +34,6 @@ vi.mock("@homarr/api/client", () => ({
vi.mock("@homarr/translation/client", () => ({
useCurrentIntlLocale: () => "en",
useI18n: () => (key: string) => key,
useScopedI18n: () => (key: string) => key,
}));
describe("Immich album queries", () => {
@@ -26,7 +26,7 @@ export default function ImmichServerStatsWidget({
height,
widgetRuntimeRef,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
const t = useI18n("widget.immich-serverStats");
const locale = useCurrentIntlLocale();
const input = { integrationId: integrationIds[0] ?? "" };
const albumsInput = { ...input, limit: MAX_ADVANCED_ALBUMS };
@@ -60,7 +60,7 @@ export default function ImmichServerStatsWidget({
{statVisibility.showUsers && (
<StatItem
icon={<IconUsers size="var(--mantine-font-size-xl)" />}
label={t("widget.immich-serverStats.users")}
label={t("users")}
value={stats.userCount.toLocaleString(locale)}
dense={statsLayout.dense}
/>
@@ -68,7 +68,7 @@ export default function ImmichServerStatsWidget({
{statVisibility.showPhotos && (
<StatItem
icon={<IconPhoto size="var(--mantine-font-size-xl)" />}
label={t("widget.immich-serverStats.photos")}
label={t("photos")}
value={stats.photoCount.toLocaleString(locale)}
dense={statsLayout.dense}
/>
@@ -76,7 +76,7 @@ export default function ImmichServerStatsWidget({
{statVisibility.showVideos && (
<StatItem
icon={<IconVideo size="var(--mantine-font-size-xl)" />}
label={t("widget.immich-serverStats.videos")}
label={t("videos")}
value={stats.videoCount.toLocaleString(locale)}
dense={statsLayout.dense}
/>
@@ -84,7 +84,7 @@ export default function ImmichServerStatsWidget({
{statVisibility.showStorage && (
<StatItem
icon={<IconDatabase size="var(--mantine-font-size-xl)" />}
label={t("widget.immich-serverStats.storage")}
label={t("storage")}
value={formatBytes(stats.totalLibraryUsageInBytes)}
dense={statsLayout.dense}
/>
@@ -98,7 +98,7 @@ export default function ImmichServerStatsWidget({
{statsContent}
{statsQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("widget.immich-serverStats.name")} />
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
</Box>
)}
</Stack>
@@ -112,19 +112,16 @@ export default function ImmichServerStatsWidget({
{statsContent}
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("widget.immich-serverStats.albumLimit", { count: MAX_ADVANCED_ALBUMS })}
{t("albumLimit", { count: MAX_ADVANCED_ALBUMS })}
</Text>
<WidgetQueryErrorIndicator
error={statsQuery.error ?? albumsQuery.error}
label={t("widget.immich-serverStats.name")}
/>
<WidgetQueryErrorIndicator error={statsQuery.error ?? albumsQuery.error} label={t("name")} />
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
{isInitialWidgetQueryPending(albumsQuery) ? (
<WidgetQueryLoadingState />
) : albumsQuery.error && albumsQuery.data === undefined ? (
<Center h="100%" p="md">
<WidgetQueryErrorIndicator error={albumsQuery.error} label={t("widget.immich-serverStats.name")} />
<WidgetQueryErrorIndicator error={albumsQuery.error} label={t("name")} />
</Center>
) : albums.length === 0 ? (
<WidgetEmptyState />
@@ -49,7 +49,8 @@ export default function IndexerManagerWidget({
widgetRuntimeRef,
displayMode,
}: WidgetComponentProps<"indexerManager">) {
const t = useI18n();
const t = useI18n("widget.indexerManager");
const tCommon = useI18n("common");
const indexersQuery = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
const isInitialPending = isInitialWidgetQueryPending(indexersQuery);
const indexersData = getUsableWidgetQueryData(indexersQuery) ?? [];
@@ -92,8 +93,8 @@ export default function IndexerManagerWidget({
p={isDense ? "xs" : "sm"}
>
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap" w="100%">
{testAllError && <VisuallyHidden role="alert">{t("common.error")}</VisuallyHidden>}
<Tooltip label={t("widget.indexerManager.title")} disabled={!hasSmallWidth}>
{testAllError && <VisuallyHidden role="alert">{tCommon("error")}</VisuallyHidden>}
<Tooltip label={t("title")} disabled={!hasSmallWidth}>
<IconReportSearch
className="indexer-manager-title-icon"
size={hasSmallWidth ? 16 : 20}
@@ -102,7 +103,7 @@ export default function IndexerManagerWidget({
</Tooltip>
{!hasSmallWidth && (
<Text size={isDense ? "xs" : "sm"} fw={600} truncate="end">
{t("widget.indexerManager.title")}
{t("title")}
</Text>
)}
{showHealthCounts && (
@@ -117,8 +118,8 @@ export default function IndexerManagerWidget({
)}
</Group>
)}
<WidgetQueryErrorIndicator error={indexersQuery.error} label={t("widget.indexerManager.title")} />
<Tooltip label={testAllError ? t("common.error") : t("widget.indexerManager.testAll")}>
<WidgetQueryErrorIndicator error={indexersQuery.error} label={t("title")} />
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
<ActionIcon
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
size="sm"
@@ -131,7 +132,7 @@ export default function IndexerManagerWidget({
onClick={() => {
testAllIndexers();
}}
aria-label={t("widget.indexerManager.testAll")}
aria-label={t("testAll")}
>
<IconTestPipe size="var(--mantine-font-size-sm)" />
</ActionIcon>
@@ -161,7 +162,7 @@ export default function IndexerManagerWidget({
</Text>
{error && (
<Badge size="xs" color="red" variant="light">
{t("common.error")}
{tCommon("error")}
</Badge>
)}
</Group>
@@ -197,7 +198,7 @@ export default function IndexerManagerWidget({
</Anchor>
{isAdvanced ? (
<Badge size="xs" color={presentation.color} variant="light">
{t(`widget.indexerManager.status.${displayStatus}`)}
{t(`status.${displayStatus}` as never)}
</Badge>
) : displayStatus === "healthy" ? (
<IconCircleCheck
@@ -23,7 +23,7 @@ import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
@@ -44,7 +44,7 @@ export default function MediaMissingWidget({
displayMode,
widgetRuntimeRef,
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const t = useI18n("widget.mediaMissing");
const isAdvanced = displayMode === "advanced";
const showMissing = isAdvanced || options.showMissing;
const showQueued = isAdvanced || options.showQueued;
@@ -181,12 +181,20 @@ export default function MediaMissingWidget({
</Group>
<Tabs.List grow>
{showMissing && (
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size="var(--mantine-font-size-sm)" />}>
<Tabs.Tab
value="missing"
px={isThin ? 6 : undefined}
leftSection={<IconQuestionMark size="var(--mantine-font-size-sm)" />}
>
{tabLabel(t("tab.missing"), missing.length, missingCount)}
</Tabs.Tab>
)}
{showQueued && (
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size="var(--mantine-font-size-sm)" />}>
<Tabs.Tab
value="queued"
px={isThin ? 6 : undefined}
leftSection={<IconDownload size="var(--mantine-font-size-sm)" />}
>
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Tabs.Tab>
)}
@@ -240,7 +248,7 @@ const episodeCode = (item: MissingMediaItem | QueuedMediaItem) =>
: null;
const TypeBadge = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => {
const t = useScopedI18n("widget.mediaMissing");
const t = useI18n("widget.mediaMissing");
const color = item.type === "movie" ? "yellow" : "blue";
const code = episodeCode(item);
@@ -23,7 +23,7 @@ import { getMantineColor, toValidDate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { MediaRelease } from "@homarr/integrations/types";
import { mediaTypeConfigurations } from "@homarr/integrations/types";
import type { TranslationFunction } from "@homarr/translation";
import type { ScopedTranslationFunction } from "@homarr/translation";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
@@ -43,7 +43,7 @@ export default function MediaReleasesWidget({
height,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const t = useI18n();
const t = useI18n("widget.mediaReleases");
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
const response = getUsableWidgetQueryData(releasesQuery);
@@ -73,7 +73,7 @@ export default function MediaReleasesWidget({
{(failedIntegrations.length > 0 || releasesQuery.error) && (
<Group pos="absolute" top={4} right={4} gap={0}>
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("widget.mediaReleases.name")} />
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("name")} />
</Group>
)}
</Box>
@@ -101,7 +101,7 @@ const formatReleaseDate = (value: unknown, locale: string, compact: boolean) =>
const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
const locale = useCurrentIntlLocale();
const t = useI18n();
const t = useI18n("widget.mediaReleases");
const length = formatLength(item.length, item.type, t);
const isCompact = !isAdvanced && (width < 340 || height < 180);
const isTiny = !isAdvanced && (width < 220 || height < 105);
@@ -281,12 +281,16 @@ const Info = ({ icon: Icon, label }: IconAndLabelProps) => {
);
};
const formatLength = (length: number | undefined, type: MediaRelease["type"], t: TranslationFunction) => {
const formatLength = (
length: number | undefined,
type: MediaRelease["type"],
t: ScopedTranslationFunction<"widget.mediaReleases">,
) => {
if (!length) return undefined;
if (type === "movie" || type === "tv" || type === "video" || type === "music" || type === "article") {
return {
type: "duration" as const,
label: t("widget.mediaReleases.length.duration", {
label: t("length.duration", {
length: Math.round(length / 60).toString(),
}),
};
@@ -11,7 +11,7 @@ import { toValidDate } from "@homarr/common";
import type { MediaRequestStatus } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
@@ -83,7 +83,7 @@ export default function MediaServerWidget({
}
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
const t = useScopedI18n("search.mode.media");
const t = useI18n("search.mode.media");
return (
<Tooltip label={t("action.search.label")}>
@@ -118,7 +118,7 @@ const MediaRequestCard = ({
options,
}: MediaRequestCardProps) => {
const board = useRequiredBoard();
const t = useScopedI18n("widget.mediaRequests-requestList");
const t = useI18n("widget.mediaRequests-requestList");
const requestHref = getSafeApplicationUrl(request.href);
const requestedByHref = getSafeApplicationUrl(request.requestedBy?.link);
@@ -252,7 +252,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
} = clientApi.widget.mediaRequests.answerRequest.useMutation({
onSettled: () => void utils.widget.mediaRequests.invalidate(),
});
const t = useScopedI18n("widget.mediaRequests-requestList");
const t = useI18n("widget.mediaRequests-requestList");
const handleDecision = (answer: RouterInputs["widget"]["mediaRequests"]["answerRequest"]["answer"]) => {
if (!canInteract || isPending) return;
mutateRequestAnswer({
@@ -308,7 +308,7 @@ interface StatusBadgeProps {
}
const StatusBadge = ({ status }: StatusBadgeProps) => {
const tStatus = useScopedI18n("widget.mediaRequests-requestList.status");
const tStatus = useI18n("widget.mediaRequests-requestList.status");
return (
<Badge size="xs" color={mediaRequestStatusConfiguration[status].color} variant="light">
@@ -19,7 +19,7 @@ import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { RequestStats } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
@@ -40,7 +40,8 @@ export default function MediaServerWidget({
width,
height,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const t = useI18n("widget.mediaRequests-requestStats");
const tCommon = useI18n("common");
const requestStats = getUsableWidgetQueryData(clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds }));
const board = useRequiredBoard();
@@ -129,7 +130,7 @@ export default function MediaServerWidget({
key={stat.name}
span={isTiny ? 6 : 3}
>
<Tooltip label={t(`titles.stats.${stat.name}`)}>
<Tooltip label={stat.name === "total" ? tCommon("total") : t(`titles.stats.${stat.name}`)}>
<Card p={0} radius={board.itemRadius} className={classes.card}>
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
<stat.icon className="mediaRequests-stats-stat-icon" size="var(--mantine-font-size-md)" />
@@ -215,7 +216,7 @@ export default function MediaServerWidget({
}
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
const t = useScopedI18n("search.mode.media");
const t = useI18n("search.mode.media");
return (
<Tooltip label={t("action.search.label")}>
@@ -17,7 +17,7 @@ import {
} from "./component";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
useI18n: () => (key: string) => key,
}));
const session = {
@@ -34,7 +34,7 @@ import { clientApi } from "@homarr/api/client";
import { objectEntries } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
@@ -183,8 +183,8 @@ export default function MediaServerWidget({
const currentStreamsData = getUsableWidgetQueryData(currentStreamsQuery);
const currentStreams = useMemo(() => currentStreamsData ?? [], [currentStreamsData]);
const t = useScopedI18n("widget.mediaServer");
const tGlobal = useI18n();
const t = useI18n("widget.mediaServer");
const tSearch = useI18n("search");
const isAdvanced = displayMode === "advanced";
const showLocation = isAdvanced || options.showLocation;
const showBitrate = isAdvanced || options.showBitrate;
@@ -258,8 +258,8 @@ export default function MediaServerWidget({
size="xs"
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={tGlobal("search.placeholder")}
aria-label={tGlobal("search.placeholder")}
placeholder={tSearch("placeholder")}
aria-label={tSearch("placeholder")}
leftSection={<IconSearch size="var(--mantine-font-size-xs)" aria-hidden />}
/>
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
@@ -474,7 +474,7 @@ export default function MediaServerWidget({
}
function CurrentlyPlaying({ item }: { item: StreamSession }) {
const t = useScopedI18n("widget.mediaServer");
const t = useI18n("widget.mediaServer");
const currentlyPlaying = item.currentlyPlaying;
if (!currentlyPlaying) {
return (
@@ -580,7 +580,7 @@ export function SessionDetailsPopover({
}
function SessionDetails({ item }: { item: StreamSession }) {
const t = useScopedI18n("widget.mediaServer.items");
const t = useI18n("widget.mediaServer.items");
const Icon = item.currentlyPlaying ? mediaTypeIconMap[item.currentlyPlaying.type] : null;
const metadata = item.currentlyPlaying?.metadata ? constructMetadata(item.currentlyPlaying.metadata) : null;
@@ -15,6 +15,7 @@ interface HealthCheckStatusProps {
export function HealthCheckStatus(props: HealthCheckStatusProps) {
const colorScheme = useColorScheme();
const t = useI18n("widget.mediaTranscoding.healthCheck");
const tCommon = useI18n("widget.mediaTranscoding.panel.common");
const indicatorColor = props.statistics.failedHealthCheckCount
? "red"
@@ -25,7 +26,7 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
return (
<Popover position="bottom" width={250} shadow="sm" withArrow>
<Popover.Target>
<ActionIcon className={actionTargetClasses.root} variant="subtle" size="sm" aria-label={t("title")}>
<ActionIcon className={actionTargetClasses.root} variant="subtle" size="sm" aria-label={tCommon("healthCheck")}>
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
<IconHeartbeat size="var(--mantine-font-size-md)" />
</Indicator>
@@ -39,7 +40,7 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
<Stack gap="sm" align="center">
<Group gap="xs">
<IconHeartbeat size="var(--mantine-font-size-lg)" />
<Text size="sm">{t("title")}</Text>
<Text size="sm">{tCommon("healthCheck")}</Text>
</Group>
<Divider
style={{
@@ -26,11 +26,12 @@ export function QueuePanel(props: QueuePanelProps) {
const { queue } = props;
const t = useI18n("widget.mediaTranscoding.panel.queue");
const tCommon = useI18n("widget.mediaTranscoding.panel.common");
if (queue.array.length === 0) {
return (
<Center style={{ flex: "1" }}>
<Title order={6}>{t("empty")}</Title>
<Title order={6}>{tCommon("empty")}</Title>
</Center>
);
}
@@ -42,7 +43,7 @@ export function QueuePanel(props: QueuePanelProps) {
<TableTr>
<TableTh ta="start" py={4}>
<Text size="xs" fw="bold">
{t("table.file")}
{tCommon("file")}
</Text>
</TableTh>
<TableTh ta="start" py={4}>
@@ -58,11 +59,11 @@ export function QueuePanel(props: QueuePanelProps) {
<TableTd py={2}>
<Group gap={4} wrap="nowrap">
{item.type === "transcode" ? (
<Tooltip label={t("table.transcode")}>
<Tooltip label={tCommon("transcode")}>
<IconTransform size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
</Tooltip>
) : (
<Tooltip label={t("table.healthCheck")}>
<Tooltip label={tCommon("healthCheck")}>
<IconHeartbeat size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
</Tooltip>
)}
@@ -16,6 +16,7 @@ interface StatisticsPanelProps {
export function StatisticsPanel(props: StatisticsPanelProps) {
const t = useI18n("widget.mediaTranscoding.panel.statistics");
const tCommon = useI18n("widget.mediaTranscoding.panel.common");
const allLibs = props.statistics;
@@ -23,7 +24,7 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
if (!(allLibs.totalFileCount > 0)) {
return (
<Center style={{ flex: "1" }}>
<Title order={6}>{t("empty")}</Title>
<Title order={6}>{tCommon("empty")}</Title>
</Center>
);
}
@@ -31,10 +32,14 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
return (
<ScrollArea h="100%">
<Group wrap="wrap" justify="center" p={4} w="100%" gap="xs">
<StatisticItem icon={IconTransform} label={t("transcodesCount")} value={props.statistics.totalTranscodeCount} />
<StatisticItem
icon={IconTransform}
label={tCommon("transcodes")}
value={props.statistics.totalTranscodeCount}
/>
<StatisticItem
icon={IconHeartbeat}
label={t("healthChecksCount")}
label={tCommon("healthChecks")}
value={props.statistics.totalHealthCheckCount}
/>
<StatisticItem icon={IconFileDescription} label={t("filesCount")} value={props.statistics.totalFileCount} />
@@ -45,8 +50,8 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
/>
</Group>
<Group justify="center" wrap="wrap" grow>
<StatisticRingProgress items={allLibs.transcodeStatus} label={t("transcodes")} />
<StatisticRingProgress items={allLibs.healthCheckStatus} label={t("healthChecks")} />
<StatisticRingProgress items={allLibs.transcodeStatus} label={tCommon("transcodes")} />
<StatisticRingProgress items={allLibs.healthCheckStatus} label={tCommon("healthChecks")} />
<StatisticRingProgress items={allLibs.videoCodecs} label={t("videoCodecs")} />
<StatisticRingProgress items={allLibs.videoContainers} label={t("videoContainers")} />
<StatisticRingProgress items={allLibs.videoResolutions} label={t("videoResolutions")} />
@@ -25,11 +25,12 @@ interface WorkersPanelProps {
export function WorkersPanel(props: WorkersPanelProps) {
const t = useI18n("widget.mediaTranscoding.panel.workers");
const tCommon = useI18n("widget.mediaTranscoding.panel.common");
if (props.workers.length === 0) {
return (
<Center style={{ flex: "1" }}>
<Title order={6}>{t("empty")}</Title>
<Title order={6}>{tCommon("empty")}</Title>
</Center>
);
}
@@ -41,7 +42,7 @@ export function WorkersPanel(props: WorkersPanelProps) {
<TableTr>
<TableTh ta="start" py={4}>
<Text size="xs" fw="bold">
{t("table.file")}
{tCommon("file")}
</Text>
</TableTh>
<TableTh ta="start" py={4} w={50}>
@@ -65,11 +66,11 @@ export function WorkersPanel(props: WorkersPanelProps) {
<Group gap="xs" wrap="nowrap">
<div>
{worker.jobType === "transcode" ? (
<Tooltip label={t("table.transcode")}>
<Tooltip label={tCommon("transcode")}>
<IconTransform size="var(--mantine-font-size-sm)" style={{ flexShrink: 0 }} />
</Tooltip>
) : (
<Tooltip label={t("table.healthCheck")}>
<Tooltip label={tCommon("healthCheck")}>
<IconHeartbeat size="var(--mantine-font-size-sm)" style={{ flexShrink: 0 }} />
</Tooltip>
)}
@@ -16,13 +16,14 @@ export default function MinecraftServerStatusWidget({
height,
}: WidgetComponentProps<"minecraftServerStatus">) {
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const t = useI18n();
const t = useI18n("widget.minecraftServerStatus");
const tCommon = useI18n("common");
if (isPending) {
return (
<Flex align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("common.action.loading")}
{tCommon("action.loading")}
</Text>
</Flex>
);
@@ -50,20 +51,10 @@ export default function MinecraftServerStatusWidget({
align="center"
>
<Group gap="xs" wrap="nowrap" align="center" maw="100%">
<Tooltip
label={
data.online
? t("widget.minecraftServerStatus.status.online")
: t("widget.minecraftServerStatus.status.offline")
}
>
<Tooltip label={data.online ? t("status.online") : t("status.offline")}>
<Box aria-hidden miw="md" h="md" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }} />
</Tooltip>
<VisuallyHidden>
{data.online
? t("widget.minecraftServerStatus.status.online")
: t("widget.minecraftServerStatus.status.offline")}
</VisuallyHidden>
<VisuallyHidden>{data.online ? t("status.online") : t("status.offline")}</VisuallyHidden>
<Text size={showMetadata ? "lg" : "md"} fw="bold" truncate="end">
{title}
</Text>
@@ -71,9 +62,7 @@ export default function MinecraftServerStatusWidget({
{showMetadata && (
<Group gap="xs" mt="xs" wrap="wrap" justify="center">
<Badge variant="light">{options.domain}</Badge>
{options.isBedrockServer && (
<Badge variant="outline">{t("widget.minecraftServerStatus.option.isBedrockServer.label")}</Badge>
)}
{options.isBedrockServer && <Badge variant="outline">{t("option.isBedrockServer.label")}</Badge>}
</Group>
)}
{data.online && (
@@ -20,7 +20,10 @@ interface EmbeddedAppEditFormProps {
}
export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormProps) => {
const t = useI18n();
const tCommon = useI18n("common");
const tAppEdit = useI18n("app.page.edit");
const tAppSelect = useI18n("app.action.select");
const tItemApp = useI18n("item.edit.app");
const utils = clientApi.useUtils();
const appFormRef = useRef<AppFormHandle>(null);
const { data: app, isPending: isLoadingApp, isError, refetch } = clientApi.app.byId.useQuery({ id: appId });
@@ -29,14 +32,14 @@ export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormPro
onSuccess: async () => {
await utils.app.invalidate();
showSuccessNotification({
title: t("app.page.edit.notification.success.title"),
message: t("app.page.edit.notification.success.message"),
title: tCommon("notification.update.success"),
message: tAppEdit("notification.success.message"),
});
},
onError: () => {
showErrorNotification({
title: t("app.page.edit.notification.error.title"),
message: t("app.page.edit.notification.error.message"),
title: tCommon("notification.update.error"),
message: tAppEdit("notification.error.message"),
});
},
});
@@ -66,9 +69,9 @@ export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormPro
if (isError || !app) {
return (
<Stack align="center" gap="md" py="xl">
<Text c="dimmed">{t("app.action.select.notFound")}</Text>
<Text c="dimmed">{tAppSelect("notFound")}</Text>
<Button variant="light" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
{tCommon("action.tryAgain")}
</Button>
</Stack>
);
@@ -77,7 +80,7 @@ export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormPro
return (
<Stack>
<Alert icon={<IconInfoCircle size="var(--mantine-font-size-md)" />} color="blue" variant="light">
{t("item.edit.app.propagationNotice")}
{tItemApp("propagationNotice")}
</Alert>
<AppForm
formRef={appFormRef}
@@ -14,7 +14,7 @@ interface InnerProps {
}
export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const t = useI18n("item.edit");
const theme = useMantineTheme();
const form = useForm({
initialValues: innerProps.advancedOptions,
@@ -33,17 +33,14 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("item.edit.field.title.label")}
label={t("field.title.label")}
data-autofocus
{...form.getInputProps("title")}
rightSection={<Input.ClearButton onClick={() => form.setFieldValue("title", "")} />}
/>
<TextMultiSelect
label={t("item.edit.field.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
/>
<TextMultiSelect label={t("field.customCssClasses.label")} {...form.getInputProps("customCssClasses")} />
<ColorInput
label={t("item.edit.field.borderColor.label")}
label={t("field.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
@@ -68,6 +68,8 @@ export const getSelectedWidgetIntegrations = (
export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n();
const tItem = useI18n("item.edit");
const tCommon = useI18n("common");
const board = useOptionalBoard();
const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
@@ -233,7 +235,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<Stack>
{canConfigureWidget && innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
@@ -280,7 +282,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
})
}
>
{t("item.edit.advancedOptions.label")}
{tItem("advancedOptions.label")}
</Button>
) : (
<ModalFormFooter
@@ -296,7 +298,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
})
}
>
{t("item.edit.advancedOptions.label")}
{tItem("advancedOptions.label")}
</Button>
}
/>
@@ -311,9 +313,9 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<Stack>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{t("item.edit.tab.integration")}</Tabs.Tab>}
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
@@ -329,7 +331,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{t("item.edit.integration.description")}
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
@@ -352,13 +354,13 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{t("item.edit.integration.action")}
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{t("item.edit.integration.fullAccessRequired")}
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
@@ -374,7 +376,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{t("common.action.previous")}
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
@@ -406,10 +408,10 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{t("common.action.saveChanges")}
{tCommon("action.saveChanges")}
</Button>
</Group>
</Box>
@@ -4,7 +4,7 @@ import { useMemo } from "react";
import { Badge, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
@@ -32,8 +32,9 @@ export default function NetworkControllerNetworkStatusWidget({
),
[results],
);
const t = useScopedI18n("widget.networkControllerStatus");
const tCommon = useScopedI18n("common");
const t = useI18n("widget.networkControllerStatus");
const tWidgetCommon = useI18n("widget.common");
const tCommon = useI18n("common");
const locale = useCurrentIntlLocale();
const countWifiGuests = summaries.reduce((sum, { summary }) => sum + summary.wifi.guests, 0);
@@ -55,7 +56,7 @@ export default function NetworkControllerNetworkStatusWidget({
{queryIndicators}
<Center p="sm" style={{ flex: 1 }}>
<Text c="dimmed" size="sm" ta="center">
{summaryQuery.isPending ? tCommon("action.loading") : t("error.integrationsDisconnected")}
{summaryQuery.isPending ? tCommon("action.loading") : tWidgetCommon("integrationDisconnected")}
</Text>
</Center>
</Stack>
@@ -77,7 +78,7 @@ export default function NetworkControllerNetworkStatusWidget({
</Text>
{updatedAt && (
<Text size="xs" c="dimmed">
{t("advanced.updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })}
{tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })}
</Text>
)}
</Stack>
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff. Show more