Compare commits

..
Author SHA1 Message Date
ajnart 79269aa055 fix: align data table scrollbars 2026-08-23 01:44:06 +02:00
ajnart 94e784f81f fix: cover all widget scroll areas 2026-08-23 01:16:00 +02:00
ajnart 60ffc34e73 fix: unify widget scroll areas 2026-08-23 01:09:02 +02:00
83 changed files with 721 additions and 1143 deletions

No files matched your search

@@ -0,0 +1,60 @@
---
name: homarr-local-harness
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
---
# Homarr Local Harness
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
## Set up the harness
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
```sh
pnpm cli harness setup feature-board
```
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
- `DEMO_MODE=true`
- `DEMO_READ_ONLY=false`
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
Pass repeatable environment overrides when the feature needs them:
```sh
pnpm cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
Always use the URL printed by the command. If the output is no longer available, query it directly:
```sh
pnpm cli harness port feature-board
```
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
## Browser validation
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
```sh
agent-browser skills get core
```
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
## Cleanup
Stop the named container when finished:
```sh
pnpm cli harness stop feature-board
```
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
@@ -0,0 +1,4 @@
interface:
display_name: "Homarr Local Harness"
short_description: "Build and browse a writable local Homarr branch harness"
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
@@ -198,13 +198,9 @@ In the modal you can edit the app that you want to display:
<figure>
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
<figcaption>
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
</figcaption>
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
</figure>
When adding a widget, set its initial grid width and height directly in the preview. These size controls are only shown before the widget is added.
As soon as you save your changes, the tile on the board will refresh and display your app.
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
Press and hold a handle, move the pointer, and release it when the tile has the size you want.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 68 KiB

@@ -16,7 +16,7 @@ import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalGridSize,
getLogicalTrackSize,
LOGICAL_GRID_GAP,
getRootSectionForLane,
} from "~/components/board/layout";
@@ -61,16 +61,16 @@ export const ClientBoard = () => {
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0)
.map(getLogicalGridSize);
.map(getLogicalTrackSize);
const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
const gridTemplateColumns = [
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
`${getLogicalGridSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
`${getLogicalTrackSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
]
.filter((value) => value !== null)
.join(" ");
@@ -111,6 +111,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const sortedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
return (
<SectionCard title={tBoard("setting.section.layout.title")}>
@@ -126,25 +129,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
disabled={nextBreakpoint === null || !baseLayout}
onClick={() => {
if (nextBreakpoint === null || !baseLayout) return;
const newLayout: FormValues["layouts"][number] = {
id: createId(),
name: tBoard("setting.section.layout.custom.defaultName"),
columnCount: baseLayout.columnCount,
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
breakpoint: nextBreakpoint,
role: "custom",
};
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
form.insertListItem("layouts", newLayout, insertionIndex);
form.setFieldValue("layouts", [
...form.values.layouts,
{
id: createId(),
name: tBoard("setting.section.layout.custom.defaultName"),
columnCount: baseLayout.columnCount,
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
breakpoint: nextBreakpoint,
role: "custom",
},
]);
}}
>
{tBoard("setting.section.layout.responsive.action.add")}
</Button>
</Group>
{form.values.layouts.map((layout, index) => {
{sortedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
const customBreakpoints = form.values.layouts
@@ -1,7 +1,5 @@
"use client";
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
import { use, useCallback, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
@@ -73,7 +71,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
),
integrationSupport: hasIntegrationSupport,
settings,
previewDimensions: dimensions,
},
{
title(translate) {
@@ -89,10 +86,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
} finally {
setIsEditorLoading(false);
}
}, [currentDefinition, dimensions, kind, openWidgetEditModal, settings, state, t, utils]);
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
const toggleEditMode = useCallback(() => {
setEditMode((currentEditMode) => !currentEditMode);
setEditMode((editMode) => !editMode);
showSuccessNotification({
message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"),
});
@@ -1,14 +1,8 @@
import { Suspense } from "react";
import { notFound, redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { Center, Loader } from "@mantine/core";
import { getHomeBoardAsync } from "@homarr/api/board-server";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import { BoardPreviewProvider } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions";
import { ModalProvider } from "@homarr/modals";
import { widgetKinds } from "@homarr/widgets/manifest";
import { WidgetPreviewPageContent } from "./_content";
@@ -22,24 +16,12 @@ export default async function WidgetPreview(props: Props) {
if (!widgetKinds.includes(kind as WidgetKind)) {
notFound();
}
const session = await auth();
if (!session) {
redirect(`/auth/login?callbackUrl=${encodeURIComponent(`/widgets/${kind}`)}`);
}
const [integrations, board] = await Promise.all([getIntegrationsWithPermissionsAsync(session), getHomeBoardAsync()]);
return (
<IntegrationProvider integrations={integrations}>
<BoardPreviewProvider board={board}>
<ModalProvider>
<Center h="100vh">
<Suspense fallback={<Loader size="sm" />}>
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
</Suspense>
</Center>
</ModalProvider>
</BoardPreviewProvider>
</IntegrationProvider>
<Center h="100vh">
<Suspense fallback={<Loader size="sm" />}>
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
</Suspense>
</Center>
);
}
@@ -184,6 +184,10 @@
min-width: 0;
}
.widgetPanel[data-editing] .panelActions {
padding-inline-end: 2.75rem;
}
.panelHeader {
min-width: 0;
min-height: 3.75rem;
@@ -1,8 +1,17 @@
.badge {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity));
--border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity));
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity));
--border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity));
}
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.settingsButton {
position: absolute;
top: -34px;
right: 14px;
z-index: 201;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
@@ -49,11 +58,3 @@
transition: none;
}
}
@media (max-width: 768px) {
.settingsButton {
right: 4px;
opacity: 1;
pointer-events: auto;
}
}
@@ -1,8 +1,9 @@
import type { ComponentType, CSSProperties, MutableRefObject, PropsWithChildren } from "react";
import type { ComponentType, CSSProperties, MutableRefObject } from "react";
import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import dynamic from "next/dynamic";
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
import type { CardProps } from "@mantine/core";
import { Badge, Box, Button, Card, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
@@ -23,11 +24,9 @@ import {
} from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { WidgetCardShell, WidgetTitleBadge } from "@homarr/widgets/widget-card-shell";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { getLogicalTrackSize } from "~/components/board/layout";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
@@ -35,6 +34,7 @@ import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
import { redirectShiftWheel } from "../advanced-focus/wheel";
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { WidgetContextMenu } from "./widget-context-menu";
@@ -48,28 +48,47 @@ interface BoardItemContentProps {
item: SectionItem;
}
type BoardItemCardProps = PropsWithChildren<{
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" } as const;
if (hasCustomCssClasses) return {};
return { overflowX: "hidden", overflowY: "auto" } as const;
};
type BoardItemCardProps = CardProps & {
item: SectionItem;
innerRef: (element: HTMLDivElement | null) => void;
}>;
};
const BoardItemCard = ({ item, innerRef, children }: BoardItemCardProps) => {
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
const board = useRequiredBoard();
return (
<WidgetCardShell
innerRef={innerRef}
kind={item.kind}
advancedOptions={item.advancedOptions}
opacity={board.opacity / 100}
<Card
{...cardProps}
ref={innerRef}
w="100%"
h="100%"
data-grid-item-content
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"board-grid-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
{children}
</WidgetCardShell>
</Card>
);
};
@@ -148,7 +167,6 @@ const LoadedBoardItemContent = ({
const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null);
const board = useRequiredBoard();
const boardCanvasScale = useBoardCanvasScale();
const t = useI18n();
const [isEditMode] = useEditMode();
const settings = useSettings();
@@ -162,10 +180,6 @@ const LoadedBoardItemContent = ({
const { width: viewportWidth, height: viewportHeight } = viewportSize;
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = getWidgetName(item.kind, t);
const previewDimensions = useMemo(
() => ({ width, height, scale: boardCanvasScale }),
[boardCanvasScale, height, width],
);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`;
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
@@ -277,11 +291,8 @@ const LoadedBoardItemContent = ({
: undefined;
const widgetCard = (
<WidgetCardShell
innerRef={cardRef}
kind={item.kind}
advancedOptions={item.advancedOptions}
opacity={isAdvanced ? 0.98 : board.opacity / 100}
<Card
ref={cardRef}
id={isPreview ? advancedViewId : undefined}
role={isPreview ? "region" : undefined}
aria-label={isPreview ? advancedViewLabel : undefined}
@@ -292,9 +303,21 @@ const LoadedBoardItemContent = ({
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"board-grid-item-content",
isPreview && advancedFocusClasses.surface,
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
item.advancedOptions.customCssClasses.join(" "),
)}
styles={{
root: {
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
containerType: "size",
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
style={previewStyle}
>
<Box ref={contentRef} w="100%" h="100%" mih={0}>
@@ -322,16 +345,15 @@ const LoadedBoardItemContent = ({
/>
</ErrorBoundary>
</Box>
</WidgetCardShell>
</Card>
);
return (
<>
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} previewDimensions={previewDimensions} />}
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} />}
<WidgetContextMenu
item={item}
definition={definition}
previewDimensions={previewDimensions}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
@@ -396,12 +418,25 @@ const LoadedBoardItemContent = ({
)}
</Box>
</WidgetContextMenu>
{!isAdvanced && (
<WidgetTitleBadge
advancedOptions={item.advancedOptions}
opacity={board.opacity / 100}
{!isAdvanced && item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
style={{ zIndex: "var(--mantine-z-index-app)" }}
top={2}
left={16}
size="xs"
radius={board.itemRadius}
/>
styles={{
root: {
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
"--opacity": board.opacity / 100,
},
}}
className={itemContentClasses.badge}
c="var(--mantine-color-text)"
>
{item.advancedOptions.title}
</Badge>
)}
</>
);
@@ -21,7 +21,6 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
interface BoardItemMenuProps {
item: SectionItem;
definition: WidgetDefinition;
previewDimensions: { width: number; height: number; scale?: number };
resetErrorBoundary?: () => void;
}
@@ -32,7 +31,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
return <BoardItemMenuInner {...props} />;
};
const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBoundary }: BoardItemMenuProps) => {
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false);
@@ -90,7 +89,6 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
integrationSupport: "supportedIntegrations" in definition,
settings,
itemId: item.id,
previewDimensions,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
@@ -108,6 +106,10 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
variant="default"
size={24}
radius="sm"
pos="absolute"
top={4}
right={4}
style={{ zIndex: 30 }}
className={itemContentClasses.settingsButton}
data-menu-open={isMenuOpen || undefined}
data-board-widget-settings
@@ -30,7 +30,6 @@ import {
getIntegrationName,
getWidgetName,
getRootSectionLane,
widgetDefaultSizes,
widgetIntegrationSupport,
widgetKinds,
widgetKindsWithOptionalIntegrations,
@@ -48,7 +47,6 @@ import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { getSectionGridColumnCount } from "../sections/grid/section-grid-placements";
import { useItemActions } from "./item-actions";
import type { WidgetConnectionStatus } from "./item-select-data";
import {
@@ -78,8 +76,6 @@ export interface WidgetItemData {
}
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
const defaultWidgetSize = { width: 1, height: 1 };
const maximumWidgetHeight = 64;
const ItemSelectModalContent = ({
actions,
@@ -128,13 +124,6 @@ const ItemSelectModalContent = ({
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
? targetSectionId
: (placementOptions[0]?.value ?? null);
const maximumWidgetSize = useMemo(() => {
if (!effectiveSectionId) return defaultWidgetSize;
return {
width: Math.max(1, getSectionGridColumnCount(board, currentLayoutId, effectiveSectionId) ?? 1),
height: maximumWidgetHeight,
};
}, [board, currentLayoutId, effectiveSectionId]);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false);
@@ -257,7 +246,6 @@ const ItemSelectModalContent = ({
try {
const definition = await loadWidgetDefinition("customApi");
const itemId = createId();
const initialSize = widgetDefaultSizes.customApi ?? defaultWidgetSize;
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const options = {
...defaultOptions,
@@ -275,7 +263,7 @@ const ItemSelectModalContent = ({
options,
integrationIds: [],
},
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }, selectedSize) => {
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
const updatedBoard = createItem({
id: itemId,
kind: "customApi",
@@ -283,14 +271,12 @@ const ItemSelectModalContent = ({
options: configuredOptions,
targetSectionId: effectiveSectionId ?? undefined,
advancedOptions,
size: selectedSize ?? initialSize,
});
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
},
integrationData: [],
integrationSupport: false,
settings,
previewResize: { initialSize, maximumSize: maximumWidgetSize },
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
@@ -325,7 +311,6 @@ const ItemSelectModalContent = ({
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const initialSize = widgetDefaultSizes[kind] ?? defaultWidgetSize;
const openEditor = (
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
) => {
@@ -341,7 +326,7 @@ const ItemSelectModalContent = ({
options: defaultOptions,
integrationIds: selectedIntegrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }, selectedSize) => {
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
const updatedBoard = createItem({
id: itemId,
kind,
@@ -349,7 +334,6 @@ const ItemSelectModalContent = ({
integrationIds: newIntegrationIds,
targetSectionId: effectiveSectionId ?? undefined,
advancedOptions,
size: selectedSize ?? initialSize,
});
if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return;
trackSetup("widget-completed", {
@@ -362,7 +346,6 @@ const ItemSelectModalContent = ({
integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
previewResize: { initialSize, maximumSize: maximumWidgetSize },
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
@@ -1,6 +1,5 @@
"use client";
import type { PropsWithChildren } from "react";
import { Suspense, use, useCallback, useMemo, useState } from "react";
import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary";
@@ -8,49 +7,16 @@ import type { FallbackProps } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps, WidgetEditModalSize } from "@homarr/widgets/modals";
import { loadWidgetComponent } from "@homarr/widgets/manifest";
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
import { AssistantContext, useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
import { getLogicalTrackSize } from "~/components/board/layout";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
const defaultWidgetSize = { width: 1, height: 1 };
const ignoreAssistantAction = () => undefined;
const ignoreAssistantPrompt = () => false;
const ignoreAssistantRefresh = () => Promise.resolve();
const getPreviewDimensions = (size: WidgetEditModalSize) => ({
width: getLogicalTrackSize(size.width),
height: getLogicalTrackSize(size.height),
});
const PreviewRuntimeWrapper = ({ children }: PropsWithChildren) => {
const assistant = useOptionalHomarrAssistant();
const previewAssistant = useMemo(() => {
if (!assistant) return null;
return {
...assistant,
open: ignoreAssistantAction,
close: ignoreAssistantAction,
toggle: ignoreAssistantAction,
sendPrompt: ignoreAssistantPrompt,
refreshCurrentView: ignoreAssistantRefresh,
setWidgetVisible: ignoreAssistantAction,
activateWidget: ignoreAssistantAction,
};
}, [assistant]);
if (!previewAssistant) return children;
return <AssistantContext.Provider value={previewAssistant}>{children}</AssistantContext.Provider>;
};
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
export const loadWidgetEditModal = () => {
@@ -117,7 +83,6 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal());
const { default: PreviewComponent } = use(loadWidgetComponent(props.innerProps.kind));
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const Component = WidgetEditModal.component;
const utils = clientApi.useUtils();
@@ -149,18 +114,6 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
return [...existing, ...additional];
}, [props.innerProps.integrationData, createdIntegrations]);
const previewDimensions = useMemo(() => {
if (props.innerProps.previewDimensions) return props.innerProps.previewDimensions;
const size = widgetDefaultSizes[props.innerProps.kind] ?? defaultWidgetSize;
return {
width: getLogicalTrackSize(size.width),
height: getLogicalTrackSize(size.height),
};
}, [props.innerProps.kind, props.innerProps.previewDimensions]);
let previewResize = props.innerProps.previewResize;
if (previewResize) {
previewResize = { ...previewResize, getDimensions: getPreviewDimensions };
}
return (
<Component
@@ -169,10 +122,6 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
...props.innerProps,
integrationData: combinedIntegrationData,
integrationEditForm: LazyIntegrationEditForm,
previewComponent: PreviewComponent,
previewDimensions,
previewResize,
previewWrapper: PreviewRuntimeWrapper,
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
}}
/>
@@ -208,10 +157,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
defaultTitle(t) {
return t("item.edit.title");
},
size: "100rem",
transitionProps: {
transition: "pop",
duration: 180,
},
size: modalSizeForm,
closeOnClickOutside: false,
});
@@ -35,7 +35,6 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
interface WidgetContextMenuProps {
item: SectionItem;
definition: WidgetDefinition;
previewDimensions: { width: number; height: number; scale?: number };
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>;
@@ -45,7 +44,6 @@ interface WidgetContextMenuProps {
export const WidgetContextMenu = ({
item,
definition,
previewDimensions,
widgetRuntimeRef,
sourceRef,
children,
@@ -162,7 +160,7 @@ export const WidgetContextMenu = ({
definition,
value: {
advancedOptions: item.advancedOptions,
options,
options: item.options,
integrationIds: item.integrationIds,
},
onSuccessfulEdit: (editResult) => {
@@ -191,9 +189,6 @@ export const WidgetContextMenu = ({
),
integrationSupport: definition.supportedIntegrations !== undefined,
settings,
itemId: item.id,
boardId: board.id,
previewDimensions,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -202,15 +197,12 @@ export const WidgetContextMenu = ({
},
);
}, [
board.id,
definition,
integrationData,
item,
handleRefetch,
openModal,
options,
persistBoard,
previewDimensions,
settings,
updateItemAdvancedOptions,
updateItemIntegrations,
@@ -31,7 +31,7 @@
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 40px 4px 8px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
@@ -2,16 +2,17 @@
The board uses fixed logical units:
- one grid track has a `212 × 212` logical-pixel footprint;
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter;
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset);
- one `1 × 1` item is exactly `200 × 200` logical pixels;
- the gap matches Mantine's `sm` spacing at `12` logical pixels, giving a `212` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `412 × 200`);
- the canvas is zoomed once with one uniform scale derived from its available width.
Grid footprints keep the same logical dimensions at every viewport. Widget cards
use a scale-compensated inset so their painted size and content measurements
match the legacy responsive grid. The visual canvas always fits the available
width, every root lane keeps at least one viewport of height, and edit-mode
previews extend it downward as needed.
Items keep the same logical dimensions at every viewport. The viewport reserves
the painted size while the inner canvas remains at its logical dimensions, so a
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
stable. The visual canvas always fits the available width, every root lane
keeps at least one viewport of height, and edit-mode previews extend it downward
as needed.
The read-only renderer should:
@@ -2,7 +2,7 @@ import type { BoardLane } from "@homarr/definitions";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
import { getLogicalGridSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
import { getCollapsedDisplayLayout } from "./reflow";
import type { GridPlacement } from "./types";
@@ -90,5 +90,5 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
return [getLayoutRowCount(displayPlacements)];
});
return getLogicalGridSize(Math.max(0, ...rowCounts));
return getLogicalTrackSize(Math.max(0, ...rowCounts));
};
@@ -10,23 +10,12 @@ export const getLogicalTrackSize = (trackCount: number) => {
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
};
/**
* Returns the complete footprint occupied by grid tracks. Cards are inset
* inside this footprint so their visual gutters stay fixed while the canvas
* is scaled.
*/
export const getLogicalGridSize = (trackCount: number) => {
const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount"));
return normalizedTrackCount * LOGICAL_GRID_PITCH;
};
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
position: "absolute",
left: x * LOGICAL_GRID_PITCH,
top: y * LOGICAL_GRID_PITCH,
width: getLogicalGridSize(w),
height: getLogicalGridSize(h),
width: getLogicalTrackSize(w),
height: getLogicalTrackSize(h),
});
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
@@ -36,7 +25,7 @@ export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
return Math.max(1, Math.ceil(visualHeight / visualScale / LOGICAL_GRID_PITCH));
return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
};
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
@@ -8,7 +8,7 @@ import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/ite
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalGridSize,
getLogicalTrackSize,
getRootSectionForLane,
} from "../index";
@@ -131,6 +131,6 @@ describe("board lanes", () => {
},
];
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalGridSize(4));
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalTrackSize(4));
});
});
@@ -3,28 +3,24 @@ import { describe, expect, test } from "vitest";
import {
getGridRowCountForVisualHeight,
getLayoutRowCount,
getLogicalGridSize,
getLogicalItemStyle,
getLogicalTrackSize,
normalizeGridPlacement,
} from "../index";
describe("fixed dashboard geometry", () => {
test("separates content track sizes from complete grid footprints", () => {
test("keeps a 1x1 item exactly 200px with a fixed 12px gap", () => {
expect(getLogicalTrackSize(1)).toBe(200);
expect(getLogicalTrackSize(0.5)).toBe(94);
expect(getLogicalTrackSize(2)).toBe(412);
expect(getLogicalTrackSize(3)).toBe(624);
expect(getLogicalGridSize(1)).toBe(212);
expect(getLogicalGridSize(2)).toBe(424);
expect(getLogicalGridSize(3)).toBe(636);
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
position: "absolute",
left: 212,
top: 424,
width: 424,
height: 636,
width: 412,
height: 624,
});
});
@@ -1,4 +1,4 @@
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import type { GridResizeDirection } from "./types";
@@ -38,7 +38,7 @@ export const getSnappedGridCoordinates = ({
target: RectangleLike;
columnCount: number;
}) => {
const logicalWidth = getLogicalGridSize(columnCount);
const logicalWidth = getLogicalTrackSize(columnCount);
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
@@ -39,7 +39,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import {
beginGridTransaction,
cancelGridTransaction,
@@ -792,8 +792,8 @@ export default function GridEditor({
data-grid-depth={depth}
style={
{
width: getLogicalGridSize(columnCount),
height: getLogicalGridSize(rowCount),
width: getLogicalTrackSize(columnCount),
height: getLogicalTrackSize(rowCount),
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
} as CSSProperties
}
@@ -1234,8 +1234,8 @@ const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGrid
if (!element) return null;
const rectangle = element.getBoundingClientRect();
const scale = {
x: rectangle.width / getLogicalGridSize(placement.w),
y: rectangle.height / getLogicalGridSize(placement.h),
x: rectangle.width / getLogicalTrackSize(placement.w),
y: rectangle.height / getLogicalTrackSize(placement.h),
};
return Number.isFinite(scale.x) && scale.x > 0 && Number.isFinite(scale.y) && scale.y > 0 ? scale : null;
};
@@ -1,4 +1,4 @@
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
export interface GridPreviewDomState {
@@ -75,8 +75,8 @@ export const clearGridPreviewGeometry = (
const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => {
element.style.setProperty("--board-grid-preview-x", `${(preview.x - controlled.x) * LOGICAL_GRID_PITCH}px`);
element.style.setProperty("--board-grid-preview-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`);
element.style.setProperty("--board-grid-preview-width", `${getLogicalGridSize(preview.w)}px`);
element.style.setProperty("--board-grid-preview-height", `${getLogicalGridSize(preview.h)}px`);
element.style.setProperty("--board-grid-preview-width", `${getLogicalTrackSize(preview.w)}px`);
element.style.setProperty("--board-grid-preview-height", `${getLogicalTrackSize(preview.h)}px`);
element.setAttribute("data-grid-preview", "true");
setPlacementAttributes(element, preview);
};
@@ -3,7 +3,7 @@
import type { RefObject } from "react";
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
import { getLayoutRowCount, getLogicalItemStyle, getLogicalGridSize } from "~/components/board/layout";
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
import type { TransactionalGridState } from "./dnd";
import type { GridInteractionStore } from "./grid-interaction-store";
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
@@ -87,7 +87,7 @@ export const GridPreviewLayer = ({
} else {
delete grid.dataset.dndPreviewRevision;
}
grid.style.height = `${getLogicalGridSize(renderedRowCount)}px`;
grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
const viewport = grid.parentElement;
if (viewport?.hasAttribute("data-section-id")) {
@@ -98,7 +98,7 @@ export const GridPreviewLayer = ({
// stray value would silently blow the cap open for the rest of the session, well after any
// drag ended, since nothing else would ever clear it back out.
if (maxRowCount === null) {
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalGridSize(renderedRowCount)}px`);
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
} else {
viewport.style.removeProperty("--board-grid-drag-height");
}
@@ -1,4 +1,4 @@
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
interface ResizeOutlinePreview {
@@ -31,8 +31,8 @@ export const createGridResizeOutlineController = (
outline.style.position = "absolute";
outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`;
outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`;
outline.style.width = `${getLogicalGridSize(preview.placement.w)}px`;
outline.style.height = `${getLogicalGridSize(preview.placement.h)}px`;
outline.style.width = `${getLogicalTrackSize(preview.placement.w)}px`;
outline.style.height = `${getLogicalTrackSize(preview.placement.h)}px`;
outline.dataset.gridResizeValid = String(preview.valid);
};
@@ -50,10 +50,6 @@
overflow: visible;
}
.staticItem[data-type="item"] > .contentMount {
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
}
.staticItem:has([data-board-container-label]) {
z-index: 2;
}
@@ -14,7 +14,7 @@ import {
getEditableCanvasAttributes,
getGridRowCountForVisualHeight,
getLayoutRowCount,
getLogicalGridSize,
getLogicalTrackSize,
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
@@ -138,9 +138,9 @@ export const SectionGrid = ({
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
const logicalWidth = getLogicalGridSize(columnCount);
const logicalHeight = getLogicalGridSize(rowCount);
const viewportHeight = getLogicalGridSize(viewportRowCount);
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const viewportHeight = getLogicalTrackSize(viewportRowCount);
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getCollapsedDisplayLayout, getLogicalGridSize } from "../../../layout";
import { getCollapsedDisplayLayout, LOGICAL_GRID_CELL_SIZE } from "../../../layout";
import { FixedGridItem } from "../fixed-grid-item";
const mocks = vi.hoisted(() => ({
@@ -250,14 +250,14 @@ describe("fixed grid item behavior", () => {
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull();
});
test("renders a view-only 1x1 card at the complete grid footprint", () => {
test("renders a view-only 1x1 card at the fixed 200px logical size", () => {
mocks.editMode = false;
renderWeather(root);
const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]');
expect(item).not.toBeNull();
expect(item?.style.width).toBe(`${getLogicalGridSize(1)}px`);
expect(item?.style.height).toBe(`${getLogicalGridSize(1)}px`);
expect(item?.style.width).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
expect(item?.style.height).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
expect(item?.getAttribute("role")).toBeNull();
act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test } from "vitest";
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
@@ -65,8 +65,8 @@ describe("grid preview geometry", () => {
expect(active?.getAttribute("data-grid-preview")).toBe("true");
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe(`${LOGICAL_GRID_PITCH}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe(`${LOGICAL_GRID_PITCH}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalGridSize(3)}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalGridSize(4)}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalTrackSize(3)}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalTrackSize(4)}px`);
expect(active?.dataset.gridW).toBe("3");
expect(active?.dataset.gridH).toBe("4");
});
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { getLogicalGridSize } from "~/components/board/layout";
import { getLogicalTrackSize } from "~/components/board/layout";
import { createGridResizeOutlineController } from "../grid-resize-outline";
@@ -30,8 +30,8 @@ describe("grid resize outline", () => {
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
expect(outline?.style.left).toBe("0px");
expect(outline?.style.top).toBe("0px");
expect(outline?.style.width).toBe(`${getLogicalGridSize(1)}px`);
expect(outline?.style.height).toBe(`${getLogicalGridSize(2)}px`);
expect(outline?.style.width).toBe(`${getLogicalTrackSize(1)}px`);
expect(outline?.style.height).toBe(`${getLogicalTrackSize(2)}px`);
expect(outline?.dataset.gridResizeValid).toBe("false");
controller.schedule(null);
+3 -24
View File
@@ -10,16 +10,12 @@ import { clientApi } from "@homarr/api/client";
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection";
import { updateBoardName } from "./updater";
type Board = RouterOutputs["board"]["getBoardByName"];
interface BoardContextValue {
board: Board;
const BoardContext = createContext<{
board: RouterOutputs["board"]["getBoardByName"];
layoutOverrideId: string | null;
currentLayout: string;
initialViewportWidth: number;
}
const BoardContext = createContext<BoardContextValue | null>(null);
} | null>(null);
const subscribeToViewport = (onStoreChange: () => void) => {
if (typeof window === "undefined") return () => undefined;
@@ -41,23 +37,6 @@ const subscribeToViewport = (onStoreChange: () => void) => {
};
};
export const BoardPreviewProvider = ({ children, board }: PropsWithChildren<{ board: Board }>) => {
const currentLayout = board.layouts[0]?.id ?? "";
return (
<BoardContext.Provider
value={{
board,
layoutOverrideId: null,
currentLayout,
initialViewportWidth: 0,
}}
>
{children}
</BoardContext.Provider>
);
};
export const BoardProvider = ({
children,
initialBoard,
+4 -12
View File
@@ -1904,7 +1904,8 @@
}
},
"widgetQuery": {
"loading": "Loading widget data"
"loading": "Loading widget data",
"stale": "Could not refresh {widget}; showing saved data"
},
"total": "Total",
"filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}",
@@ -2082,12 +2083,10 @@
"label": "Section or rail"
},
"width": {
"label": "Width",
"shortLabel": "W"
"label": "Width"
},
"height": {
"label": "Height",
"shortLabel": "H"
"label": "Height"
},
"xOffset": {
"label": "X offset"
@@ -2116,13 +2115,6 @@
"loading": "Loading integration",
"loadError": "This integration could not be loaded. Try again before changing its connection settings."
},
"preview": {
"integrationRequired": "Choose a connection to preview this widget.",
"size": "Size"
},
"settings": {
"label": "Widget settings"
},
"advancedOptions": {
"label": "Advanced options",
"title": "Advanced item options"
@@ -49,7 +49,7 @@ import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectableCard } from "../components/selectable-card";
import { selectGridCols } from "../select-grid-layout";
import { buildSortedIntegrations, filterIntegrations } from "./integration-grid-shared";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
import type { IntegrationGridItem } from "./integration-grid-shared";
export interface IntegrationSelectGridProps {
@@ -134,11 +134,8 @@ const IntegrationCard = ({
onClick={() => onSelect(integration.kind)}
aria-label={integration.name}
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
title={
<Text fw={700} size="md" style={{ whiteSpace: "nowrap" }}>
{integration.name}
</Text>
}
title={integration.name}
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />}
footerLeft={
connectedCount > 0 ? (
<Badge variant="light" color="teal" size="xs" radius="xs">
@@ -235,3 +232,24 @@ const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; lim
</Group>
);
};
const IntegrationCategoryBadges = ({
categories,
limit,
}: {
categories: IntegrationGridItem["categories"];
limit?: number;
}) => {
const t = useI18n();
const items = limit ? categories.slice(0, limit) : categories;
return (
<Group gap={4} wrap="wrap">
{items.map((category) => (
<Badge key={category} variant="light" size="xs" color="gray" radius="xs">
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
</Badge>
))}
</Group>
);
};
+1 -2
View File
@@ -30,8 +30,7 @@
"./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts",
"./refetch-intervals": "./src/refetch-intervals.ts",
"./widget-card-shell": "./src/widget-card-shell.tsx"
"./refetch-intervals": "./src/refetch-intervals.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -11,7 +11,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
import {
@@ -28,6 +28,7 @@ import {
} from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
const t = useI18n("widget.airQuality");
const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude,
longitude: options.location.longitude,
@@ -44,6 +45,11 @@ export default function AirQualityWidget({ options, width, height, displayMode }
) : (
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
)}
{query.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={query.error} label={t("name")} />
</Box>
)}
</Box>
);
}
@@ -14,6 +14,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import actionTargetClasses from "../common/action-target.module.css";
import { storedContentToPlainText } from "./content";
@@ -238,6 +239,7 @@ const AnchorNoteWidgetContent = ({
)}
</Stack>
<Group gap="xs" wrap="nowrap">
<WidgetQueryErrorIndicator error={noteQuery.error} label={t("name")} />
{(isEditing || canEdit) && (
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
{isEditing ? (
+7 -1
View File
@@ -16,7 +16,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
@@ -24,6 +24,7 @@ import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
const tCommon = useI18n("common");
const tApp = useI18n("widget.app");
const settings = useSettings();
const board = useRequiredBoard();
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
@@ -126,6 +127,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
</Suspense>
) : null}
</AppLink>
{appQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={appQuery.error} label={tApp("name")} />
</Box>
)}
</Box>
);
}
@@ -8,6 +8,7 @@ import { getIconUrl } from "@homarr/definitions";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
export default function ArchiveTeamWarriorWidget({
integrationIds,
@@ -49,7 +50,7 @@ const ArchiveTeamWarriorWidgetContent = ({
const t = useI18n("widget.archiveTeamWarrior");
const tWidgetCommon = useI18n("widget.common");
const locale = useCurrentIntlLocale();
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const [data, statusQuery] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const status = data.status;
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
@@ -73,6 +74,7 @@ const ArchiveTeamWarriorWidgetContent = ({
</Group>
<Group gap={4} wrap="nowrap">
<WidgetQueryErrorIndicator error={statusQuery.error} label={t("name")} />
<Badge size="sm" color={getStatusColor(statusKey)}>
{t(`status.${statusKey}`)}
</Badge>
+10 -1
View File
@@ -6,9 +6,11 @@ import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { formatBytes, formatDuration } from "@homarr/common";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { AudioStatsContent } from "./audio-stats-content";
@@ -21,6 +23,8 @@ export default function AudioStatsWidget({
displayMode = "compact",
widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) {
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 };
@@ -52,6 +56,9 @@ export default function AudioStatsWidget({
if (displayMode === "compact") {
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
</Box>
{summary}
</Box>
);
@@ -66,8 +73,10 @@ export default function AudioStatsWidget({
return (
<Stack h="100%" gap="lg" p="md">
<div style={{ minHeight: 150 }}>{summary}</div>
{hasStreamErrors && (
{(statsError || streamsError || hasStreamErrors) && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
<WidgetQueryErrorIndicator error={streamsError} label={tMediaServer("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
@@ -28,6 +28,7 @@ import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { getBeszelAlertsQueryInput } from "./display";
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
@@ -125,6 +126,7 @@ export default function BeszelAlertsWidget({
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={alertsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
</Box>
@@ -52,6 +52,7 @@ import {
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { isBeszelGridMetricVisible } from "./display";
@@ -459,6 +460,7 @@ export default function BeszelSystemGridWidget({
displayMode,
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useI18n("widget.beszel");
const tFeature = useI18n("widget.beszelSystemGrid");
const tCommon = useI18n("common");
const board = useRequiredBoard();
const { openModal } = useModalAction(BeszelSystemStatsModal);
@@ -483,6 +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={tFeature("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<Center h="100%">
@@ -511,6 +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={tFeature("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<Box
@@ -19,6 +19,7 @@ import { useWidgetRuntimeQueries } from "../runtime-hooks";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
@@ -143,6 +144,7 @@ export default function BeszelSystemStatsWidget({
return (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={systemsResult} />
</Group>
<Center h="100%">
@@ -182,6 +184,7 @@ export default function BeszelSystemStatsWidget({
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={systemsResult} />
</Group>
</Box>
@@ -42,6 +42,7 @@ import {
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { getBeszelTableVisibleMetricKeys } from "./display";
@@ -452,6 +453,7 @@ export default function BeszelSystemTableWidget({
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
</div>
+20 -2
View File
@@ -16,6 +16,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { CalendarDay } from "./calender-day";
@@ -53,6 +54,7 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
events={[]}
failedIntegrations={[]}
isPending={false}
queryError={undefined}
month={month}
setMonth={setMonth}
/>
@@ -104,6 +106,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
events={events}
failedIntegrations={failedIntegrations}
isPending={isPending}
queryError={calendarQuery.error}
month={month}
setMonth={setMonth}
options={options}
@@ -117,6 +120,7 @@ interface CalendarBaseProps {
events: CalendarEventWithSource[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
isPending: boolean;
queryError: unknown;
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
@@ -128,11 +132,13 @@ const CalendarBase = ({
events,
failedIntegrations,
isPending,
queryError,
month,
setMonth,
options,
displayMode,
}: CalendarBaseProps) => {
const t = useI18n("widget.calendar");
const locale = useCurrentIntlLocale();
const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard();
@@ -164,6 +170,8 @@ const CalendarBase = ({
events={agendaEvents}
failedIntegrations={failedIntegrations}
isPending={isPending}
queryError={queryError}
queryErrorLabel={t("name")}
isEditMode={isEditMode}
locale={locale}
month={month}
@@ -172,6 +180,8 @@ const CalendarBase = ({
);
}
const hasErrors = failedIntegrations.length > 0 || Boolean(queryError);
return (
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
<Calendar
@@ -184,11 +194,13 @@ const CalendarBase = ({
date={month}
maxLevel="month"
firstDayOfWeek={firstDayOfWeek}
static={isEditMode}
className={classes.calendar}
w="100%"
h="100%"
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
borderRadius: "md",
height: isSmall ? "1.5rem" : undefined,
width: isSmall ? "1.5rem" : undefined,
@@ -237,16 +249,17 @@ const CalendarBase = ({
<CalendarDay
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={eventsForDate.length === 0}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
/>
);
}}
/>
{failedIntegrations.length > 0 && (
{hasErrors && (
<Group className={classes.errorIndicator} gap={0} pos="absolute">
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
</Group>
)}
</Box>
@@ -257,6 +270,8 @@ interface CalendarAgendaProps {
events: CalendarEventWithSource[];
failedIntegrations: CalendarBaseProps["failedIntegrations"];
isPending: boolean;
queryError: unknown;
queryErrorLabel: string;
isEditMode: boolean;
locale: string;
month: Date;
@@ -267,6 +282,8 @@ const CalendarAgenda = ({
events,
failedIntegrations,
isPending,
queryError,
queryErrorLabel,
isEditMode,
locale,
month,
@@ -314,6 +331,7 @@ const CalendarAgenda = ({
</Group>
<Group gap={0}>
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
</Group>
</Group>
@@ -4,6 +4,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { AnimatedWeatherIcon } from "../weather/animated-icon";
import { WeatherDescription } from "../weather/icon";
@@ -24,16 +25,17 @@ export const ClockWeatherSummary = ({
animateIcon,
detailed,
}: ClockWeatherSummaryProps) => {
const t = useI18n();
const tCommon = useI18n("common");
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const weather = weatherQuery.data;
if (!weather) {
if (isInitialWidgetQueryPending(weatherQuery)) {
return <Loader size="xs" aria-label={tCommon("action.loading")} />;
}
return null;
return isInitialWidgetQueryPending(weatherQuery) ? (
<Loader size="xs" aria-label={tCommon("action.loading")} />
) : (
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
);
}
const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
@@ -70,6 +72,7 @@ export const ClockWeatherSummary = ({
</Text>
</Box>
)}
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
</Stack>
);
};
@@ -151,8 +151,8 @@ export function HomarrDataTable<T>({
defaultColumnProps={{
...defaultColumnProps,
noWrap: defaultColumnProps?.noWrap ?? true,
draggable: true,
resizable: true,
draggable: !isEditMode,
resizable: !isEditMode,
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
}}
scrollAreaProps={{ ...scrollAreaDefaultProps, ...scrollAreaProps }}
@@ -5,10 +5,11 @@ import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetQueryLoadingState } from "./query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: 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;
},
@@ -25,7 +26,7 @@ const matchMedia = (query: string) => ({
dispatchEvent: () => false,
});
describe("WidgetQueryLoadingState", () => {
describe("WidgetQueryErrorIndicator", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
@@ -35,6 +36,28 @@ describe("WidgetQueryLoadingState", () => {
vi.unstubAllGlobals();
});
test("uses a localized label without exposing the raw failure", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const secret = "http://admin:password@private.local/path?token=secret";
await act(async () =>
root?.render(
createElement(
MantineProvider,
null,
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
),
),
);
expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull();
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
});
test("announces the loading state to assistive technology", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
@@ -1,9 +1,39 @@
"use client";
import { Center, Loader, VisuallyHidden } from "@mantine/core";
import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface WidgetQueryErrorIndicatorProps {
error: unknown;
label: string;
}
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
const t = useI18n("common.widgetQuery");
if (!error) return null;
const accessibleLabel = t("stale", { widget: label });
return (
<Tooltip label={accessibleLabel} position="left" withArrow>
<ActionIcon
component="span"
color="orange"
variant="subtle"
size="sm"
role="note"
aria-label={accessibleLabel}
tabIndex={0}
style={{ cursor: "help" }}
>
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" />
</ActionIcon>
</Tooltip>
);
}
export function WidgetQueryLoadingState() {
const t = useI18n("common.widgetQuery");
return (
@@ -3,9 +3,11 @@
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/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";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils";
@@ -146,6 +148,9 @@ const FailedSourceBadges = ({
{t("source.unavailable", { source: source.integrationName })}
</Badge>
))}
<Box ml="auto">
<WidgetQueryErrorIndicator error={queryError} label={getIntegrationName("coolify")} />
</Box>
</Group>
);
};
@@ -35,6 +35,7 @@ import type { widgetKind } from ".";
import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerModal from "./TimerModal";
@@ -164,6 +165,7 @@ 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("name")} />
</Group>
</Box>
{controlAllButtonsVisible && (
@@ -32,6 +32,7 @@ import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
export default function DnsHoleSummaryWidget({
options,
@@ -70,6 +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={tDns("name")} />
</Group>
</Box>
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
@@ -55,6 +55,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { HomarrDataTable } from "../common/homarr-data-table";
import { formatLocalizedDateTime } from "../common/locale";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
@@ -313,6 +314,7 @@ export default function DownloadClientsWidget({
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={currentItems ?? []} />
<WidgetQueryErrorIndicator error={downloadsQuery.error} label={t("name")} />
</Group>
);
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
import { Accordion, Box, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -9,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import type { WidgetComponentProps } from "../../definition";
import { formatUptime } from "../system-health";
import { getClusterAccordionDefault, getClusterVisibleSections } from "./accordion-state";
@@ -70,6 +71,9 @@ export const ClusterHealthMonitoring = ({
const isTiny = displayMode !== "advanced" && width < 256;
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("name")} />
</Box>
{(isAdvanced || options.showUptime) && !isTiny && (
<Group justify="center" wrap="nowrap">
<Text fz="xs" fw={700} c="dimmed" ta="center">
@@ -45,7 +45,7 @@ import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-stor
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { CpuRing } from "./rings/cpu-ring";
@@ -81,6 +81,7 @@ export const SystemHealthMonitoring = ({
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={healthResults} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
@@ -20,6 +20,7 @@ import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { useWidgetRuntimeActions } from "../../runtime-hooks";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import classes from "./component.module.css";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
@@ -31,6 +32,7 @@ export default function ImmichAlbumCarouselWidget({
}: WidgetComponentProps<"immich-albumCarousel">) {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [paused, setPaused] = useState(false);
const t = useI18n("widget.immich-albumCarousel");
const albumQuery = clientApi.widget.immich.getAlbum.useQuery(
{
@@ -69,6 +71,9 @@ 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("name")} />
</Box>
{album.assets.length === 0 || photoAssets.length === 0 ? (
<NoPhotosInAlbum />
) : (
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Box, Center, Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
@@ -12,7 +12,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../../runtime-hooks";
import classes from "./component.module.css";
@@ -103,6 +103,11 @@ export default function ImmichServerStatsWidget({
return (
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
{statsContent}
{statsQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
</Box>
)}
</Stack>
);
}
@@ -116,10 +121,15 @@ export default function ImmichServerStatsWidget({
<Text size="xs" c="dimmed">
{t("albumLimit", { count: MAX_ADVANCED_ALBUMS })}
</Text>
<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("name")} />
</Center>
) : albums.length === 0 ? (
<WidgetEmptyState />
) : (
@@ -29,6 +29,7 @@ import { useWidgetRuntimeActions } from "../runtime-hooks";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import actionTargetClasses from "../common/action-target.module.css";
import classes from "./component.module.css";
import { getIndexerDisplayStatus } from "./status";
@@ -117,6 +118,7 @@ export default function IndexerManagerWidget({
)}
</Group>
)}
<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)}
@@ -28,6 +28,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { NoIntegrationDataError } from "../errors/no-data-integration";
@@ -162,10 +163,15 @@ export default function MediaMissingWidget({
))}
</Group>
);
const queryFailure = <WidgetQueryErrorIndicator error={mediaQuery.error} label={t("name")} />;
if (isAdvanced) {
return (
<Stack h="100%" gap={0}>
{partialFailures}
<Group justify="flex-end" px="xs">
{queryFailure}
{partialFailures}
</Group>
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
{showMissing && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
@@ -204,7 +210,10 @@ export default function MediaMissingWidget({
h="100%"
style={{ display: "flex", flexDirection: "column" }}
>
{partialFailures}
<Group justify="flex-end" px="xs">
{queryFailure}
{partialFailures}
</Group>
<Tabs.List grow>
{showMissing && (
<Tabs.Tab
@@ -33,7 +33,7 @@ import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import classes from "./component.module.css";
export default function MediaReleasesWidget({
@@ -43,6 +43,7 @@ export default function MediaReleasesWidget({
height,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const t = useI18n("widget.mediaReleases");
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
const response = getUsableWidgetQueryData(releasesQuery);
@@ -69,9 +70,10 @@ export default function MediaReleasesWidget({
</SimpleGrid>
</ScrollArea>
)}
{failedIntegrations.length > 0 && (
{(failedIntegrations.length > 0 || releasesQuery.error) && (
<Group pos="absolute" top={4} right={4} gap={0}>
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("name")} />
</Group>
)}
</Box>
@@ -40,6 +40,7 @@ import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import classes from "./component.module.css";
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["metadata"]>["transcoding"];
@@ -261,12 +262,14 @@ export default function MediaServerWidget({
aria-label={tSearch("placeholder")}
leftSection={<IconSearch size="var(--mantine-font-size-xs)" aria-hidden />}
/>
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
</Group>
)}
{!isAdvanced && hasFailedIntegrations && (
{!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && (
<Group px="xs" justify="flex-end">
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { getQueryKey } from "@trpc/react-query";
import {
Box,
Button,
Divider,
Group,
@@ -25,6 +26,7 @@ import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
@@ -113,9 +115,16 @@ export default function MediaTranscodingWidget({
const queuePageCount = totalQueuePages ?? 1;
const isTiny = !isAdvanced && (width < 280 || height < 140);
const footerLayout = getTranscodingFooterLayout(width, height);
const queryIndicator = (
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<WidgetQueryErrorIndicator error={transcodingQuery.error} label={t("name")} />
</Box>
);
if (isAdvanced) {
return (
<Stack gap="xs" h="100%" p="xs" pos="relative">
{queryIndicator}
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={widthLayout.advancedColumns} spacing="sm">
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
@@ -146,6 +155,7 @@ export default function MediaTranscodingWidget({
return (
<Stack gap={4} h="100%" pos="relative">
{queryIndicator}
{view === "workers" ? (
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
) : view === "queue" ? (
@@ -1,191 +0,0 @@
.workspace {
display: grid;
grid-template-columns: 1fr;
gap: var(--mantine-spacing-lg);
align-items: start;
}
.workspace[data-with-preview][data-with-settings] {
grid-template-columns: minmax(0, 3fr) auto minmax(34rem, 2fr);
}
.previewPanel {
position: sticky;
top: 0;
width: 100%;
max-width: 52rem;
min-width: 0;
justify-self: center;
}
.previewDimensions {
position: absolute;
z-index: 2;
top: var(--mantine-spacing-xs);
right: var(--mantine-spacing-xs);
pointer-events: none;
}
.previewSizeControls {
position: absolute;
z-index: 3;
top: var(--mantine-spacing-xs);
left: var(--mantine-spacing-xs);
padding: 0.25rem 0.375rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
box-shadow: var(--mantine-shadow-xs);
backdrop-filter: blur(8px);
}
.previewSizeInput {
width: 5rem;
font-variant-numeric: tabular-nums;
}
.previewCanvas {
position: relative;
width: 100%;
height: clamp(24rem, calc(100dvh - 17rem), 44rem);
padding: var(--mantine-spacing-lg);
overflow: hidden;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
background-image:
linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 14%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 14%, transparent) 1px, transparent 1px);
background-size: 2rem 2rem;
}
.previewDivider {
height: auto;
align-self: stretch;
}
.previewViewport {
position: relative;
flex: 0 0 auto;
animation: dock-widget 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.previewWidget {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
}
.previewContent {
width: 100%;
height: 100%;
pointer-events: none;
user-select: none;
}
.settingsPanel {
min-width: 0;
max-height: calc(100dvh - 13rem);
padding-right: 0.25rem;
overflow-y: auto;
scrollbar-gutter: stable;
}
.settingsForm {
min-width: 0;
}
.fullWidthField {
grid-column: 1 / -1;
}
.switchOption {
display: flex;
cursor: pointer;
height: 4.5rem;
align-items: center;
padding: 0.625rem 0.75rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.switchOption > *,
.switchOption :global(.mantine-Switch-root),
.switchOption :global(.mantine-Switch-body) {
width: 100%;
}
.switchOption :global(.mantine-Switch-body) {
flex-direction: row-reverse;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-md);
}
.switchOption :global(.mantine-Switch-labelWrapper) {
min-width: 0;
flex: 1;
}
.switchOption :global(.mantine-Switch-description) {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.footer {
position: sticky;
z-index: 3;
bottom: calc(var(--mantine-spacing-md) * -1);
margin-inline: calc(var(--mantine-spacing-md) * -1);
margin-bottom: calc(var(--mantine-spacing-md) * -1);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
backdrop-filter: blur(10px);
}
@keyframes dock-widget {
from {
opacity: 0;
transform: translate(-1.25rem, 1rem) scale(0.92);
}
to {
opacity: 1;
transform: none;
}
}
@media (max-width: 68em) {
.workspace[data-with-preview][data-with-settings] {
grid-template-columns: 1fr;
}
.previewPanel {
position: static;
max-width: 44rem;
}
.previewDivider {
display: none;
}
.previewCanvas {
height: 24rem;
}
.settingsPanel {
max-height: none;
overflow: visible;
}
}
@media (prefers-reduced-motion: reduce) {
.previewViewport {
animation: none;
}
}
+175 -495
View File
@@ -1,38 +1,18 @@
"use client";
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
import { useEffect, useId, useRef, useState } from "react";
import type { ComponentType, FormEvent, PropsWithChildren } from "react";
import {
Alert,
Badge,
Box,
Button,
Center,
Divider,
Group,
NumberInput,
SimpleGrid,
Stack,
Tabs,
Text,
Tooltip,
} from "@mantine/core";
import { useEffect, useRef, useState } from "react";
import type { ComponentType, FormEvent } from "react";
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { useElementSize } from "@mantine/hooks";
import { IconArrowLeft, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
import { objectEntries } from "@homarr/common";
import { IntegrationProvider, useSession } from "@homarr/auth/client";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { SpotlightProvider } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { zodErrorMap } from "@homarr/validation/form/i18n";
@@ -41,16 +21,13 @@ import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
import type { OptionsBuilderResult } from "../options";
import type { WidgetComponentProps, WidgetDefinition } from "../definition";
import { WidgetError } from "../errors/component";
import type { WidgetDefinition } from "../definition";
import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetCardShell, WidgetTitleBadge } from "../widget-card-shell";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
import classes from "./widget-edit-modal.module.css";
export interface WidgetEditModalState {
options: Record<string, unknown>;
@@ -58,17 +35,6 @@ export interface WidgetEditModalState {
advancedOptions: BoardItemAdvancedOptions;
}
export interface WidgetEditModalSize {
width: number;
height: number;
}
interface WidgetEditPreviewResizeOptions {
initialSize: WidgetEditModalSize;
maximumSize: WidgetEditModalSize;
getDimensions?: (size: WidgetEditModalSize) => { width: number; height: number; scale?: number };
}
export interface EmbeddedIntegrationEditFormHandle {
submitIfDirty: () => Promise<boolean>;
}
@@ -83,7 +49,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
kind: TSort;
definition: WidgetDefinition;
value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState, size?: WidgetEditModalSize) => void;
onSuccessfulEdit: (value: WidgetEditModalState) => void;
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
settings: SettingsContextProps;
@@ -93,215 +59,8 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
previewComponent?: ComponentType<WidgetComponentProps<TSort>>;
previewDimensions?: { width: number; height: number; scale?: number };
previewResize?: WidgetEditPreviewResizeOptions;
previewWrapper?: ComponentType<PropsWithChildren>;
}
interface WidgetEditPreviewProps {
kind: WidgetKind;
Component: ComponentType<WidgetComponentProps<WidgetKind>>;
definition: WidgetDefinition;
state: WidgetEditModalState;
itemId?: string;
boardId?: string;
dimensions?: { width: number; height: number; scale?: number };
integrationData: IntegrationSelectOption[];
onChangeOptions: (newOptions: Record<string, unknown>) => void;
resize?: {
size: WidgetEditModalSize;
maximumSize: WidgetEditModalSize;
onChange: (size: WidgetEditModalSize) => void;
};
PreviewWrapper?: ComponentType<PropsWithChildren>;
}
const PreviewRuntimeBoundary = ({
Wrapper,
children,
}: PropsWithChildren<{ Wrapper?: ComponentType<PropsWithChildren> }>) => {
if (!Wrapper) return children;
return <Wrapper>{children}</Wrapper>;
};
const normalizePreviewSize = (size: WidgetEditModalSize, maximumSize: WidgetEditModalSize): WidgetEditModalSize => ({
width: Math.max(1, Math.min(Math.round(size.width), maximumSize.width)),
height: Math.max(1, Math.min(Math.round(size.height), maximumSize.height)),
});
const WidgetEditPreview = ({
kind,
Component,
definition,
state,
itemId,
boardId,
dimensions = { width: 400, height: 240 },
integrationData,
onChangeOptions,
resize,
PreviewWrapper,
}: WidgetEditPreviewProps) => {
const t = useI18n();
const tItem = useI18n("item.edit");
const board = useOptionalBoard();
const generatedPreviewId = useId().replaceAll(":", "");
const { ref, width: availableWidth, height: availableHeight } = useElementSize<HTMLDivElement>();
const sourceWidth = Math.max(dimensions.width, 1);
const sourceHeight = Math.max(dimensions.height, 1);
let maximumScale = dimensions.scale ?? 0.9;
if (!Number.isFinite(maximumScale) || maximumScale <= 0) maximumScale = 0.9;
maximumScale = Math.min(maximumScale, 0.9);
let previewScale = maximumScale;
if (availableWidth > 0 && availableHeight > 0) {
previewScale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight, maximumScale);
}
const previewWidth = sourceWidth * previewScale;
const previewHeight = sourceHeight * previewScale;
const hasIntegrationSupport = "supportedIntegrations" in definition;
const integrationRequired = hasIntegrationSupport && definition.integrationsRequired !== false;
const isMissingIntegration = integrationRequired && state.integrationIds.length === 0;
const previewIntegrations = integrationData
.filter((integration) => state.integrationIds.includes(integration.id))
.map((integration) => ({
id: integration.id,
permissions: integration.permissions ?? {
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: false,
},
}));
let componentItemId = itemId;
if (kind === "assistant") componentItemId = `widget-preview-${generatedPreviewId}`;
const isPendingCustomWidget = kind === "customApi" && !itemId;
const previewOpacity = (board?.opacity ?? 100) / 100;
const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => {
if (typeof value !== "number" || !Number.isFinite(value) || !resize) return;
resize.onChange(
normalizePreviewSize(
{
...resize.size,
[dimension]: value,
},
resize.maximumSize,
),
);
};
return (
<Stack className={classes.previewPanel} gap={0}>
<Center ref={ref} className={classes.previewCanvas}>
{resize && (
<Group className={classes.previewSizeControls} gap={6} wrap="nowrap">
<Text size="xs" fw={600} c="dimmed">
{tItem("preview.size")}
</Text>
<Tooltip label={t("item.moveResize.field.width.label")}>
<NumberInput
className={classes.previewSizeInput}
aria-label={t("item.moveResize.field.width.label")}
value={resize.size.width}
onChange={(value) => handleResizeValue("width", value)}
min={1}
max={resize.maximumSize.width}
step={1}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
size="xs"
leftSection={t("item.moveResize.field.width.shortLabel")}
leftSectionPointerEvents="none"
/>
</Tooltip>
<Text size="xs" c="dimmed" aria-hidden>
×
</Text>
<Tooltip label={t("item.moveResize.field.height.label")}>
<NumberInput
className={classes.previewSizeInput}
aria-label={t("item.moveResize.field.height.label")}
value={resize.size.height}
onChange={(value) => handleResizeValue("height", value)}
min={1}
max={resize.maximumSize.height}
step={1}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
size="xs"
leftSection={t("item.moveResize.field.height.shortLabel")}
leftSectionPointerEvents="none"
/>
</Tooltip>
</Group>
)}
<Badge className={classes.previewDimensions} size="xs" variant="light" color="gray">
{Math.round(sourceWidth)} × {Math.round(sourceHeight)}
</Badge>
{isMissingIntegration ? (
<Alert color="gray" variant="light" icon={<IconEye size={18} />} maw={320}>
{tItem("preview.integrationRequired")}
</Alert>
) : (
<Box className={classes.previewViewport} w={previewWidth} h={previewHeight}>
<WidgetCardShell
className={classes.previewWidget}
kind={kind}
advancedOptions={state.advancedOptions}
opacity={previewOpacity}
radius={board?.itemRadius}
w={sourceWidth}
h={sourceHeight}
p={0}
data-grid-item-content
style={{ transform: `scale(${previewScale})` }}
>
<WidgetTitleBadge
advancedOptions={state.advancedOptions}
opacity={previewOpacity}
radius={board?.itemRadius}
/>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[state.options, state.integrationIds]}
fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
)}
>
<PreviewRuntimeBoundary Wrapper={PreviewWrapper}>
<SpotlightProvider>
<IntegrationProvider integrations={previewIntegrations}>
<div className={classes.previewContent} inert>
<Component
options={state.options as never}
integrationIds={state.integrationIds}
width={sourceWidth}
height={sourceHeight}
isEditMode={isPendingCustomWidget}
displayMode="compact"
boardId={boardId}
itemId={componentItemId}
setOptions={({ newOptions }) => onChangeOptions(newOptions as Record<string, unknown>)}
/>
</div>
</IntegrationProvider>
</SpotlightProvider>
</PreviewRuntimeBoundary>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</WidgetCardShell>
</Box>
)}
</Center>
</Stack>
);
};
export const getSelectedWidgetIntegrations = (
integrationData: readonly IntegrationSelectOption[],
selectedIds: readonly string[],
@@ -322,12 +81,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [previewSize, setPreviewSize] = useState<WidgetEditModalSize | null>(() => {
const resize = innerProps.previewResize;
if (!resize) return null;
return normalizePreviewSize(resize.initialSize, resize.maximumSize);
});
const widgetFormId = useId();
z.config({
customError: zodErrorMap(t),
@@ -453,13 +206,10 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
}
}
innerProps.onSuccessfulEdit(
{
...values,
advancedOptions,
},
previewSize ?? undefined,
);
innerProps.onSuccessfulEdit({
...values,
advancedOptions,
});
actions.closeModal();
} finally {
setIsSubmitting(false);
@@ -481,149 +231,47 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
}
: undefined;
const handlePreviewOptionsChange = (newOptions: Record<string, unknown>) => {
form.setFieldValue("options", {
...form.values.options,
...newOptions,
});
};
const selectedIntegrationKinds = innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind);
const visibleOptions = Object.entries(options).filter(([, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return false;
}
return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds);
});
const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch");
const otherOptions = visibleOptions.filter(([, value]) => value.type !== "switch");
const hasWidgetSettings = canConfigureWidget && (innerProps.integrationSupport || visibleOptions.length > 0);
const previewResize = innerProps.previewResize;
let previewDimensions = innerProps.previewDimensions;
let resizeControls: WidgetEditPreviewProps["resize"];
if (previewSize && previewResize?.getDimensions) {
previewDimensions = previewResize.getDimensions(previewSize);
resizeControls = {
size: previewSize,
maximumSize: previewResize.maximumSize,
onChange: setPreviewSize,
};
}
const widgetFormContent = (
<Box
className={classes.workspace}
data-with-preview={innerProps.previewComponent ? true : undefined}
data-with-settings={hasWidgetSettings ? true : undefined}
>
{innerProps.previewComponent && (
<WidgetEditPreview
kind={innerProps.kind}
Component={innerProps.previewComponent}
definition={definition}
state={{ ...form.values, advancedOptions }}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
dimensions={previewDimensions}
integrationData={innerProps.integrationData}
onChangeOptions={handlePreviewOptionsChange}
resize={resizeControls}
PreviewWrapper={innerProps.previewWrapper}
<Stack>
{canConfigureWidget && innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
)}
{innerProps.previewComponent && hasWidgetSettings && (
<Divider orientation="vertical" className={classes.previewDivider} />
)}
<form id={widgetFormId} className={classes.settingsForm} onSubmit={onFormSubmit} hidden={!hasWidgetSettings}>
{hasWidgetSettings && (
<Stack className={classes.settingsPanel} gap="sm">
<Group gap="xs" wrap="nowrap">
<IconSettings size={16} />
<Text fw={600} size="sm">
{tItem("settings.label")}
</Text>
</Group>
{canConfigureWidget && innerProps.integrationSupport && (
<Box className={classes.fullWidthField}>
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
</Box>
)}
{canConfigureWidget && (
<Stack gap="sm">
{switchOptions.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm">
{switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
{canConfigureWidget &&
Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
return (
<Box
key={key}
className={classes.switchOption}
data-option-type={value.type}
onClick={(event) => {
const target = event.target;
if (target instanceof Element && target.closest("label, input")) return;
const input = event.currentTarget.querySelector<HTMLInputElement>("input[type=checkbox]");
if (!input?.disabled) input?.click();
}}
>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</SimpleGrid>
)}
{otherOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
if (
!Input ||
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Box key={key} data-option-type={value.type}>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</Stack>
)}
</Stack>
)}
</form>
</Box>
);
const footer = (
<Box className={classes.footer}>
<Group justify="space-between" wrap="nowrap">
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
);
})}
{showResourceTabs ? (
<Button
variant="subtle"
type="button"
@@ -636,111 +284,143 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
>
{tItem("advancedOptions.label")}
</Button>
<Group gap="xs" wrap="nowrap">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Box>
) : (
<ModalFormFooter
onCancel={actions.closeModal}
leftSection={
<Button
variant="subtle"
type="button"
onClick={() =>
openModal({
advancedOptions,
onSuccess: setAdvancedOptions,
})
}
>
{tItem("advancedOptions.label")}
</Button>
}
/>
)}
</Stack>
);
return (
<FormProvider form={form}>
<Stack gap="sm">
<form onSubmit={onFormSubmit}>
<FormProvider form={form}>
{showResourceTabs ? (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
{widgetFormContent}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="sm">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
<Stack>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="sm">
<Stack>
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
{showAppTab && (
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Stack>
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
)}
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
<Box
pos="sticky"
bottom={0}
style={{
marginInline: "calc(var(--mantine-spacing-md) * -1)",
marginBottom: "calc(var(--mantine-spacing-md) * -1)",
paddingInline: "var(--mantine-spacing-md)",
paddingBlock: "var(--mantine-spacing-sm)",
background: "var(--mantine-color-body)",
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Box>
</Stack>
) : (
widgetFormContent
)}
{footer}
</Stack>
</FormProvider>
</FormProvider>
</form>
);
}).withOptions({
keepMounted: true,
@@ -9,6 +9,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import { getNetworkControllerStatusLayout } from "./layout";
import { WifiVariant } from "./variants/wifi-variant";
import { WiredVariant } from "./variants/wired-variant";
@@ -34,6 +35,7 @@ export default function NetworkControllerNetworkStatusWidget({
),
[results],
);
const t = useI18n("widget.networkControllerStatus");
const tWidgetCommon = useI18n("widget.common");
const tCommon = useI18n("common");
const locale = useCurrentIntlLocale();
@@ -47,6 +49,7 @@ export default function NetworkControllerNetworkStatusWidget({
const queryIndicators = (
<Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
</Group>
);
@@ -15,6 +15,7 @@ import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getBinaryStatusKey } from "../../common/locale";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import type { NetworkControllerMatrixMetricKey, NetworkControllerMatrixSectionKey } from "./display";
import { getNetworkControllerMatrix } from "./display";
@@ -56,6 +57,7 @@ export default function NetworkControllerSummaryWidget({
const queryIndicators = (
<Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
</Group>
);
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { getNotificationDisplay } from "./display";
import classes from "./component.module.css";
@@ -66,6 +67,11 @@ export default function NotificationsWidget({
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
<Stack w="100%" gap="xs">
{notificationsQuery.error && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={notificationsQuery.error} label={t("name")} />
</Group>
)}
{failedIntegrations.length > 0 && (
<Group gap={4} wrap="wrap">
{failedIntegrations.map((integration) => (
@@ -29,6 +29,13 @@
min-height: 220px;
}
.queryIndicator {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
}
.compactRoot {
padding: clamp(4px, 3cqi, 8px);
gap: 2px;
+5 -1
View File
@@ -16,12 +16,13 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "./layout-utils";
@@ -277,6 +278,9 @@ export default function PatchMonWidget({
return (
<div className={classes.root}>
<div className={classes.queryIndicator}>
<WidgetQueryErrorIndicator error={statsQuery.error} label={getIntegrationName("patchmon")} />
</div>
{showHero && (
<ComplianceHero
compliancePercent={compliancePercent}
@@ -9,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
@@ -71,6 +72,7 @@ export default function SpeedtestTrackerWidget({
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
</Group>
<ScrollArea h="100%">
<Stack gap="md" p="md">
@@ -129,6 +131,7 @@ export default function SpeedtestTrackerWidget({
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
</Group>
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
{dashboardData.length > 1 && (
+6 -1
View File
@@ -9,7 +9,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
function round(value: number) {
@@ -196,6 +196,11 @@ export default function StockPriceWidget({
return (
<Box h="100%" w="100%" pos="relative">
{content}
{stockQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={stockQuery.error} label={t("name")} />
</Box>
)}
</Box>
);
}
@@ -11,7 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
@@ -253,6 +253,7 @@ export default function SystemResources({
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
const emptyState = (
@@ -4,11 +4,13 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
import { SystemResourceCPUChart } from "./chart/cpu-chart";
import { SystemResourceGPUChart } from "./chart/gpu-chart";
@@ -82,6 +84,7 @@ export default function SystemResources({
height,
displayMode,
}: WidgetComponentProps<"systemResources">) {
const t = useI18n("widget.systemResources");
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
const results = getUsableWidgetQueryData(healthQuery) ?? [];
const data = results.filter(
@@ -95,6 +98,7 @@ export default function SystemResources({
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
@@ -3,12 +3,14 @@
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import type { TracearrDashboardData } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import { formatTotalTracearrBitrate } from "./bitrate";
import { RecentActivityList } from "./recent-activity-section";
@@ -184,6 +186,7 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("tracearr")} />
</Group>
<ScrollArea h="100%">
<SimpleGrid
@@ -220,6 +223,7 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("tracearr")} />
</Group>
<ScrollArea h="100%">
<Stack gap="xs" p="xs">
@@ -12,6 +12,7 @@ import {
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
@@ -19,6 +20,7 @@ import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
@@ -210,6 +212,7 @@ function TraefikWidgetContent({
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={data} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
</Group>
<ScrollArea h="100%">{summary}</ScrollArea>
</Box>
@@ -262,6 +265,7 @@ function TraefikWidgetContent({
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={data} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
</Group>
<ScrollArea h="100%">
<Stack gap="md" p="md">
@@ -277,6 +281,7 @@ function TraefikWidgetContent({
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={data} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
</Group>
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<ScrollArea h="100%">{summary}</ScrollArea>
+9 -1
View File
@@ -15,7 +15,7 @@ import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
interface UmamiContentProps {
integrationIds: string[];
@@ -270,6 +270,14 @@ export function UmamiContent({
<Box className={classes.chartSurface} mt={4} style={{ flex: 1, minHeight: 0 }}>
{displayMode === "advanced" ? advancedContent : selectedView}
</Box>
{(statsQuery.error || activeVisitorsQuery.error || multiEventQuery.error) && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator
error={statsQuery.error ?? activeVisitorsQuery.error ?? multiEventQuery.error}
label={t("name")}
/>
</Box>
)}
</Stack>
);
}
+3
View File
@@ -24,6 +24,7 @@ import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
const statusColors: Record<UpsStatus, string> = {
@@ -86,6 +87,7 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={data} />
<WidgetQueryErrorIndicator error={summariesQuery.error} label={t("name")} />
</Group>
<WidgetEmptyState />
</Box>
@@ -116,6 +118,7 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={data} />
<WidgetQueryErrorIndicator error={summariesQuery.error} label={t("name")} />
</Group>
<ScrollArea h="100%">
{displayMode === "advanced" ? (
@@ -6,12 +6,14 @@ import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/
import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
import { aggregateUptimeKumaDashboards } from "./aggregate";
@@ -252,6 +254,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
</Group>
{summaryContent}
</Box>
@@ -288,6 +291,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
</Group>
<ScrollArea h="100%">
<Stack gap="lg" p="md">
@@ -303,6 +307,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
</Group>
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
{summaryContent}
@@ -110,6 +110,10 @@
outline-offset: 0.125rem;
}
.errorIndicator {
z-index: 2;
}
@media (any-pointer: coarse) {
.detailsButton,
.forecastButton,
+8 -1
View File
@@ -3,10 +3,11 @@
import { Box } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { AdvancedWeather } from "./advanced";
import classes from "./component.module.css";
@@ -19,6 +20,7 @@ export default function WeatherWidget({
height,
displayMode,
}: WidgetComponentProps<"weather">) {
const t = useI18n("widget.weather");
const input = {
latitude: options.location.latitude,
longitude: options.location.longitude,
@@ -38,6 +40,11 @@ export default function WeatherWidget({
<CompactWeather height={height} isEditMode={isEditMode} options={options} weather={weather} width={width} />
)}
</Box>
{weatherQuery.error && (
<Box pos="absolute" top={4} right={4} className={classes.errorIndicator}>
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("name")} />
</Box>
)}
</Box>
);
}
@@ -1,34 +0,0 @@
.card {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity));
--default-border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity));
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity));
--default-border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity));
}
background-color: var(--background-color) !important;
border-color: var(--border-color, var(--default-border-color)) !important;
}
.card.iframe {
background-color: transparent !important;
border-color: transparent !important;
}
.title {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity));
--default-border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity));
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity));
--default-border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity));
}
background-color: var(--background-color) !important;
border-color: var(--border-color, var(--default-border-color)) !important;
}
@@ -1,95 +0,0 @@
"use client";
import type { AriaRole, CSSProperties, PropsWithChildren, Ref } from "react";
import type { CardProps } from "@mantine/core";
import { Badge, Card } from "@mantine/core";
import combineClasses from "clsx";
import type { WidgetKind } from "@homarr/definitions";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import classes from "./widget-card-shell.module.css";
const getOverflow = (kind: WidgetKind, hasCustomCssClasses: boolean): CSSProperties => {
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" };
if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" };
if (hasCustomCssClasses) return {};
return { overflowX: "hidden", overflowY: "auto" };
};
interface WidgetCardShellProps extends PropsWithChildren<Omit<CardProps, "className" | "styles">> {
kind: WidgetKind;
advancedOptions: BoardItemAdvancedOptions;
opacity: number;
className?: string;
innerRef?: Ref<HTMLDivElement>;
id?: string;
role?: AriaRole;
"aria-label"?: string;
"data-advanced-focus-surface"?: boolean;
"data-grid-item-content"?: boolean;
}
export const WidgetCardShell = ({
kind,
advancedOptions,
opacity,
className,
innerRef,
children,
...cardProps
}: WidgetCardShellProps) => (
<Card
{...cardProps}
ref={innerRef}
className={combineClasses(
classes.card,
kind === "iframe" && classes.iframe,
`${kind}-wrapper`,
"board-grid-item-content",
advancedOptions.customCssClasses.join(" "),
className,
)}
styles={{
root: {
"--opacity": opacity,
"--border-color": advancedOptions.borderColor || undefined,
containerType: "size",
...getOverflow(kind, advancedOptions.customCssClasses.length > 0),
} as CSSProperties,
}}
>
{children}
</Card>
);
interface WidgetTitleBadgeProps {
advancedOptions: BoardItemAdvancedOptions;
opacity: number;
radius: CardProps["radius"];
}
export const WidgetTitleBadge = ({ advancedOptions, opacity, radius }: WidgetTitleBadgeProps) => {
if (!advancedOptions.title?.trim()) return null;
return (
<Badge
pos="absolute"
style={{ zIndex: "var(--mantine-z-index-app)" }}
top={2}
left={16}
size="xs"
radius={radius}
styles={{
root: {
"--border-color": advancedOptions.borderColor || undefined,
"--opacity": opacity,
} as CSSProperties,
}}
className={classes.title}
c="var(--mantine-color-text)"
>
{advancedOptions.title}
</Badge>
);
};
+7 -1
View File
@@ -12,6 +12,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
@@ -40,7 +41,7 @@ const WudWidgetContent = ({
isAdvanced: boolean;
}) => {
const t = useI18n("widget.wud");
const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
const [data, statsQuery] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
const board = useRequiredBoard();
const isTiny = !isAdvanced && width < 256;
@@ -114,6 +115,11 @@ const WudWidgetContent = ({
return (
<Stack p="xs" gap="xs" h="100%">
{Boolean(statsQuery.error) && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
</Group>
)}
{showTitle && !isTiny && (
<Group gap="xs" wrap="nowrap" justify="space-between" miw={0}>
<Group gap="xs" wrap="nowrap" miw={0}>