Compare commits

...
Author SHA1 Message Date
ajnart e56112cfd7 fix(ui): prevent integration card title wrapping 2026-08-23 09:21:48 +02:00
Thomas 0d9eeb5752 Merge pull request #6678 from homarr-labs/feat/fix-responsive-layout-order
fix: keep responsive layout editors stable
2026-08-23 08:58:48 +02:00
Thomas bf0878c68b Merge pull request #6680 from homarr-labs/feat/fix-widget-edit-layout-shifts
fix: stabilize widgets in board edit mode
2026-08-23 08:56:07 +02:00
Thomas eb6d337dfb fix: unify widget scroll areas (#6679)
* fix: unify widget scroll areas

* fix: cover all widget scroll areas

* fix: align data table scrollbars
2026-08-23 01:58:06 +02:00
ajnart 978b3c7f33 fix: stabilize widgets in board edit mode 2026-08-23 01:56:31 +02:00
Thomas 07547320f1 Merge pull request #6667 from homarr-labs/feat/widget-settings-modal
feat: rework widget settings modal
2026-08-23 01:47:14 +02:00
ajnart 02870fc392 test: align grid footprint expectations 2026-08-23 01:42:16 +02:00
ajnart d5cf1b82f2 fix: address widget editor review feedback 2026-08-23 01:20:39 +02:00
ajnart e022c3f735 fix: match legacy widget grid dimensions 2026-08-23 01:20:39 +02:00
ajnart 14cc3e8a44 fix: make widget toggle cards clickable 2026-08-23 01:19:09 +02:00
ajnart 93d160aebd chore: remove PR artifacts 2026-08-23 01:19:09 +02:00
ajnart 493e93ea42 feat: size widgets before adding 2026-08-23 01:19:08 +02:00
ajnart 85c20702cd docs: add widget settings demo evidence 2026-08-23 01:19:08 +02:00
ajnart 91b248d395 fix: harden widget editor previews 2026-08-23 01:19:08 +02:00
ajnart b7eadc7867 fix: load resolved widget settings 2026-08-23 01:19:08 +02:00
ajnart 1861d8415c fix: isolate widget editor forms 2026-08-23 01:19:08 +02:00
ajnart 7fe5f8f81c fix: tighten widget preview stage 2026-08-23 01:19:08 +02:00
ajnart b3a30e0cf6 fix: constrain widget editor previews 2026-08-23 01:19:08 +02:00
ajnart 763e1324e8 fix: preserve widget preview dimensions 2026-08-23 01:19:08 +02:00
ajnart 5188ed89d6 feat: rework widget settings modal 2026-08-23 01:19:08 +02:00
97 changed files with 1166 additions and 728 deletions

No files matched your search

@@ -1,60 +0,0 @@
---
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.
@@ -1,4 +0,0 @@
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,9 +198,13 @@ In the modal you can edit the app that you want to display:
<figure> <figure>
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} /> <img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption> <figcaption>
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
</figcaption>
</figure> </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. 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. 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. 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: 68 KiB

After

Width:  |  Height:  |  Size: 45 KiB

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