Merge origin/release/v2 into feat/fix-v2-stack-review-findings

This commit is contained in:
ajnart committed 2026-08-21 19:08:02 +02:00
commit e1775e524b
583 files changed
+16433 -9067

No files matched your search

+4 -1
View File
@@ -13,7 +13,9 @@
},
"exports": {
".": "./index.ts",
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./inputs": "./src/_inputs/index.ts",
"./inputs/form": "./src/_inputs/form.ts",
"./inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
@@ -23,6 +25,7 @@
"./errors": "./src/errors/component.tsx",
"./manifest": "./src/manifest.ts",
"./options": "./src/options.ts",
"./option-translation": "./src/option-translation.ts",
"./errors/classes": "./src/errors/index.ts",
"./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts",
+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}
@@ -17,8 +17,15 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const renderHereLink = (children: React.ReactNode) => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{children}
</Anchor>
);
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();
@@ -38,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) => ({
@@ -49,13 +56,9 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
}
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
{t.rich("widget.common.app.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
<Text component="span" size="xs">
{tApp.rich("description", {
here: () => renderHereLink(tCommon("here")),
})}
</Text>
}
@@ -77,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}
+6 -4
View File
@@ -23,10 +23,11 @@ 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 });
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeAppHref(app?.href);
useRegisterSpotlightContextResults(
@@ -50,6 +51,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, href, options.openInNewTab],
);
if (!options.appId) return <WidgetEmptyState />;
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />;
@@ -120,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";
@@ -123,19 +136,12 @@ const MIN_CELL_HEIGHT = 80;
const ADVANCED_MIN_CELL_WIDTH = 320;
const ADVANCED_MIN_CELL_HEIGHT = 420;
const getColCount = (width: number, _height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
const getColCount = (width: number, height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth)));
let best = 1;
for (let c = 1; c <= maxCols; c++) {
const emptyCells = (c - (itemCount % c)) % c;
const bestEmpty = (best - (itemCount % best)) % best;
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
best = c;
}
}
return best;
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
const idealCols = Math.round(Math.sqrt(itemCount * aspectRatio));
return Math.min(maxCols, Math.max(1, idealCols));
};
interface MetricRowProps {
@@ -175,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;
@@ -184,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}
@@ -235,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}
@@ -306,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}
/>
@@ -352,6 +370,7 @@ const SystemCard = ({
system,
options,
t,
tCommon,
size,
maxMetrics,
itemRadius,
@@ -382,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
@@ -402,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">
@@ -445,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);
@@ -469,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%">
@@ -498,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
@@ -523,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>
) : (
<>
+25 -14
View File
@@ -20,27 +20,29 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
const minAxisSize = Math.min(rootWidth, rootHeight);
const shouldScaleDown = minAxisSize < 350;
const isSmall = rootHeight < 256;
const shouldShowIndicators = rootHeight >= 256;
const indicatorSize = shouldScaleDown ? 3 : 4;
const cell = (
<Container
h="100%"
w="100%"
p={0}
pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10}
m={0}
pos="relative"
style={{
alignContent: "center",
alignItems: "center",
borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer",
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
</Text>
<NotificationIndicator events={events} isSmall={isSmall} />
<NotificationIndicator events={events} size={indicatorSize} visible={shouldShowIndicators} />
</Container>
);
@@ -67,25 +69,34 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
interface NotificationIndicatorProps {
events: CalendarEvent[];
isSmall: boolean;
size: number;
visible: boolean;
}
const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String);
const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(
(color): color is string => Boolean(color),
);
if (!visible) return null;
return (
<Flex
w="75%"
mt={3}
w="fit-content"
maw="75%"
h={size}
align={"center"}
pos={"absolute"}
gap={3}
bottom={isSmall ? 4 : 10}
left={"12.5%"}
gap={2}
p={0}
direction={"row"}
justify={"center"}
aria-hidden
>
{notificationEvents.map((notificationEvent) => {
return <Box key={notificationEvent} bg={notificationEvent} h={4} w={4} p={0} style={{ borderRadius: 999 }} />;
return (
<Box key={notificationEvent} bg={notificationEvent} h={size} w={size} p={0} style={{ borderRadius: 999 }} />
);
})}
</Flex>
);
+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 (
-2
View File
@@ -15,7 +15,6 @@ import {
IconBuildingBank,
IconBusStop,
IconCalendar,
IconCalendarWeek,
IconChartAreaLine,
IconChartBar,
IconClock,
@@ -60,7 +59,6 @@ import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock,
weather: IconCloud,
today: IconCalendarWeek,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
+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>
);
@@ -12,11 +12,18 @@
background-color: transparent;
}
.homarr-data-table .mantine-datatable-table {
width: 100% !important;
max-width: 100%;
table-layout: fixed !important;
}
.homarr-data-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.homarr-data-table th {
padding: 0;
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
@@ -28,6 +35,13 @@
text-overflow: ellipsis;
}
/* Lets a cell's content (e.g. an oversized action icon) visually overflow its own box without
growing it - used to make a per-row icon look bigger without inflating row height, since the
icon is pulled out of flow via absolute positioning rather than actually being a bigger cell. */
.homarr-data-table td.docker-actions-cell {
overflow: visible;
}
.homarr-data-table td .mantine-Text-root,
.homarr-data-table td .mantine-Group-root {
white-space: nowrap;
@@ -40,6 +54,25 @@
transition: opacity 150ms ease-out;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-group {
gap: 0;
}
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
margin: 0;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-unsorted-icon {
display: none;
}
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon svg,
.homarr-data-table .mantine-datatable-header-cell-sortable-text svg,
.homarr-data-table .mantine-datatable-header-cell-sortable-icon svg {
width: var(--mantine-font-size-md);
height: var(--mantine-font-size-md);
}
.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible,
@@ -2,10 +2,24 @@
import "./homarr-data-table.css";
import type { DataTableProps } from "mantine-datatable";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
const fallbackColumnWidth = 60;
const getColumnWidth = (width: string | number | undefined): number => {
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
if (typeof width === "string") {
const parsedWidth = Number.parseFloat(width);
if (Number.isFinite(parsedWidth) && parsedWidth > 0) return parsedWidth;
}
return fallbackColumnWidth;
};
type HomarrDataTableProps<T> = DataTableProps<T> & {
columns: DataTableColumn<T>[];
groups?: never;
isEditMode: boolean;
cellPadding: string;
rowCursor?: "auto" | "default";
@@ -16,11 +30,78 @@ export function HomarrDataTable<T>({
cellPadding,
rowCursor = "auto",
className,
columns,
defaultColumnProps,
onMouseDownCapture,
scrollAreaProps,
style,
...props
}: HomarrDataTableProps<T>) {
const [resizePreviewWidths, setResizePreviewWidths] = useState<Record<string, number>>();
const removeResizeListeners = useRef<() => void>(() => undefined);
useEffect(() => () => removeResizeListeners.current(), []);
const handleMouseDownCapture = useCallback(
(event: React.MouseEvent<HTMLTableElement>) => {
onMouseDownCapture?.(event);
const target = event.target;
if (!(target instanceof Element)) return;
const handle = target.closest(".mantine-datatable-header-resizable-handle");
const currentCell = handle?.closest<HTMLTableCellElement>("th[data-accessor]");
const headerRow = currentCell?.parentElement;
if (!currentCell || !headerRow) return;
const cells = Array.from(headerRow.querySelectorAll<HTMLTableCellElement>("th[data-accessor]"));
const currentIndex = cells.indexOf(currentCell);
const nextCell = cells[currentIndex + 1];
const currentAccessor = currentCell.dataset.accessor;
const nextAccessor = nextCell?.dataset.accessor;
if (!nextCell || !currentAccessor || !nextAccessor) return;
const widths = Object.fromEntries(
cells.flatMap((cell) => {
const accessor = cell.dataset.accessor;
if (!accessor) return [];
return [[accessor, cell.getBoundingClientRect().width]];
}),
);
const currentWidth = widths[currentAccessor];
const nextWidth = widths[nextAccessor];
if (currentWidth === undefined || nextWidth === undefined) return;
const visualScale = currentCell.getBoundingClientRect().width / currentCell.offsetWidth;
const minimumWidth = fallbackColumnWidth * visualScale;
const startX = event.clientX;
removeResizeListeners.current();
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = Math.min(
nextWidth - minimumWidth,
Math.max(minimumWidth - currentWidth, moveEvent.clientX - startX),
);
setResizePreviewWidths({
...widths,
[currentAccessor]: currentWidth + delta,
[nextAccessor]: nextWidth - delta,
});
};
const handleResizeEnd = () => {
removeResizeListeners.current();
requestAnimationFrame(() => requestAnimationFrame(() => setResizePreviewWidths(undefined)));
};
removeResizeListeners.current = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("blur", handleResizeEnd);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("blur", handleResizeEnd);
},
[onMouseDownCapture],
);
const mergedClassName = [
"homarr-data-table",
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
@@ -28,6 +109,26 @@ export function HomarrDataTable<T>({
]
.filter(Boolean)
.join(" ");
const fittedColumns = useMemo(() => {
const visibleColumns = columns.filter(({ hidden }) => !hidden);
const getRenderedWidth = (column: DataTableColumn<T>) =>
resizePreviewWidths?.[String(column.accessor)] ?? getColumnWidth(column.width);
const totalWidth = visibleColumns.reduce((total, column) => total + getRenderedWidth(column), 0);
if (totalWidth <= 0) return columns;
return columns.map((column) => {
if (column.hidden) return column;
const width = `${(getRenderedWidth(column) / totalWidth) * 100}%`;
const fittedWidth = { width, minWidth: width, maxWidth: width };
const cellsStyle = column.cellsStyle ?? defaultColumnProps?.cellsStyle;
return {
...column,
titleStyle: [column.titleStyle, fittedWidth],
cellsStyle: (record: T, index: number) => [cellsStyle?.(record, index), fittedWidth, { padding: cellPadding }],
};
});
}, [cellPadding, columns, defaultColumnProps?.cellsStyle, resizePreviewWidths]);
return (
<DataTable<T>
@@ -42,6 +143,8 @@ export function HomarrDataTable<T>({
textSelectionDisabled
height="100%"
{...props}
columns={fittedColumns}
onMouseDownCapture={handleMouseDownCapture}
className={mergedClassName}
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
defaultColumnProps={{
@@ -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" />
@@ -107,7 +107,7 @@ export const parseColumnWidths = (value: string, columnAccessors: readonly strin
}
};
export const usePersistedTableLayout = <T>({
export const usePersistedTableLayout = <T,>({
columns,
columnAccessors,
columnOrder,
@@ -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";
@@ -14,9 +14,13 @@ export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
const EMPTY_RECORD: Record<string, unknown> = {};
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 {
@@ -37,7 +37,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"),
@@ -52,7 +53,7 @@ const useRuntimeMessages = (): CustomWidgetRuntimeMessages => {
actionCompleted: t("actionCompleted"),
confirmDelete: t("confirmDelete"),
toggle: t("toggle"),
refresh: t("refresh"),
refresh: actionT("refresh"),
};
};
+1 -1
View File
@@ -39,7 +39,6 @@ describe("supportsAdvancedFocus", () => {
beszelSystemStatsDefinition,
beszelSystemTableDefinition,
customApiDefinition,
dockerDefinition,
indexerManagerDefinition,
notebookDefinition,
])("keeps non-enhanced widgets compact", (widgetDefinition) => {
@@ -49,6 +48,7 @@ describe("supportsAdvancedFocus", () => {
test("requires an explicit opt-in", () => {
expect(supportsAdvancedFocus({})).toBe(false);
expect(supportsAdvancedFocus(clockDefinition)).toBe(true);
expect(supportsAdvancedFocus(dockerDefinition)).toBe(true);
expect(supportsAdvancedFocus(downloadsDefinition)).toBe(true);
});
});
@@ -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",
}),
+67 -30
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";
@@ -69,6 +70,18 @@ interface ContainerActionHandlers {
const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const containerMenuWidth = 240;
// The *button* sizes stay small and fixed - row height is set by the tallest cell, and the
// actions button is repeated once per row, so any growth here multiplies across every row and
// tanks how many containers fit on screen. The *icon* sizes are rendered larger than their
// button via absolute positioning (see ContainerMenuButton/footer refresh below) - an
// absolutely-positioned element is removed from normal flow, so it doesn't count toward its
// ancestor's height even though it visually overflows the button. This lets the icon look
// properly sized without the button (and therefore the row) growing to match.
const rowActionButtonSize = 22;
const rowActionIconVisualSize = 32;
const footerRefreshButtonSize = 24;
const footerRefreshIconVisualSize = 30;
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
@@ -80,17 +93,23 @@ 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 size="xs" radius="sm" variant="light" color={containerStateColorMap[state]}>
<Badge
size="xs"
radius="sm"
variant="light"
color={containerStateColorMap[state]}
styles={{ label: { lineHeight: 1.2 } }}
>
{t(state)}
</Badge>
);
};
const ContainerStateDot = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
const t = useI18n("docker.field.state.option");
const label = t(state);
return (
@@ -111,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,
@@ -126,16 +146,15 @@ const createColumns = (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />}
<Avatar
variant="outline"
radius="sm"
size={20}
radius="xl"
size={24}
styles={{ image: { objectFit: "contain" } }}
src={container.iconUrl}
style={{ flexShrink: 0 }}
>
{container.name.at(0)?.toUpperCase()}
</Avatar>
<Text size="sm" truncate>
<Text size="sm" lh={1.2} truncate>
{container.name}
</Text>
</Group>
@@ -162,7 +181,7 @@ const createColumns = (
},
{
accessor: "cpuUsage",
title: t("field.stats.cpu.label"),
title: invariantTechnicalLabels.cpu,
width: 80,
sortable: sortingEnabled,
render: (container) => {
@@ -191,8 +210,12 @@ const createColumns = (
{
accessor: "actions",
title: "",
width: 44,
width: rowActionButtonSize + 18,
textAlign: "right",
// The default `.homarr-data-table td { overflow: hidden }` (needed for name/host ellipsis
// truncation) would clip the actions icon's intentional visual overflow - opt this one
// cell out of it.
cellsClassName: "docker-actions-cell",
render: (container) => <ContainerMenuButton container={container} handlers={handlers} />,
},
];
@@ -211,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({
@@ -243,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();
@@ -363,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,
@@ -407,7 +431,7 @@ export default function DockerWidget({
<Box style={{ flex: 1, minHeight: 0 }}>
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={width < 400 ? "2px 8px" : "4px 8px"}
cellPadding="2px 8px"
rowCursor="default"
fetching={isFetching && containers.length === 0}
fz={width < 400 ? "xs" : "sm"}
@@ -431,38 +455,43 @@ export default function DockerWidget({
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
py={2}
px={8}
wrap="nowrap"
>
<Group gap={4} wrap="nowrap">
<IconBrandDocker size="var(--mantine-font-size-xl)" style={{ flexShrink: 0 }} />
<Text size="sm" truncate>
<IconBrandDocker size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
<Text size="xs" truncate>
{t("table.footer", { count: containers.length.toString() })}
</Text>
</Group>
<Group gap="sm" wrap="nowrap" justify="flex-end" style={{ minWidth: 0 }}>
{footerVisibility.cpu && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
)}
{footerVisibility.memory && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
)}
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
className={actionTargetClasses.root}
size="sm"
size={footerRefreshButtonSize}
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching || refreshInventory.isPending}
onClick={() => refreshInventory.mutate()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
style={{ position: "relative", overflow: "visible" }}
>
<IconRefresh size="var(--mantine-font-size-md)" />
<IconRefresh
size={footerRefreshIconVisualSize}
style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }}
/>
</ActionIcon>
</Tooltip>
</Group>
@@ -481,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);
@@ -492,11 +521,18 @@ function ContainerMenuButton({
className={actionTargetClasses.root}
variant="subtle"
color="gray"
size="sm"
size={rowActionButtonSize}
aria-label={t("title")}
onClick={(event) => event.stopPropagation()}
style={{ position: "relative", overflow: "visible" }}
>
<IconDots size="var(--mantine-font-size-md)" />
{/* Rendered larger than the button and pulled out of flow via absolute positioning,
so it looks properly sized without the button - and therefore the row - growing
to match (row height is set by the tallest cell, and this button repeats per row). */}
<IconDots
size={rowActionIconVisualSize}
style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }}
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}>
@@ -515,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;
@@ -562,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
+8 -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,16 +17,16 @@ 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,
supportsAdvancedFocus: true,
queryKey: [["docker", "getContainers"]],
refetchInterval: 30,
createOptions() {
@@ -48,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,
}),
+26 -20
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 ?? []} />
@@ -870,7 +870,7 @@ function buildHoverTooltip(record: ExtendedDownloadClientItem, t: DownloadsT): R
}
function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) {
const t = useScopedI18n("widget.downloads");
const t = useI18n("widget.downloads");
const locale = useCurrentIntlLocale();
const progressPercent = Math.floor(item.progress * 100);
const categoryDisplay = formatCategoryDisplay(item.category);
@@ -891,13 +891,13 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
thickness={5}
roundCaps
sections={[{ value: progressPercent, color: progressColor(item.state, item.progress) }]}
label={<Text ta="center" fw={700} style={{ fontSize: 10 }}>{`${progressPercent}%`}</Text>}
label={<Text size="xs" ta="center" fw={700}>{`${progressPercent}%`}</Text>}
/>
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="nowrap">
<Avatar size={16} radius={0} src={getIconUrl(item.integration.kind)} />
<Text size="xs" fw={600} truncate>
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar size="var(--mantine-font-size-md)" radius={0} src={getIconUrl(item.integration.kind)} />
<Text size="sm" fw={600} truncate style={{ minWidth: 0 }}>
{item.name}
</Text>
<Badge size="xs" variant="light" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
@@ -905,7 +905,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
</Badge>
</Group>
<SimpleGrid cols={3} spacing={4} verticalSpacing={2}>
<SimpleGrid cols={3} spacing="xs" verticalSpacing={4} style={{ minWidth: 0 }}>
<DetailPair
label={t("items.size.detailsTitle")}
value={`${formatBytes(item.received)} / ${formatBytes(item.size)}`}
@@ -957,17 +957,17 @@ function DetailPair({
color?: string;
}) {
return (
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", fontSize: 10 }}>
<Stack gap={0} style={{ minWidth: 0, overflow: "hidden" }}>
<Text size="xs" c="dimmed" truncate>
{label}:
</Text>
<Group gap={2} wrap="nowrap">
<Group gap={2} wrap="nowrap" style={{ minWidth: 0 }}>
{icon}
<Text size="xs" fw={500} c={color} truncate style={{ fontSize: 10 }}>
<Text size="sm" fw={500} c={color} truncate style={{ minWidth: 0 }}>
{value}
</Text>
</Group>
</Group>
</Stack>
);
}
@@ -986,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;
@@ -1206,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;
@@ -1364,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>
)}
@@ -1401,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;
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import {
DOWNLOAD_COLUMN_ACCESSORS,
ADVANCED_DOWNLOAD_COLUMN_ACCESSORS,
filterDownloadItemsByStatus,
getAvailableDownloadStates,
getDownloadColumnAccessors,
@@ -25,8 +25,8 @@ describe("downloads filters", () => {
});
describe("downloads advanced columns", () => {
test("uses every column in advanced mode without changing the compact selection", () => {
expect(getDownloadColumnAccessors(["name"], true)).toEqual(DOWNLOAD_COLUMN_ACCESSORS);
test("uses summary columns in advanced mode without changing the compact selection", () => {
expect(getDownloadColumnAccessors(["name"], true)).toEqual(ADVANCED_DOWNLOAD_COLUMN_ACCESSORS);
expect(getDownloadColumnAccessors(["name"], false)).toEqual(["name"]);
});
});
+12 -1
View File
@@ -20,6 +20,17 @@ export const DOWNLOAD_COLUMN_ACCESSORS = [
"type",
] as const satisfies readonly (keyof ExtendedDownloadClientItem)[];
export const ADVANCED_DOWNLOAD_COLUMN_ACCESSORS = [
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"integration",
] as const satisfies readonly (typeof DOWNLOAD_COLUMN_ACCESSORS)[number][];
type DownloadColumnAccessor = (typeof DOWNLOAD_COLUMN_ACCESSORS)[number];
export function getDownloadsStatsDisplay(showCompactStats: boolean, isAdvanced: boolean) {
@@ -33,7 +44,7 @@ export function getDownloadColumnAccessors(
configuredColumns: readonly DownloadColumnAccessor[],
isAdvanced: boolean,
): readonly DownloadColumnAccessor[] {
return isAdvanced ? DOWNLOAD_COLUMN_ACCESSORS : configuredColumns;
return isAdvanced ? ADVANCED_DOWNLOAD_COLUMN_ACCESSORS : configuredColumns;
}
export function filterDownloadItemsByStatus<T extends { state: DownloadState }>(
@@ -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 />
-2
View File
@@ -63,7 +63,6 @@ import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
import * as today from "./today";
import * as airQuality from "./air-quality";
import * as countdown from "./countdown";
import * as timer from "./timer";
@@ -98,7 +97,6 @@ export { AssistantWidgetRendererProvider } from "./assistant/context";
export const widgetImports = {
clock,
weather,
today,
airQuality,
countdown,
timer,
@@ -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
-2
View File
@@ -24,7 +24,6 @@ export type WidgetResources = {
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
clock: () => import("./clock") as Promise<WidgetModule>,
weather: () => import("./weather") as Promise<WidgetModule>,
today: () => import("./today") as Promise<WidgetModule>,
airQuality: () => import("./air-quality") as Promise<WidgetModule>,
countdown: () => import("./countdown") as Promise<WidgetModule>,
timer: () => import("./timer") as Promise<WidgetModule>,
@@ -89,7 +88,6 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
weather: () => import("./weather/component") as Promise<WidgetComponentModule>,
today: () => import("./today/component") as Promise<WidgetComponentModule>,
airQuality: () => import("./air-quality/component") as Promise<WidgetComponentModule>,
countdown: () => import("./countdown/component") as Promise<WidgetComponentModule>,
timer: () => import("./timer/component") as Promise<WidgetComponentModule>,
@@ -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;
@@ -260,7 +260,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")}>
Loaded 100 of 583 files, more files were not shown because too many files have changed in this diff. Show more