diff --git a/.env.example b/.env.example index fbc491bae..ae3d1e38b 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,7 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE' # If it is used, please use the full path to the directory where the certificates are stored. # LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES' -TURBO_TELEMETRY_DISABLED=1 \ No newline at end of file +TURBO_TELEMETRY_DISABLED=1 + +# Enable kubernetes tool +# ENABLE_KUBERNETES=true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index fde418afc..14be5a9d8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,6 +31,7 @@ body: label: Version description: What version of Homarr are you running? options: + - 1.10.0 - 1.9.0 - 1.8.0 - 1.7.0 diff --git a/.gitignore b/.gitignore index 76c8f08fc..0aa3ed4c7 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ e2e/shared/tmp #personal backgrounds apps/nextjs/public/images/background.png + +# next-intl +en.d.json.ts \ No newline at end of file diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index a8a022a60..d88dac644 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -10,7 +10,12 @@ import MillionLint from "@million/lint"; import createNextIntlPlugin from "next-intl/plugin"; // Package path does not work... so we need to use relative path -const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts"); +const withNextIntl = createNextIntlPlugin({ + experimental: { + createMessagesDeclaration: "../../packages/translation/src/lang/en.json", + }, + requestConfig: "../../packages/translation/src/request.ts", +}); interface WebpackConfig { module: { diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index cb7669807..dff2b6f74 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -48,17 +48,17 @@ "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0", - "@mantine/colors-generator": "^7.17.1", - "@mantine/core": "^7.17.1", - "@mantine/dropzone": "^7.17.1", - "@mantine/hooks": "^7.17.1", - "@mantine/modals": "^7.17.1", - "@mantine/tiptap": "^7.17.1", + "@mantine/colors-generator": "^7.17.2", + "@mantine/core": "^7.17.2", + "@mantine/dropzone": "^7.17.2", + "@mantine/hooks": "^7.17.2", + "@mantine/modals": "^7.17.2", + "@mantine/tiptap": "^7.17.2", "@million/lint": "1.0.14", "@tabler/icons-react": "^3.31.0", - "@tanstack/react-query": "^5.67.2", - "@tanstack/react-query-devtools": "^5.67.2", - "@tanstack/react-query-next-experimental": "^5.67.2", + "@tanstack/react-query": "^5.68.0", + "@tanstack/react-query-devtools": "^5.68.0", + "@tanstack/react-query-next-experimental": "^5.68.0", "@trpc/client": "next", "@trpc/next": "next", "@trpc/react-query": "next", @@ -72,18 +72,18 @@ "dotenv": "^16.4.7", "flag-icons": "^7.3.2", "glob": "^11.0.1", - "jotai": "^2.12.1", + "jotai": "^2.12.2", "mantine-react-table": "2.0.0-beta.9", "next": "15.1.7", "postcss-preset-mantine": "^1.17.0", - "prismjs": "^1.29.0", + "prismjs": "^1.30.0", "react": "19.0.0", "react-dom": "19.0.0", "react-error-boundary": "^5.0.0", "react-simple-code-editor": "^0.14.1", "sass": "^1.85.1", "superjson": "2.2.2", - "swagger-ui-react": "^5.20.0", + "swagger-ui-react": "^5.20.1", "use-deep-compare-effect": "^1.8.1", "zod": "^3.24.2" }, @@ -92,13 +92,13 @@ "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", "@types/chroma-js": "3.1.1", - "@types/node": "^22.13.9", + "@types/node": "^22.13.10", "@types/prismjs": "^1.26.5", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", "@types/swagger-ui-react": "^5.18.0", "concurrently": "^9.1.2", - "eslint": "^9.21.0", + "eslint": "^9.22.0", "node-loader": "^2.1.0", "prettier": "^3.5.3", "typescript": "^5.8.2" diff --git a/apps/nextjs/public/images/apps/nextcloud.svg b/apps/nextjs/public/images/apps/nextcloud.svg new file mode 100644 index 000000000..841b9d987 --- /dev/null +++ b/apps/nextjs/public/images/apps/nextcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/nextjs/public/images/kubernetes/configmaps.svg b/apps/nextjs/public/images/kubernetes/configmaps.svg new file mode 100644 index 000000000..85ac9b476 --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/configmaps.svg @@ -0,0 +1,141 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/ingresses.svg b/apps/nextjs/public/images/kubernetes/ingresses.svg new file mode 100644 index 000000000..0dde27514 --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/ingresses.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/namespaces.svg b/apps/nextjs/public/images/kubernetes/namespaces.svg new file mode 100644 index 000000000..231c21c9e --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/namespaces.svg @@ -0,0 +1,85 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/nodes.svg b/apps/nextjs/public/images/kubernetes/nodes.svg new file mode 100644 index 000000000..c0f2b8e13 --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/nodes.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/pods.svg b/apps/nextjs/public/images/kubernetes/pods.svg new file mode 100644 index 000000000..f88d2dbca --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/pods.svg @@ -0,0 +1,103 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/secrets.svg b/apps/nextjs/public/images/kubernetes/secrets.svg new file mode 100644 index 000000000..195727e1e --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/secrets.svg @@ -0,0 +1,128 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/services.svg b/apps/nextjs/public/images/kubernetes/services.svg new file mode 100644 index 000000000..779b61405 --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/services.svg @@ -0,0 +1,117 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/volumes.svg b/apps/nextjs/public/images/kubernetes/volumes.svg new file mode 100644 index 000000000..dba1bd2d7 --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/volumes.svg @@ -0,0 +1,97 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx b/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx index 7de11ff0f..cc6535703 100644 --- a/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx @@ -63,7 +63,8 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) { - {t("description", { username: invite.creator.name })} + {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} + {t("description", { username: invite.creator.name! })} diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index b988647bc..5fbd3c3f7 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -20,7 +20,6 @@ import { SettingsProvider } from "@homarr/settings"; import { SpotlightProvider } from "@homarr/spotlight"; import type { SupportedLanguage } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; -import { getI18nMessages } from "@homarr/translation/server"; import { Analytics } from "~/components/layout/analytics"; import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; @@ -81,7 +80,6 @@ export default async function Layout(props: { const serverSettings = await getServerSettingsAsync(db); const colorScheme = await getCurrentColorSchemeAsync(); const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr"; - const i18nMessages = await getI18nMessages(); const StackedProvider = composeWrappers([ (innerProps) => { @@ -105,7 +103,7 @@ export default async function Layout(props: { (innerProps) => , (innerProps) => , (innerProps) => , - (innerProps) => , + (innerProps) => , (innerProps) => , (innerProps) => , (innerProps) => , diff --git a/apps/nextjs/src/app/[locale]/manage/about/page.tsx b/apps/nextjs/src/app/[locale]/manage/about/page.tsx index 024267be2..ece275534 100644 --- a/apps/nextjs/src/app/[locale]/manage/about/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/about/page.tsx @@ -80,7 +80,7 @@ export default async function AboutPage() { {t("accordion.contributors.title")} {t("accordion.contributors.subtitle", { - count: githubContributors.length, + count: String(githubContributors.length), })} @@ -104,7 +104,7 @@ export default async function AboutPage() { {t("accordion.translators.title")} {t("accordion.translators.subtitle", { - count: crowdinContributors.length, + count: String(crowdinContributors.length), })} @@ -128,7 +128,7 @@ export default async function AboutPage() { {t("accordion.libraries.title")} {t("accordion.libraries.subtitle", { - count: Object.keys(attributes.dependencies).length, + count: String(Object.keys(attributes.dependencies).length), })} diff --git a/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx b/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx index bdc520579..2fb1dce56 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx @@ -23,7 +23,9 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => { const onClick = useCallback(() => { openConfirmModal({ title: t("title"), - children: t("message", app), + children: t("message", { + name: app.name, + }), onConfirm: () => { mutate( { id: app.id }, diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index cb286217e..ed93ba822 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -1,16 +1,27 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Alert, Button, Checkbox, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core"; +import { + Alert, + Button, + Checkbox, + Collapse, + Fieldset, + Group, + SegmentedControl, + Stack, + Text, + TextInput, +} from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; -import type { z } from "zod"; +import { z } from "zod"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; -import { getAllSecretKindOptions, getIntegrationName, integrationDefs } from "@homarr/definitions"; +import { getAllSecretKindOptions, getIconUrl, getIntegrationName, integrationDefs } from "@homarr/definitions"; import type { UseFormReturnType } from "@homarr/form"; import { useZodForm } from "@homarr/form"; import { convertIntegrationTestConnectionError } from "@homarr/integrations/client"; @@ -26,11 +37,19 @@ interface NewIntegrationFormProps { }; } +const formSchema = validation.integration.create.omit({ kind: true }).and( + z.object({ + createApp: z.boolean(), + appHref: validation.app.manage.shape.href, + }), +); + export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => { const t = useI18n(); const secretKinds = getAllSecretKindOptions(searchParams.kind); const router = useRouter(); - const form = useZodForm(validation.integration.create.omit({ kind: true }), { + const [opened, setOpened] = useState(false); + const form = useZodForm(formSchema, { initialValues: { name: searchParams.name ?? getIntegrationName(searchParams.kind), url: searchParams.url ?? "", @@ -39,23 +58,60 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => value: "", })), attemptSearchEngineCreation: true, + createApp: false, + appHref: "", + }, + onValuesChange(values, previous) { + if (values.createApp !== previous.createApp) { + setOpened(values.createApp); + } }, }); - const { mutateAsync, isPending } = clientApi.integration.create.useMutation(); + + const { mutateAsync: createIntegrationAsync, isPending: isPendingIntegration } = + clientApi.integration.create.useMutation(); + const { mutateAsync: createAppAsync, isPending: isPendingApp } = clientApi.app.create.useMutation(); + const isPending = isPendingIntegration || isPendingApp; const handleSubmitAsync = async (values: FormType) => { - await mutateAsync( + await createIntegrationAsync( { kind: searchParams.kind, ...values, }, { - onSuccess: () => { + async onSuccess() { showSuccessNotification({ title: t("integration.page.create.notification.success.title"), message: t("integration.page.create.notification.success.message"), }); - void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); + + if (!values.createApp) { + await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); + return; + } + + const hasCustomHref = values.appHref !== null && values.appHref.trim().length >= 1; + await createAppAsync( + { + name: values.name, + href: hasCustomHref ? values.appHref : values.url, + iconUrl: getIconUrl(searchParams.kind), + description: null, + pingUrl: values.url, + }, + { + async onSettled() { + await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); + }, + onError() { + showErrorNotification({ + title: t("app.page.create.notification.error.title"), + message: t("app.page.create.notification.error.message"), + }); + }, + }, + ); }, onError: (error) => { const testConnectionError = convertIntegrationTestConnectionError(error.data?.error); @@ -117,6 +173,16 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => /> )} + + + + + + )} - + ))} - + - + ); } @@ -283,6 +298,7 @@ interface ControlsCardProps { open: () => void; t: TranslationFunction; hasIconColor: boolean; + rootWidth: number; } const ControlsCard: React.FC = ({ @@ -293,6 +309,7 @@ const ControlsCard: React.FC = ({ open, t, hasIconColor, + rootWidth, }) => { const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 }); const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined; @@ -302,95 +319,161 @@ const ControlsCard: React.FC = ({ const board = useRequiredBoard(); const iconUrl = integrationDefs[data.integration.kind].iconUrl; + const layout = rootWidth < 256 ? "sm" : "md"; return ( - - - - - - {data.integration.name} - - - toggleDns(data.integration.id)} - > - - ) - } - > - {t( - `widget.dnsHoleControls.controls.${ - !isConnected - ? "disconnected" - : typeof isEnabled === "undefined" - ? "processing" - : isEnabled - ? "enabled" - : "disabled" - }`, - )} - - + + + {layout === "md" && ( + + )} + + + + {layout === "sm" && ( + + )} + + {data.integration.name} + + + + {layout === "sm" && ( + + {!isEnabled ? ( + toggleDns(data.integration.id)} + disabled={!controlEnabled} + size="sm" + color="green" + variant="light" + > + + + ) : ( + toggleDns(data.integration.id)} + disabled={!controlEnabled} + size="sm" + color="red" + variant="light" + > + + + )} + { + setSelectedIntegrationIds([data.integration.id]); + open(); + }} + size="sm" + color="yellow" + variant="light" + > + + + + )} + {layout === "md" && ( + toggleDns(data.integration.id)} + > + + ) + } + > + {t( + `widget.dnsHoleControls.controls.${ + !isConnected + ? "disconnected" + : typeof isEnabled === "undefined" + ? "processing" + : isEnabled + ? "enabled" + : "disabled" + }`, + )} + + + )} + + {layout === "md" && ( + { + setSelectedIntegrationIds([data.integration.id]); + open(); + }} + > + + + )} - { - setSelectedIntegrationIds([data.integration.id]); - open(); - }} - > - - - - + + ); }; diff --git a/packages/widgets/src/dns-hole/summary/component.tsx b/packages/widgets/src/dns-hole/summary/component.tsx index 239d02295..b0636ff44 100644 --- a/packages/widgets/src/dns-hole/summary/component.tsx +++ b/packages/widgets/src/dns-hole/summary/component.tsx @@ -63,7 +63,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]); return ( - + {data.length > 0 ? ( stats.map((item) => ( @@ -89,43 +89,43 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget const stats = [ { icon: IconBarrierBlock, - value: (data) => + value: (data, size) => formatNumber( data.reduce((count, { adsBlockedToday }) => count + adsBlockedToday, 0), - 2, + size === "sm" ? 0 : 2, ), label: (t) => t("widget.dnsHoleSummary.data.adsBlockedToday"), color: "rgba(240, 82, 60, 0.4)", // RED }, { icon: IconPercentage, - value: (data) => { + value: (data, size) => { const totalCount = data.reduce((count, { dnsQueriesToday }) => count + dnsQueriesToday, 0); const blocked = data.reduce((count, { adsBlockedToday }) => count + adsBlockedToday, 0); - return `${formatNumber(totalCount === 0 ? 0 : (blocked / totalCount) * 100, 2)}%`; + return `${formatNumber(totalCount === 0 ? 0 : (blocked / totalCount) * 100, size === "sm" ? 0 : 2)}%`; }, label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"), color: "rgba(255, 165, 20, 0.4)", // YELLOW }, { icon: IconSearch, - value: (data) => + value: (data, size) => formatNumber( data.reduce((count, { dnsQueriesToday }) => count + dnsQueriesToday, 0), - 2, + size === "sm" ? 0 : 2, ), label: (t) => t("widget.dnsHoleSummary.data.dnsQueriesToday"), color: "rgba(0, 175, 218, 0.4)", // BLUE }, { icon: IconWorldWww, - value: (data) => { + value: (data, size) => { // We use a suffix to indicate that there might be more domains in the at least two lists. const suffix = data.length >= 2 ? "+" : ""; return ( formatNumber( data.reduce((count, { domainsBeingBlocked }) => count + domainsBeingBlocked, 0), - 2, + size === "sm" ? 0 : 2, ) + suffix ); }, @@ -137,7 +137,7 @@ const stats = [ interface StatItem { icon: TablerIcon; - value: (summaries: DnsHoleSummary[]) => string; + value: (summaries: DnsHoleSummary[], size: "sm" | "md") => string; tooltip?: (summaries: DnsHoleSummary[], t: TranslationFunction) => string | undefined; label: stringOrTranslation; color: string; @@ -152,6 +152,8 @@ interface StatCardProps { const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => { const { ref, height, width } = useElementSize(); const isLong = width > height + 20; + const canStackText = height > 32; + const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92); const tooltip = item.tooltip?.(data, t); const board = useRequiredBoard(); @@ -174,25 +176,26 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => { align="center" justify="center" direction={isLong ? "row" : "column"} - style={{ containerType: "size" }} + gap={0} > - + - - {item.value(data)} + + {item.value(data, width <= 64 ? "sm" : "md")} - {item.label && ( - + {!hideLabel && ( + {translateIfNecessary(t, item.label)} )} diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index 10c98ba8a..588cdf608 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -24,7 +24,6 @@ import { Tooltip, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import type { IconProps } from "@tabler/icons-react"; import { IconAlertTriangle, IconCirclesRelation, @@ -75,16 +74,6 @@ const columnsRatios: Record = { upSpeed: 3, }; -const actionIconIconStyle: IconProps["style"] = { - height: "var(--ai-icon-size)", - width: "var(--ai-icon-size)", -}; - -const standardIconStyle: IconProps["style"] = { - height: "var(--icon-size)", - width: "var(--icon-size)", -}; - export default function DownloadClientsWidget({ isEditMode, integrationIds, @@ -307,72 +296,41 @@ export default function DownloadClientsWidget({ upSpeed: options.columns.includes("upSpeed") && integrationTypes.includes("torrent"), } satisfies Record; - //Set a relative width using ratio table - const totalWidth = options.columns.reduce( - (count: number, column) => (columnVisibility[column] ? count + columnsRatios[column] : count), - 0, - ); - //Default styling behavior for stopping interaction when editing. (Applied everywhere except the table header) const editStyle: MantineStyleProp = { pointerEvents: isEditMode ? "none" : undefined, }; - //General style sizing as vars that should apply or be applied to all elements - const baseStyle: MantineStyleProp = { - "--total-width": totalWidth, - "--ratio-width": "calc(100cqw / var(--total-width))", - "--space-size": "calc(var(--ratio-width) * 0.1)", //Standard gap and spacing value - "--text-fz": "calc(var(--ratio-width) * 0.45)", //General Font Size - "--button-fz": "var(--text-fz)", - "--icon-size": "calc(var(--ratio-width) * 2 / 3)", //Normal icon size - "--ai-icon-size": "calc(var(--ratio-width) * 0.5)", //Icon inside action icons size - "--button-size": "calc(var(--ratio-width) * 0.75)", //Action Icon, button and avatar size - "--image-size": "var(--button-size)", - "--mrt-base-background-color": "transparent", - }; - //Base element in common with all columns const columnsDefBase = useCallback( ({ key, showHeader, - align, }: { key: keyof ExtendedDownloadClientItem; showHeader: boolean; - align?: "center" | "left" | "right" | "justify" | "char"; }): MRT_ColumnDef => { - const style: MantineStyleProp = { - minWidth: 0, - width: "var(--column-width)", - height: "var(--ratio-width)", - padding: "var(--space-size)", - transition: "unset", - "--key-width": columnsRatios[key], - "--column-width": "calc((var(--key-width)/var(--total-width) * 100cqw))", - }; return { id: key, accessorKey: key, header: key, size: columnsRatios[key], - mantineTableBodyCellProps: { style, align }, - mantineTableHeadCellProps: { - style, - align: isEditMode ? "center" : align, - }, - Header: () => (showHeader && !isEditMode ? {t(`items.${key}.columnTitle`)} : ""), + Header: () => + showHeader ? ( + + {t(`items.${key}.columnTitle`)} + + ) : null, }; }, - [isEditMode, t], + [t], ); //Make columns and cell elements, Memoized to data with deps on data and EditMode const columns = useMemo[]>( () => [ { - ...columnsDefBase({ key: "actions", showHeader: false, align: "center" }), + ...columnsDefBase({ key: "actions", showHeader: false }), enableSorting: false, Cell: ({ cell, row }) => { const actions = cell.getValue(); @@ -380,19 +338,15 @@ export default function DownloadClientsWidget({ const [opened, { open, close }] = useDisclosure(false); return actions ? ( - + - - {pausedAction === "resume" ? ( - - ) : ( - - )} + + {pausedAction === "resume" ? : } - - + + @@ -423,68 +377,68 @@ export default function DownloadClientsWidget({ ) : ( - - + + ); }, }, { - ...columnsDefBase({ key: "added", showHeader: true, align: "center" }), + ...columnsDefBase({ key: "added", showHeader: true }), sortUndefined: "last", Cell: ({ cell }) => { const added = cell.getValue(); - return {added !== undefined ? dayjs(added).fromNow() : "unknown"}; + return {added !== undefined ? dayjs(added).fromNow() : "unknown"}; }, }, { - ...columnsDefBase({ key: "category", showHeader: false, align: "center" }), + ...columnsDefBase({ key: "category", showHeader: false }), sortUndefined: "last", Cell: ({ cell }) => { const category = cell.getValue(); return ( category !== undefined && ( - + ) ); }, }, { - ...columnsDefBase({ key: "downSpeed", showHeader: true, align: "right" }), + ...columnsDefBase({ key: "downSpeed", showHeader: true }), sortUndefined: "last", Cell: ({ cell }) => { const downSpeed = cell.getValue(); - return downSpeed && {humanFileSize(downSpeed, "/s")}; + return downSpeed ? {humanFileSize(downSpeed, "/s")} : null; }, }, { - ...columnsDefBase({ key: "id", showHeader: false, align: "center" }), + ...columnsDefBase({ key: "id", showHeader: false }), enableSorting: false, Cell: ({ cell }) => { const id = cell.getValue(); return ( - + ); }, }, { - ...columnsDefBase({ key: "index", showHeader: true, align: "center" }), + ...columnsDefBase({ key: "index", showHeader: true }), Cell: ({ cell }) => { const index = cell.getValue(); - return {index}; + return {index}; }, }, { - ...columnsDefBase({ key: "integration", showHeader: false, align: "center" }), + ...columnsDefBase({ key: "integration", showHeader: false }), Cell: ({ cell }) => { const integration = cell.getValue(); return ( - + ); }, @@ -494,62 +448,61 @@ export default function DownloadClientsWidget({ Cell: ({ cell }) => { const name = cell.getValue(); return ( - + {name} ); }, }, { - ...columnsDefBase({ key: "progress", showHeader: true, align: "center" }), + ...columnsDefBase({ key: "progress", showHeader: true }), Cell: ({ cell, row }) => { const progress = cell.getValue(); return ( - - + + {new Intl.NumberFormat("en", { style: "percent", notation: "compact", unitDisplay: "narrow" }).format( progress, )} - + ); }, }, { - ...columnsDefBase({ key: "ratio", showHeader: true, align: "center" }), + ...columnsDefBase({ key: "ratio", showHeader: true }), sortUndefined: "last", Cell: ({ cell }) => { const ratio = cell.getValue(); - return ratio !== undefined && {ratio.toFixed(ratio >= 100 ? 0 : ratio >= 10 ? 1 : 2)}; + return ratio !== undefined && {ratio.toFixed(ratio >= 100 ? 0 : ratio >= 10 ? 1 : 2)}; }, }, { - ...columnsDefBase({ key: "received", showHeader: true, align: "right" }), + ...columnsDefBase({ key: "received", showHeader: true }), Cell: ({ cell }) => { const received = cell.getValue(); - return {humanFileSize(received)}; + return {humanFileSize(received)}; }, }, { - ...columnsDefBase({ key: "sent", showHeader: true, align: "right" }), + ...columnsDefBase({ key: "sent", showHeader: true }), sortUndefined: "last", Cell: ({ cell }) => { const sent = cell.getValue(); - return sent && {humanFileSize(sent)}; + return sent && {humanFileSize(sent)}; }, }, { - ...columnsDefBase({ key: "size", showHeader: true, align: "right" }), + ...columnsDefBase({ key: "size", showHeader: true }), Cell: ({ cell }) => { const size = cell.getValue(); - return {humanFileSize(size)}; + return {humanFileSize(size)}; }, }, { @@ -557,25 +510,25 @@ export default function DownloadClientsWidget({ enableSorting: false, Cell: ({ cell }) => { const state = cell.getValue(); - return {t(`states.${state}`)}; + return {t(`states.${state}`)}; }, }, { - ...columnsDefBase({ key: "time", showHeader: true, align: "center" }), + ...columnsDefBase({ key: "time", showHeader: true }), Cell: ({ cell }) => { const time = cell.getValue(); - return time === 0 ? : {dayjs().add(time).fromNow()}; + return time === 0 ? : {dayjs().add(time).fromNow()}; }, }, { ...columnsDefBase({ key: "type", showHeader: true }), Cell: ({ cell }) => { const type = cell.getValue(); - return {type}; + return {type}; }, }, { - ...columnsDefBase({ key: "upSpeed", showHeader: true, align: "right" }), + ...columnsDefBase({ key: "upSpeed", showHeader: true }), sortUndefined: "last", Cell: ({ cell }) => { const upSpeed = cell.getValue(); @@ -604,17 +557,17 @@ export default function DownloadClientsWidget({ mantineTableContainerProps: { style: { height: "100%" } }, mantineTableProps: { className: "downloads-widget-table", - style: { - "--sortButtonSize": "var(--button-size)", - "--dragButtonSize": "var(--button-size)", - }, }, mantineTableBodyProps: { style: editStyle }, + mantineTableHeadCellProps: { + p: 4, + }, mantineTableBodyCellProps: ({ cell, row }) => ({ onClick: () => { setClickedIndex(row.index); if (cell.column.id !== "actions") open(); }, + p: 4, }), onColumnOrderChange: (order) => { //Order has a tendency to add the disabled column at the end of the the real ordered array @@ -666,26 +619,25 @@ export default function DownloadClientsWidget({ if (options.columns.length === 0) return (
- {t("errors.noColumns")} + {t("errors.noColumns")}
); //The actual widget return ( - + {integrationTypes.includes("torrent") && ( - - {`${t("globalRatio")}:`} - {(globalTraffic.up / globalTraffic.down).toFixed(2)} + + {`${t("globalRatio")}:`} + {(globalTraffic.up / globalTraffic.down).toFixed(2)} )} - - + + - + {t("items.integration.columnTitle")} - + {clients.map((client) => ( ))} @@ -847,37 +799,37 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli {someInteract && ( mutateResumeQueue({ integrationIds: integrationsStatuses.paused })} > - + )} {someInteract && ( mutatePauseQueue({ integrationIds: integrationsStatuses.active })} > - + )} @@ -967,9 +919,8 @@ const ClientAvatar = ({ client }: ClientAvatarProps) => { key={client.integration.id} src={getIconUrl(client.integration.kind)} style={{ filter: !isConnected ? "grayscale(100%)" : undefined }} - size={30} + size="sm" p={5} - bd={`calc(var(--space-size)*0.5) solid ${client.status ? "transparent" : "var(--mantine-color-red-filled)"}`} /> ); }; diff --git a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx index c2abec2b8..db9718990 100644 --- a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx +++ b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx @@ -31,6 +31,7 @@ const running = (total: number, current: Resource) => { export const ClusterHealthMonitoring = ({ integrationId, options, + width, }: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => { const t = useI18n(); const [healthData] = clientApi.widget.healthMonitoring.getClusterHealthStatus.useSuspenseQuery( @@ -72,14 +73,15 @@ export const ClusterHealthMonitoring = ({ const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0; const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0; + const isTiny = width < 256; return ( - - - + + + {formatUptime(uptime, t)} - + - + - + - + - + @@ -140,45 +146,50 @@ export const ClusterHealthMonitoring = ({ interface SummaryHeaderProps { cpu: number; memory: number; + isTiny: boolean; } -const SummaryHeader = ({ cpu, memory }: SummaryHeaderProps) => { +const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => { const t = useI18n(); return (
- + - +
} sections={[{ value: cpu, color: cpu > 75 ? "orange" : "green" }]} /> - {t("widget.healthMonitoring.cluster.summary.cpu")} - {cpu.toFixed(1)}% + + {t("widget.healthMonitoring.cluster.summary.cpu")} + + {cpu.toFixed(1)}%
- + } sections={[{ value: memory, color: memory > 75 ? "orange" : "green" }]} /> - {t("widget.healthMonitoring.cluster.summary.memory")} - {memory.toFixed(1)}% + + {t("widget.healthMonitoring.cluster.summary.memory")} + + {memory.toFixed(1)}%
diff --git a/packages/widgets/src/health-monitoring/cluster/resource-accordion-item.tsx b/packages/widgets/src/health-monitoring/cluster/resource-accordion-item.tsx index 717822e60..5c6aa9dc4 100644 --- a/packages/widgets/src/health-monitoring/cluster/resource-accordion-item.tsx +++ b/packages/widgets/src/health-monitoring/cluster/resource-accordion-item.tsx @@ -13,6 +13,7 @@ interface ResourceAccordionItemProps { activeCount: number; totalCount: number; }; + isTiny: boolean; } export const ResourceAccordionItem = ({ @@ -21,13 +22,14 @@ export const ResourceAccordionItem = ({ icon: Icon, badge, children, + isTiny, }: PropsWithChildren) => { return ( - }> - - {title} - + }> + + {title} + {badge.activeCount} / {badge.totalCount} diff --git a/packages/widgets/src/health-monitoring/cluster/resource-table.tsx b/packages/widgets/src/health-monitoring/cluster/resource-table.tsx index bc8226f37..8f3637f19 100644 --- a/packages/widgets/src/health-monitoring/cluster/resource-table.tsx +++ b/packages/widgets/src/health-monitoring/cluster/resource-table.tsx @@ -1,4 +1,4 @@ -import { Group, Indicator, Popover, Table, Text } from "@mantine/core"; +import { Group, Indicator, Popover, Table, TableTbody, TableThead, TableTr, Text } from "@mantine/core"; import type { Resource } from "@homarr/integrations/types"; import { useI18n } from "@homarr/translation/client"; @@ -8,36 +8,47 @@ import { ResourcePopover } from "./resource-popover"; interface ResourceTableProps { type: Resource["type"]; data: Resource[]; + isTiny: boolean; } -export const ResourceTable = ({ type, data }: ResourceTableProps) => { +export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => { const t = useI18n(); return ( - - - {t("widget.healthMonitoring.cluster.table.header.name")} + + + + {t("widget.healthMonitoring.cluster.table.header.name")} + {type !== "storage" ? ( - {t("widget.healthMonitoring.cluster.table.header.cpu")} + + {t("widget.healthMonitoring.cluster.table.header.cpu")} + ) : null} {type !== "storage" ? ( - {t("widget.healthMonitoring.cluster.table.header.memory")} + + {t("widget.healthMonitoring.cluster.table.header.memory")} + ) : null} {type === "storage" ? ( - {t("widget.healthMonitoring.cluster.table.header.node")} + + {t("widget.healthMonitoring.cluster.table.header.node")} + ) : null} - - - + + + {data.map((item) => { return ( - + {item.type === "storage" ? ( @@ -50,12 +61,12 @@ export const ResourceTable = ({ type, data }: ResourceTableProps) => { )} - + ); })} - +
- - - {item.name} + + + + {item.name} +
); }; diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx index 837e04504..d1ee3dd28 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -31,30 +31,20 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal } return ( - + - + {t("widget.healthMonitoring.tab.system")} - + {t("widget.healthMonitoring.tab.cluster")} - + - + diff --git a/packages/widgets/src/health-monitoring/rings/cpu-ring.tsx b/packages/widgets/src/health-monitoring/rings/cpu-ring.tsx index 0069c6d96..ff1746eea 100644 --- a/packages/widgets/src/health-monitoring/rings/cpu-ring.tsx +++ b/packages/widgets/src/health-monitoring/rings/cpu-ring.tsx @@ -1,33 +1,30 @@ -import { Box, Center, RingProgress, Text } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; +import { Center, RingProgress, Text } from "@mantine/core"; import { IconCpu } from "@tabler/icons-react"; import { progressColor } from "../system-health"; -export const CpuRing = ({ cpuUtilization }: { cpuUtilization: number }) => { - const { width, ref } = useElementSize(); - const fallbackWidth = width || 1; // See https://github.com/homarr-labs/homarr/issues/2196 - +export const CpuRing = ({ cpuUtilization, isTiny }: { cpuUtilization: number; isTiny: boolean }) => { return ( - - - {`${cpuUtilization.toFixed(2)}%`} - - - } - sections={[ - { - value: Number(cpuUtilization.toFixed(2)), - color: progressColor(Number(cpuUtilization.toFixed(2))), - }, - ]} - /> - + + {`${cpuUtilization.toFixed(2)}%`} + + + } + sections={[ + { + value: Number(cpuUtilization.toFixed(2)), + color: progressColor(Number(cpuUtilization.toFixed(2))), + }, + ]} + /> ); }; diff --git a/packages/widgets/src/health-monitoring/rings/cpu-temp-ring.tsx b/packages/widgets/src/health-monitoring/rings/cpu-temp-ring.tsx index a70f26a90..2c6a2b254 100644 --- a/packages/widgets/src/health-monitoring/rings/cpu-temp-ring.tsx +++ b/packages/widgets/src/health-monitoring/rings/cpu-temp-ring.tsx @@ -1,39 +1,41 @@ -import { Box, Center, RingProgress, Text } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; +import { Center, RingProgress, Text } from "@mantine/core"; import { IconCpu } from "@tabler/icons-react"; import { progressColor } from "../system-health"; -export const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: number | undefined }) => { - const { width, ref } = useElementSize(); - const fallbackWidth = width || 1; // See https://github.com/homarr-labs/homarr/issues/2196 - +export const CpuTempRing = ({ + fahrenheit, + cpuTemp, + isTiny, +}: { + fahrenheit: boolean; + cpuTemp: number | undefined; + isTiny: boolean; +}) => { if (!cpuTemp) { return null; } return ( - - - - {fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`} - - - - } - sections={[ - { - value: cpuTemp, - color: progressColor(cpuTemp), - }, - ]} - /> - + + + {fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`} + + + + } + sections={[ + { + value: cpuTemp, + color: progressColor(cpuTemp), + }, + ]} + /> ); }; diff --git a/packages/widgets/src/health-monitoring/rings/memory-ring.tsx b/packages/widgets/src/health-monitoring/rings/memory-ring.tsx index 2d8ce4d68..e19ad0fe6 100644 --- a/packages/widgets/src/health-monitoring/rings/memory-ring.tsx +++ b/packages/widgets/src/health-monitoring/rings/memory-ring.tsx @@ -1,38 +1,33 @@ -import { Box, Center, RingProgress, Text } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; +import { Center, RingProgress, Text } from "@mantine/core"; import { IconBrain } from "@tabler/icons-react"; import { progressColor } from "../system-health"; -export const MemoryRing = ({ available, used }: { available: string; used: string }) => { - const { width, ref } = useElementSize(); - const fallbackWidth = width || 1; // See https://github.com/homarr-labs/homarr/issues/2196 +export const MemoryRing = ({ available, used, isTiny }: { available: string; used: string; isTiny: boolean }) => { const memoryUsage = formatMemoryUsage(available, used); return ( - - - - {memoryUsage.memUsed.GB}GiB - - - - } - sections={[ - { - value: Number(memoryUsage.memUsed.percent), - color: progressColor(Number(memoryUsage.memUsed.percent)), - tooltip: `${memoryUsage.memUsed.percent}%`, - }, - ]} - /> - + + + {memoryUsage.memUsed.GB}GiB + + + + } + sections={[ + { + value: Number(memoryUsage.memUsed.percent), + color: progressColor(Number(memoryUsage.memUsed.percent)), + tooltip: `${memoryUsage.memUsed.percent}%`, + }, + ]} + /> ); }; diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index 82109f87a..6e4aa2ed4 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -44,7 +44,11 @@ import classes from "./system-health.module.css"; dayjs.extend(duration); -export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) => { +export const SystemHealthMonitoring = ({ + options, + integrationIds, + width, +}: WidgetComponentProps<"healthMonitoring">) => { const t = useI18n(); const [healthData] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery( { @@ -79,6 +83,8 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon }, ); + const isTiny = width < 256; + return ( {healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => { @@ -91,95 +97,93 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon h="100%" className={`health-monitoring-information health-monitoring-${integrationName}`} p="sm" + pos="relative" > - - + 0 ? "blue" : "gray"} + position="top-end" + size={16} + label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined} + disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0} > - - 0 ? "blue" : "gray"} - position="top-end" - size="md" - label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined} - disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0} - > - - - - - - - - - }> - {t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })} - - }> - {t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })} - - }> - {t("widget.healthMonitoring.popover.memoryAvailable", { - memoryAvailable: memoryUsage.memFree.GB, - percent: memoryUsage.memFree.percent, - })} - - }> - {t("widget.healthMonitoring.popover.version", { - version: healthInfo.version, - })} - - }> - {formatUptime(healthInfo.uptime, t)} - - }> - {t("widget.healthMonitoring.popover.loadAverage")} - - }> - - {t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}% - - - {t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "} - {healthInfo.loadAverage["5min"]}% - - - {t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "} - {healthInfo.loadAverage["15min"]}% - - - - - - - {options.cpu && } - {options.cpu && } - {options.memory && } - - { - - {t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })} - - } + + + + + + + + + }> + {t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })} + + }> + {t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })} + + }> + {t("widget.healthMonitoring.popover.memoryAvailable", { + memoryAvailable: memoryUsage.memFree.GB, + percent: String(memoryUsage.memFree.percent), + })} + + }> + {t("widget.healthMonitoring.popover.version", { + version: healthInfo.version, + })} + + }> + {formatUptime(healthInfo.uptime, t)} + + }> + {t("widget.healthMonitoring.popover.loadAverage")} + + }> + + {t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}% + + + {t("widget.healthMonitoring.popover.minutes", { count: "5" })} {healthInfo.loadAverage["5min"]}% + + + {t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "} + {healthInfo.loadAverage["15min"]}% + + + + + + + {options.cpu && } + {options.cpu && ( + + )} + {options.memory && ( + + )} + + { + + {t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })} + + } {options.fileSystem && disksData.map((disk) => { return ( @@ -188,63 +192,72 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon `health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`, classes.card, )} + style={{ overflow: "visible" }} key={disk.deviceName} radius={board.itemRadius} - p="sm" + p="xs" > - - - - - {disk.deviceName} - - - - - - {options.fahrenheit - ? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F` - : `${disk.temperature}°C`} - - - - - - {disk.overallStatus ? disk.overallStatus : "N/A"} - - - - - - - - {t("widget.healthMonitoring.popover.used")} - - - - - = 1 - ? `${(Number(disk.available) / 1024 ** 4).toFixed(2)} TiB` - : `${(Number(disk.available) / 1024 ** 3).toFixed(2)} GiB` - } + + - + + + {disk.deviceName} + + + + + + {options.fahrenheit + ? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F` + : `${disk.temperature}°C`} + + + + + + {disk.overallStatus ? disk.overallStatus : "N/A"} + + +
+ + + + + {t("widget.healthMonitoring.popover.used")} + + + + + = 1 + ? `${(Number(disk.available) / 1024 ** 4).toFixed(2)} TiB` + : `${(Number(disk.available) / 1024 ** 3).toFixed(2)} GiB` + } > - - {t("widget.healthMonitoring.popover.available")} - - - - + + + {t("widget.healthMonitoring.popover.available")} + + + + + ); })} @@ -262,7 +275,12 @@ export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => const hours = uptimeDuration.hours(); const minutes = uptimeDuration.minutes(); - return t("widget.healthMonitoring.popover.uptime", { months, days, hours, minutes }); + return t("widget.healthMonitoring.popover.uptime", { + months: String(months), + days: String(days), + hours: String(hours), + minutes: String(minutes), + }); }; export const progressColor = (percentage: number) => { diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx index 12aed440a..e406ffa8a 100644 --- a/packages/widgets/src/indexer-manager/component.tsx +++ b/packages/widgets/src/indexer-manager/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Anchor, Button, Card, Container, Flex, Group, ScrollArea, Text } from "@mantine/core"; +import { ActionIcon, Anchor, Button, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -11,7 +11,12 @@ import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; -export default function IndexerManagerWidget({ options, integrationIds }: WidgetComponentProps<"indexerManager">) { +export default function IndexerManagerWidget({ + options, + integrationIds, + width, + height, +}: WidgetComponentProps<"indexerManager">) { const t = useI18n(); const [indexersData] = clientApi.widget.indexerManager.getIndexersStatus.useSuspenseQuery( { integrationIds }, @@ -40,37 +45,60 @@ export default function IndexerManagerWidget({ options, integrationIds }: Widget }, ); + const hasSmallWidth = width < 256; + const hasSmallHeight = height < 256; + return ( - - - + + + {t("widget.indexerManager.title")} - + {hasSmallHeight && ( + { + testAll({ integrationIds }); + }} + > + + + )} + - + {indexersData.map(({ integrationId, indexers }) => ( - + {indexers.map((indexer) => ( - + {indexer.name} @@ -78,35 +106,38 @@ export default function IndexerManagerWidget({ options, integrationIds }: Widget ) : ( )} ))} - + ))} - + {!hasSmallHeight && ( + + )} ); } diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 07248d203..58499ecd4 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -16,6 +16,7 @@ export default function MediaServerWidget({ integrationIds, isEditMode, options, + width, }: WidgetComponentProps<"mediaRequests-requestList">) { const t = useScopedI18n("widget.mediaRequests-requestList"); const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery( @@ -59,19 +60,21 @@ export default function MediaServerWidget({ if (mediaRequests.length === 0) throw new NoIntegrationDataError(); + const isTiny = width < 256; + return ( - + {mediaRequests.map((mediaRequest) => ( - - - - - {mediaRequest.airDate?.getFullYear() ?? t("toBeDetermined")} - - - {getAvailabilityProperties(mediaRequest.availability, t).label} - + {!isTiny && ( + + )} + + + + + + {mediaRequest.airDate?.getFullYear() ?? t("toBeDetermined")} + + {!isTiny && ( + + {getAvailabilityProperties(mediaRequest.availability, t).label} + + )} + + + + + {(mediaRequest.requestedBy?.displayName ?? "") || "unknown"} + + + + + + {mediaRequest.name || "unknown"} + + {mediaRequest.status === MediaRequestStatus.PendingApproval ? ( + + + { + mutateRequestAnswer({ + integrationId: mediaRequest.integrationId, + requestId: mediaRequest.id, + answer: "approve", + }); + }} + > + + + + + { + mutateRequestAnswer({ + integrationId: mediaRequest.integrationId, + requestId: mediaRequest.id, + answer: "decline", + }); + }} + > + + + + + ) : ( + + )} - - {mediaRequest.name || "unknown"} - - - - - - {(mediaRequest.requestedBy?.displayName ?? "") || "unknown"} - - - {mediaRequest.status === MediaRequestStatus.PendingApproval ? ( - - - { - mutateRequestAnswer({ - integrationId: mediaRequest.integrationId, - requestId: mediaRequest.id, - answer: "approve", - }); - }} - > - - - - - { - mutateRequestAnswer({ - integrationId: mediaRequest.integrationId, - requestId: mediaRequest.id, - answer: "decline", - }); - }} - > - - - - - ) : ( - - )} - ))} diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 59be66653..87e7da8aa 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -1,11 +1,9 @@ "use client"; -import { ActionIcon, Avatar, Card, Grid, Group, Space, Stack, Text, Tooltip } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; +import { Avatar, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core"; import type { Icon } from "@tabler/icons-react"; import { IconDeviceTv, - IconExternalLink, IconHourglass, IconLoaderQuarter, IconMovie, @@ -28,6 +26,7 @@ import classes from "./component.module.css"; export default function MediaServerWidget({ integrationIds, isEditMode, + width, }: WidgetComponentProps<"mediaRequests-requestStats">) { const t = useScopedI18n("widget.mediaRequests-requestStats"); const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery( @@ -41,8 +40,6 @@ export default function MediaServerWidget({ }, ); - const { width, height, ref } = useElementSize(); - const board = useRequiredBoard(); if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError(); @@ -90,94 +87,84 @@ export default function MediaServerWidget({ }, ] satisfies { name: keyof RequestStats; icon: Icon; number: number }[]; + const isTiny = width < 256; + return ( - - {t("titles.stats.main")} - - - {data.map((stat) => ( - - - - - - - {stat.number} - - - - - - ))} - - - {t("titles.users.main")} - - - {requestStats.users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => ( - - - - + + + {t("titles.stats.main")} + + + {data.map((stat) => ( + + + + + + + {stat.number} + + + - - - {user.displayName} + + ))} + + + + + {t("titles.users.main")} ({t("titles.users.requests")}) + + + {requestStats.users.slice(0, 10).map((user) => ( + + + + + + + + {user.displayName} + + + + + {user.requestCount} - - {`${t("titles.users.requests")}: ${user.requestCount}`} - - - - - - - - - ))} + + + ))} + ); diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index a52c0ee8f..146f7915f 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -1,8 +1,9 @@ "use client"; +import type { ReactNode } from "react"; import { useMemo } from "react"; -import { Avatar, Box, Flex, Group, Stack, Text, Title } from "@mantine/core"; -import { IconDeviceAudioTape, IconDeviceTv, IconMovie, IconVideo } from "@tabler/icons-react"; +import { Avatar, Flex, Group, Stack, Text, Title } from "@mantine/core"; +import { IconDeviceTv, IconHeadphones, IconMovie, IconVideo } from "@tabler/icons-react"; import type { MRT_ColumnDef } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table"; @@ -11,6 +12,7 @@ import { getIconUrl, integrationDefs } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; import { createModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; @@ -28,59 +30,51 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget ); const utils = clientApi.useUtils(); + const t = useScopedI18n("widget.mediaServer"); const columns = useMemo[]>( () => [ { accessorKey: "sessionName", - header: "Name", - mantineTableHeadCellProps: { - style: { - width: "30%", - }, - }, + header: t("items.name"), + Cell: ({ row }) => ( - + {row.original.sessionName} ), }, { accessorKey: "user.username", - header: "User", - mantineTableHeadCellProps: { - style: { - width: "25%", - }, - }, + header: t("items.user"), + Cell: ({ row }) => ( - - - {row.original.user.username} + + + {row.original.user.username} ), }, { accessorKey: "currentlyPlaying", // currentlyPlaying.name can be undefined which results in a warning. This is why we use currentlyPlaying instead of currentlyPlaying.name - header: "Currently playing", - mantineTableHeadCellProps: { - style: { - width: "45%", - }, - }, - Cell: ({ row }) => { - if (row.original.currentlyPlaying) { - return ( - - {row.original.currentlyPlaying.name} - - ); - } + header: t("items.currentlyPlaying"), - return null; + Cell: ({ row }) => { + if (!row.original.currentlyPlaying) return null; + + const Icon = mediaTypeIconMap[row.original.currentlyPlaying.type]; + + return ( + + + + {row.original.currentlyPlaying.name} + + + ); }, }, ], - [], + [t], ); clientApi.widget.mediaServer.subscribeToCurrentStreams.useSubscription( @@ -137,8 +131,18 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget enableDensityToggle: false, enableFilters: false, enableHiding: false, + enableColumnPinning: true, initialState: { density: "xs", + columnPinning: { + right: ["currentlyPlaying"], + }, + }, + mantineTableHeadProps: { + fz: "xs", + }, + mantineTableHeadCellProps: { + py: 4, }, mantinePaperProps: { flex: 1, @@ -158,20 +162,16 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget }, mantineTableBodyCellProps: ({ row }) => ({ onClick: () => { - openModal({ - item: row.original, - title: - row.original.currentlyPlaying?.type === "movie" ? ( - - ) : row.original.currentlyPlaying?.type === "tv" ? ( - - ) : row.original.currentlyPlaying?.type === "video" ? ( - - ) : ( - - ), - }); + openModal( + { + item: row.original, + }, + { + title: row.original.sessionName, + }, + ); }, + py: 4, }), }); @@ -210,42 +210,64 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget ); } -const itemInfoModal = createModal<{ item: StreamSession; title: React.ReactNode }>(({ innerProps }) => { +const itemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => { const t = useScopedI18n("widget.mediaServer.items"); + const Icon = innerProps.item.currentlyPlaying ? mediaTypeIconMap[innerProps.item.currentlyPlaying.type] : null; return ( - {innerProps.title} - {innerProps.item.currentlyPlaying?.name} - - {innerProps.item.currentlyPlaying?.episodeName} - {innerProps.item.currentlyPlaying?.seasonName && ( - <> - {" - "} - {innerProps.item.currentlyPlaying.seasonName} - - )} - + {Icon && innerProps.item.currentlyPlaying !== null && ( + + + {innerProps.item.currentlyPlaying.name} + + )} + {innerProps.item.currentlyPlaying?.episodeName && ( + + {innerProps.item.currentlyPlaying.episodeName} + {innerProps.item.currentlyPlaying.seasonName && ( + <> + {" - "} + {innerProps.item.currentlyPlaying.seasonName} + + )} + + )} - - - + + {" "} + {innerProps.item.user.username} + + } + /> + {innerProps.item.sessionName}} /> + {innerProps.item.sessionId}} /> ); }).withOptions({ defaultTitle() { return ""; }, - size: "auto", + size: "lg", centered: true, }); -const NormalizedLine = ({ itemKey, value }: { itemKey: string; value: string }) => { +const NormalizedLine = ({ itemKey, value }: { itemKey: string; value: ReactNode }) => { return ( {itemKey}: - {value} + {value} ); }; + +const mediaTypeIconMap = { + movie: IconMovie, + tv: IconDeviceTv, + video: IconVideo, + audio: IconHeadphones, +} satisfies Record["type"], TablerIcon>; diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx index 10342ec6a..d66da98e6 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -2,20 +2,32 @@ import { useState } from "react"; import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core"; +import type { TablerIcon } from "@tabler/icons-react"; import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useI18n } from "@homarr/translation/client"; +import { views } from "."; import type { WidgetComponentProps } from "../definition"; import { HealthCheckStatus } from "./health-check-status"; import { QueuePanel } from "./panels/queue.panel"; import { StatisticsPanel } from "./panels/statistics.panel"; import { WorkersPanel } from "./panels/workers.panel"; -type Views = "workers" | "queue" | "statistics"; +type View = (typeof views)[number]; -export default function MediaTranscodingWidget({ integrationIds, options }: WidgetComponentProps<"mediaTranscoding">) { +const viewIcons = { + workers: IconCpu2, + queue: IconClipboardList, + statistics: IconReportAnalytics, +} satisfies Record; + +export default function MediaTranscodingWidget({ + integrationIds, + options, + width, +}: WidgetComponentProps<"mediaTranscoding">) { const [queuePage, setQueuePage] = useState(1); const queuePageSize = 10; const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery( @@ -31,15 +43,16 @@ export default function MediaTranscodingWidget({ integrationIds, options }: Widg }, ); - const [view, setView] = useState(options.defaultView); + const [view, setView] = useState(options.defaultView); const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize); const t = useI18n("widget.mediaTranscoding"); + const isTiny = width < 256; return ( {view === "workers" ? ( - + ) : view === "queue" ? ( ) : ( @@ -48,65 +61,48 @@ export default function MediaTranscodingWidget({ integrationIds, options }: Widg { + const Icon = viewIcons[value]; + return { label: ( -
- - - {t("tab.workers")} - +
+ + {!isTiny && ( + + {t(`tab.${value}`)} + + )}
), - value: "workers", - }, - { - label: ( -
- - - {t("tab.queue")} - -
- ), - value: "queue", - }, - { - label: ( -
- - - {t("tab.statistics")} - -
- ), - value: "statistics", - }, - ]} + value, + }; + })} value={view} - onChange={(value) => setView(value as Views)} + onChange={(value) => setView(value as View)} size="xs" /> - {view === "queue" && ( - <> - - - - - - - - - - {t("currentIndex", { - start: transcodingData.data.queue.startIndex + 1, - end: transcodingData.data.queue.endIndex + 1, - total: transcodingData.data.queue.totalCount, - })} - - - )} + + {view === "queue" && ( + <> + + + {!isTiny && } + + + {!isTiny && } + + + + {t("currentIndex", { + start: String(transcodingData.data.queue.startIndex + 1), + end: String(transcodingData.data.queue.endIndex + 1), + total: String(transcodingData.data.queue.totalCount), + })} + + + )} + diff --git a/packages/widgets/src/media-transcoding/health-check-status.tsx b/packages/widgets/src/media-transcoding/health-check-status.tsx index 6491f6289..d1b3f7df9 100644 --- a/packages/widgets/src/media-transcoding/health-check-status.tsx +++ b/packages/widgets/src/media-transcoding/health-check-status.tsx @@ -23,8 +23,8 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) { return ( - - + + diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts index 355f3d9d3..0c6239eea 100644 --- a/packages/widgets/src/media-transcoding/index.ts +++ b/packages/widgets/src/media-transcoding/index.ts @@ -1,20 +1,20 @@ import { IconTransform } from "@tabler/icons-react"; import { z } from "zod"; +import { capitalize } from "@homarr/common"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; +export const views = ["workers", "queue", "statistics"] as const; + export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { icon: IconTransform, createOptions() { return optionsBuilder.from((factory) => ({ defaultView: factory.select({ defaultValue: "statistics", - options: [ - { label: "Workers", value: "workers" }, - { label: "Queue", value: "queue" }, - { label: "Statistics", value: "statistics" }, - ], + options: views.map((view) => ({ label: capitalize(view), value: view })), }), queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }), })); diff --git a/packages/widgets/src/media-transcoding/panels/queue.panel.tsx b/packages/widgets/src/media-transcoding/panels/queue.panel.tsx index 9d4d6a680..45cc7581f 100644 --- a/packages/widgets/src/media-transcoding/panels/queue.panel.tsx +++ b/packages/widgets/src/media-transcoding/panels/queue.panel.tsx @@ -1,4 +1,4 @@ -import { Center, Group, ScrollArea, Table, Text, Title, Tooltip } from "@mantine/core"; +import { Center, Group, ScrollArea, Table, TableTd, TableTh, TableTr, Text, Title, Tooltip } from "@mantine/core"; import { IconHeartbeat, IconTransform } from "@tabler/icons-react"; import { humanFileSize } from "@homarr/common"; @@ -17,7 +17,7 @@ export function QueuePanel(props: QueuePanelProps) { if (queue.array.length === 0) { return (
- {t("empty")} + {t("empty")}
); } @@ -26,36 +26,42 @@ export function QueuePanel(props: QueuePanelProps) { - - - - + + + + {t("table.file")} + + + + + {t("table.size")} + + + {queue.array.map((item) => ( - - - - + + ))}
{t("table.file")}{t("table.size")}
- -
- {item.type === "transcode" ? ( - - - - ) : ( - - - - )} -
+ + + + {item.type === "transcode" ? ( + + + + ) : ( + + + + )} {item.filePath.split("\\").pop()?.split("/").pop() ?? item.filePath} -
+ + {humanFileSize(item.fileSize)} -
diff --git a/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx b/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx index 93da0c0ce..23ab97563 100644 --- a/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx +++ b/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx @@ -1,11 +1,12 @@ -import type react from "react"; import type { MantineColor, RingProgressProps } from "@mantine/core"; -import { Box, Center, Grid, Group, RingProgress, Stack, Text, Title, useMantineColorScheme } from "@mantine/core"; +import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tooltip } from "@mantine/core"; import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react"; +import { useRequiredBoard } from "@homarr/boards/context"; import { humanFileSize } from "@homarr/common"; import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations"; import { useI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; const PIE_COLORS: MantineColor[] = ["cyan", "grape", "gray", "orange", "pink"]; @@ -21,90 +22,54 @@ export function StatisticsPanel(props: StatisticsPanelProps) { if (!allLibs) { return (
- {t("empty")} + {t("empty")}
); } return ( - - - - - {t("transcodes")} - - - - } - label={t("transcodesCount", { - value: props.statistics.totalTranscodeCount, - })} - /> - - - } - label={t("healthChecksCount", { - value: props.statistics.totalHealthCheckCount, - })} - /> - - - } - label={t("filesCount", { - value: props.statistics.totalFileCount, - })} - /> - - - } - label={t("savedSpace", { - value: humanFileSize(Math.floor(allLibs.savedSpace)), - })} - /> - - - - - {t("healthChecks")} - + + + + + + - - - - {t("videoCodecs")} - - - - {t("videoContainers")} - - - - {t("videoResolutions")} - + + + + + + - +
); } +interface StatisticRingProgressProps { + items: TdarrPieSegment[]; + label: string; +} + +const StatisticRingProgress = ({ items, label }: StatisticRingProgressProps) => { + return ( + + + {label} + + + + ); +}; + function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] { const total = segments.reduce((prev, curr) => prev + curr.value, 0); return segments.map((segment, index) => ({ @@ -115,26 +80,22 @@ function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps[ })); } -interface StatBoxProps { - icon: react.ReactNode; +interface StatisticItemProps { + icon: TablerIcon; + value: string | number; label: string; } -function StatBox(props: StatBoxProps) { - const { colorScheme } = useMantineColorScheme(); +function StatisticItem(props: StatisticItemProps) { + const board = useRequiredBoard(); return ( - ({ - padding: theme.spacing.xs, - border: "1px solid", - borderRadius: theme.radius.md, - borderColor: colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1], - })} - > - - {props.icon} - {props.label} - - + + + + + {props.value} + + + ); } diff --git a/packages/widgets/src/media-transcoding/panels/workers.panel.tsx b/packages/widgets/src/media-transcoding/panels/workers.panel.tsx index 961ac5d7c..d1602c248 100644 --- a/packages/widgets/src/media-transcoding/panels/workers.panel.tsx +++ b/packages/widgets/src/media-transcoding/panels/workers.panel.tsx @@ -1,4 +1,16 @@ -import { Center, Group, Progress, ScrollArea, Table, Text, Title, Tooltip } from "@mantine/core"; +import { + Center, + Group, + Progress, + ScrollArea, + Table, + TableTd, + TableTh, + TableTr, + Text, + Title, + Tooltip, +} from "@mantine/core"; import { IconHeartbeat, IconTransform } from "@tabler/icons-react"; import type { TdarrWorker } from "@homarr/integrations"; @@ -6,6 +18,7 @@ import { useI18n } from "@homarr/translation/client"; interface WorkersPanelProps { workers: TdarrWorker[]; + isTiny: boolean; } export function WorkersPanel(props: WorkersPanelProps) { @@ -14,7 +27,7 @@ export function WorkersPanel(props: WorkersPanelProps) { if (props.workers.length === 0) { return (
- {t("empty")} + {t("empty")}
); } @@ -23,52 +36,71 @@ export function WorkersPanel(props: WorkersPanelProps) { - - - - - + + + + {t("table.file")} + + + + + {t("table.eta")} + + + + + {t("table.progress")} + + + - {props.workers.map((worker) => ( - - - - - - ))} + {Math.round(worker.percentage)}% + + + + ); + })}
{t("table.file")}{t("table.eta")}{t("table.progress")}
- -
- {worker.jobType === "transcode" ? ( - - - - ) : ( - - - + {props.workers.map((worker) => { + const fileName = worker.filePath.split("\\").pop()?.split("/").pop() ?? worker.filePath; + return ( + + + +
+ {worker.jobType === "transcode" ? ( + + + + ) : ( + + + + )} +
+ + {fileName} + +
+
+ + {worker.ETA.startsWith("0:") ? worker.ETA.substring(2) : worker.ETA} + + + + {!props.isTiny && ( + <> + {worker.step} + + )} -
- - {worker.filePath.split("\\").pop()?.split("/").pop() ?? worker.filePath} - -
-
- {worker.ETA.startsWith("0:") ? worker.ETA.substring(2) : worker.ETA} - - - {worker.step} - - {Math.round(worker.percentage)}% - -
diff --git a/packages/widgets/src/minecraft/server-status/component.tsx b/packages/widgets/src/minecraft/server-status/component.tsx index 5ae024714..0d24784f7 100644 --- a/packages/widgets/src/minecraft/server-status/component.tsx +++ b/packages/widgets/src/minecraft/server-status/component.tsx @@ -36,7 +36,7 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent > - + {title} @@ -44,11 +44,13 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent {data.online && ( <> - {`minecraft + {!options.isBedrockServer && ( + {`minecraft + )} diff --git a/packages/widgets/src/modals/widget-advanced-options-modal.tsx b/packages/widgets/src/modals/widget-advanced-options-modal.tsx index 674bc8f9c..f957b100a 100644 --- a/packages/widgets/src/modals/widget-advanced-options-modal.tsx +++ b/packages/widgets/src/modals/widget-advanced-options-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Group, Stack } from "@mantine/core"; +import { Button, CloseButton, ColorInput, Group, Stack, useMantineTheme } from "@mantine/core"; import { useForm } from "@homarr/form"; import { createModal } from "@homarr/modals"; @@ -15,6 +15,7 @@ interface InnerProps { export const WidgetAdvancedOptionsModal = createModal(({ actions, innerProps }) => { const t = useI18n(); + const theme = useMantineTheme(); const form = useForm({ initialValues: innerProps.advancedOptions, }); @@ -30,6 +31,18 @@ export const WidgetAdvancedOptionsModal = createModal(({ actions, in label={t("item.edit.field.customCssClasses.label")} {...form.getInputProps("customCssClasses")} /> + color[6])} + rightSection={ + form.setFieldValue("borderColor", "")} + style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }} + /> + } + {...form.getInputProps("borderColor")} + />