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 b6fc27e44d fix: preserve layout validation errors 2026-08-23 01:24:36 +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
ajnart 9eafd4f7a7 fix: preserve custom layout role type 2026-08-23 01:13:00 +02:00
ajnart 71ffa5d1be fix: keep responsive layout editors stable 2026-08-23 00:59:02 +02:00
119 changed files with 1171 additions and 1043 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."
@@ -194,17 +194,17 @@ To configure this, click on the small 3 dots at the top of the item in the edit
<figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption> <figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption>
</figure> </figure>
The side editor keeps the selected tile visible while you choose the app and adjust its display settings: In the modal you can edit the app that you want to display:
<figure> <figure>
<img <img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
src={editItemCurrent} <figcaption>
alt="App tile highlighted beside the Edit item side editor" The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
style={{ maxWidth: "620px" }} </figcaption>
/>
<figcaption>The selected tile stays highlighted while the rest of the board is subdued.</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: 144 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(" ");
@@ -111,9 +111,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base"); const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const sortedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
return ( return (
<SectionCard title={tBoard("setting.section.layout.title")}> <SectionCard title={tBoard("setting.section.layout.title")}>
@@ -129,25 +126,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
disabled={nextBreakpoint === null || !baseLayout} disabled={nextBreakpoint === null || !baseLayout}
onClick={() => { onClick={() => {
if (nextBreakpoint === null || !baseLayout) return; if (nextBreakpoint === null || !baseLayout) return;
form.setFieldValue("layouts", [ const newLayout: FormValues["layouts"][number] = {
...form.values.layouts, id: createId(),
{ name: tBoard("setting.section.layout.custom.defaultName"),
id: createId(), columnCount: baseLayout.columnCount,
name: tBoard("setting.section.layout.custom.defaultName"), leftGutterColumnCount: baseLayout.leftGutterColumnCount,
columnCount: baseLayout.columnCount, rightGutterColumnCount: baseLayout.rightGutterColumnCount,
leftGutterColumnCount: baseLayout.leftGutterColumnCount, breakpoint: nextBreakpoint,
rightGutterColumnCount: baseLayout.rightGutterColumnCount, role: "custom",
breakpoint: nextBreakpoint, };
role: "custom", let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
}, if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
]); form.insertListItem("layouts", newLayout, insertionIndex);
}} }}
> >
{tBoard("setting.section.layout.responsive.action.add")} {tBoard("setting.section.layout.responsive.action.add")}
</Button> </Button>
</Group> </Group>
{sortedLayouts.map(({ layout, index }) => { {form.values.layouts.map((layout, index) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base"); const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
const customBreakpoints = form.values.layouts const customBreakpoints = form.values.layouts
@@ -1,6 +1,8 @@
"use client"; "use client";
import { use, useCallback, useId, useState } from "react"; /* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
import { use, useCallback, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core"; import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -32,7 +34,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal); const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const previewId = useId();
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false); const [isEditorLoading, setIsEditorLoading] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({ const [dimensions, setDimensions] = useState<Dimensions>({
@@ -59,7 +60,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
preloadWidgetEditModal(); preloadWidgetEditModal();
try { try {
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : []; const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
const originalState = state;
openWidgetEditModal( openWidgetEditModal(
{ {
kind, kind,
@@ -68,14 +68,12 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
onSuccessfulEdit: (value) => { onSuccessfulEdit: (value) => {
setState(value); setState(value);
}, },
onPreviewChange: setState,
onPreviewRestore: () => setState(originalState),
integrationData: integrationData.filter((integration) => integrationData: integrationData.filter((integration) =>
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind), (currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
), ),
integrationSupport: hasIntegrationSupport, integrationSupport: hasIntegrationSupport,
settings, settings,
focusTargetId: previewId, previewDimensions: dimensions,
}, },
{ {
title(translate) { title(translate) {
@@ -91,10 +89,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
} finally { } finally {
setIsEditorLoading(false); setIsEditorLoading(false);
} }
}, [currentDefinition, kind, openWidgetEditModal, previewId, 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"),
}); });
@@ -115,7 +113,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
return ( return (
<> <>
<Card id={previewId} w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}> <Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
@@ -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;
@@ -30,13 +30,6 @@ interface UpdateItemIntegrations {
newIntegrations: string[]; newIntegrations: string[];
} }
interface UpdateItemSettings {
itemId: string;
options: Record<string, unknown>;
advancedOptions: BoardItemAdvancedOptions;
integrationIds: string[];
}
export const useItemActions = () => { export const useItemActions = () => {
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
@@ -84,20 +77,6 @@ export const useItemActions = () => {
[updateBoard], [updateBoard],
); );
const updateItemSettings = useCallback(
({ itemId, options, advancedOptions, integrationIds }: UpdateItemSettings) => {
updateBoard((previous) => ({
...previous,
items: previous.items.map((item) => {
if (item.id !== itemId || !("integrationIds" in item)) return item;
return { ...item, options, advancedOptions, integrationIds };
}),
}));
},
[updateBoard],
);
const moveAndResizeItem = useCallback( const moveAndResizeItem = useCallback(
(input: Omit<MoveAndResizeItemInput, "layoutId">) => { (input: Omit<MoveAndResizeItemInput, "layoutId">) => {
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId })); updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
@@ -126,7 +105,6 @@ export const useItemActions = () => {
updateItemOptions, updateItemOptions,
updateItemAdvancedOptions, updateItemAdvancedOptions,
updateItemIntegrations, updateItemIntegrations,
updateItemSettings,
duplicateItem, duplicateItem,
createItem, createItem,
}; };
@@ -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,34 +277,24 @@ 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}
data-advanced-focus-surface={isPreview || undefined} data-advanced-focus-surface={isPreview || undefined}
data-advanced-focus-item-id={isAdvanced ? item.id : undefined}
data-grid-item-content data-grid-item-content
radius={board.itemRadius} radius={board.itemRadius}
p={isAdvanced ? undefined : 0} p={isAdvanced ? undefined : 0}
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}>
@@ -346,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}
@@ -419,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>
)} )}
</> </>
); );
@@ -3,7 +3,6 @@ import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions"; import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
@@ -22,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;
} }
@@ -32,9 +32,8 @@ 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 board = useRequiredBoard();
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);
const tItem = useI18n("item"); const tItem = useI18n("item");
@@ -42,7 +41,8 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal(); const openMoveModal = useOpenItemMoveModal();
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemSettings, duplicateItem, removeItem } = useItemActions(); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { integrations: integrationData, section } = useSectionContext(); const { integrations: integrationData, section } = useSectionContext();
const settings = useSettings(); const settings = useSettings();
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t); const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
@@ -56,11 +56,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
}, [item, resetErrorBoundary]); }, [item, resetErrorBoundary]);
const openEditModal = () => { const openEditModal = () => {
const originalSettings = {
options: item.options,
advancedOptions: item.advancedOptions,
integrationIds: item.integrationIds,
};
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
@@ -70,14 +65,19 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
options: item.options, options: item.options,
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
}, },
onPreviewChange: (value) => { onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
updateItemSettings({ itemId: item.id, ...value }); updateItemOptions({
}, itemId: item.id,
onPreviewRestore: () => { newOptions: options,
updateItemSettings({ itemId: item.id, ...originalSettings }); });
}, updateItemAdvancedOptions({
onSuccessfulEdit: (value) => { itemId: item.id,
updateItemSettings({ itemId: item.id, ...value }); newAdvancedOptions: advancedOptions,
});
updateItemIntegrations({
itemId: item.id,
newIntegrations: integrationIds,
});
refResetErrorBoundaryOnNextRender.current = true; refResetErrorBoundaryOnNextRender.current = true;
}, },
onIntegrationSaved: resetErrorBoundary, onIntegrationSaved: resetErrorBoundary,
@@ -90,7 +90,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
integrationSupport: "supportedIntegrations" in definition, integrationSupport: "supportedIntegrations" in definition,
settings, settings,
itemId: item.id, 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,
}, },
{ {
@@ -108,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,13 +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,
boardId: board.id, previewResize: { initialSize, maximumSize: maximumWidgetSize },
}, },
{ {
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`, title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
@@ -312,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 }[],
) => { ) => {
@@ -327,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,
@@ -335,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", {
@@ -347,7 +362,7 @@ const ItemSelectModalContent = ({
integrationData: availableIntegrations, integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport, integrationSupport: hasIntegrationSupport,
settings, settings,
boardId: board.id, 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,8 +208,10 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
defaultTitle(t) { defaultTitle(t) {
return t("item.edit.title"); return t("item.edit.title");
}, },
presentation: "inspector", size: "100rem",
withOverlay: false, transitionProps: {
size: modalSizeForm, 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,
@@ -58,7 +60,7 @@ export const WidgetContextMenu = ({
const tCommon = useI18n("common.action"); const tCommon = useI18n("common.action");
const settings = useSettings(); const settings = useSettings();
const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const { updateItemSettings } = useItemActions(); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0; const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, { const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations, enabled: hasSupportedIntegrations,
@@ -154,11 +156,6 @@ export const WidgetContextMenu = ({
}) ?? []; }) ?? [];
const openEditModal = useCallback(() => { const openEditModal = useCallback(() => {
const originalSettings = {
options: item.options,
advancedOptions: item.advancedOptions,
integrationIds: item.integrationIds,
};
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
@@ -168,13 +165,10 @@ export const WidgetContextMenu = ({
options, options,
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
}, },
onPreviewChange: (value) => {
updateItemSettings({ itemId: item.id, ...value });
},
onPreviewRestore: () => {
updateItemSettings({ itemId: item.id, ...originalSettings });
},
onSuccessfulEdit: (editResult) => { onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
persistBoard((previous) => ({ persistBoard((previous) => ({
...previous, ...previous,
items: previous.items.map((boardItem) => items: previous.items.map((boardItem) =>
@@ -199,6 +193,7 @@ export const WidgetContextMenu = ({
settings, settings,
itemId: item.id, itemId: item.id,
boardId: board.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
@@ -215,8 +210,11 @@ export const WidgetContextMenu = ({
openModal, openModal,
options, options,
persistBoard, persistBoard,
previewDimensions,
settings, settings,
updateItemSettings, updateItemAdvancedOptions,
updateItemIntegrations,
updateItemOptions,
]); ]);
const handleToggle = useCallback( const handleToggle = useCallback(
@@ -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);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

+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,
@@ -7,11 +7,9 @@
.inspectorContent { .inspectorContent {
--modal-content-flex: 0 0 100%; --modal-content-flex: 0 0 100%;
--modal-content-max-height: min(88dvh, 52rem); --modal-content-max-height: min(88dvh, 52rem);
--modal-size: 100%;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0;
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0; border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
} }
@@ -25,13 +23,8 @@
--modal-content-flex: 0 0 min(34rem, 46vw); --modal-content-flex: 0 0 min(34rem, 46vw);
--modal-content-height: 100dvh; --modal-content-height: 100dvh;
--modal-content-max-height: 100dvh; --modal-content-max-height: 100dvh;
--modal-size: min(34rem, 46vw);
width: min(34rem, 46vw); width: min(34rem, 46vw);
border-radius: 0; border-radius: 0;
} }
.inspectorInner:has([data-widget-inspector-side="left"]) {
justify-content: flex-start;
}
} }
-1
View File
@@ -19,7 +19,6 @@ export type CreateModalOptions = Pick<
| "centered" | "centered"
| "keepMounted" | "keepMounted"
| "withCloseButton" | "withCloseButton"
| "withOverlay"
| "zIndex" | "zIndex"
| "scrollAreaComponent" | "scrollAreaComponent"
| "yOffset" | "yOffset"
+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" ? (
@@ -17,10 +17,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
const t = useI18n("item.edit"); const t = useI18n("item.edit");
const theme = useMantineTheme(); const theme = useMantineTheme();
const form = useForm({ const form = useForm({
initialValues: { initialValues: innerProps.advancedOptions,
...innerProps.advancedOptions,
title: innerProps.advancedOptions.title ?? "",
},
}); });
const handleSubmit = (values: BoardItemAdvancedOptions) => { const handleSubmit = (values: BoardItemAdvancedOptions) => {
innerProps.onSuccess({ innerProps.onSuccess({
@@ -1,118 +0,0 @@
"use client";
import { useState } from "react";
import { Box, getDefaultZIndex, Overlay, Portal } from "@mantine/core";
import { useIsomorphicEffect } from "@mantine/hooks";
import classes from "./widget-edit-modal.module.css";
interface FocusRect {
top: number;
left: number;
width: number;
height: number;
}
export type WidgetInspectorSide = "left" | "right";
const focusGap = 6;
const getFocusRect = (element: HTMLElement): FocusRect | null => {
const rect = element.getBoundingClientRect();
const top = Math.max(4, rect.top - focusGap);
const left = Math.max(4, rect.left - focusGap);
const right = Math.min(window.innerWidth - 4, rect.right + focusGap);
const bottom = Math.min(window.innerHeight - 4, rect.bottom + focusGap);
if (right <= left || bottom <= top) return null;
return {
top,
left,
width: right - left,
height: bottom - top,
};
};
const focusRectsMatch = (current: FocusRect | null, next: FocusRect | null) => {
if (current === next) return true;
if (!current || !next) return false;
return (
current.top === next.top &&
current.left === next.left &&
current.width === next.width &&
current.height === next.height
);
};
export const useWidgetEditFocus = (itemId: string | undefined, focusTargetId: string | undefined) => {
const [focusRect, setFocusRect] = useState<FocusRect | null>(null);
useIsomorphicEffect(() => {
let element: HTMLElement | null = null;
if (focusTargetId) {
element = document.getElementById(focusTargetId);
} else if (itemId) {
element = document.querySelector<HTMLElement>(`[data-advanced-focus-item-id="${CSS.escape(itemId)}"]`);
element ??= document.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(itemId)}"]`);
}
if (!element) {
setFocusRect(null);
return;
}
let animationFrame = 0;
const update = () => {
animationFrame = 0;
const nextRect = getFocusRect(element);
setFocusRect((current) => (focusRectsMatch(current, nextRect) ? current : nextRect));
};
const scheduleUpdate = () => {
if (animationFrame !== 0) return;
animationFrame = window.requestAnimationFrame(update);
};
update();
const resizeObserver = new ResizeObserver(scheduleUpdate);
resizeObserver.observe(element);
window.addEventListener("resize", scheduleUpdate);
window.addEventListener("scroll", scheduleUpdate, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("scroll", scheduleUpdate, true);
window.cancelAnimationFrame(animationFrame);
};
}, [focusTargetId, itemId]);
const inspectorSide: WidgetInspectorSide =
focusRect && focusRect.left + focusRect.width / 2 > window.innerWidth / 2 ? "left" : "right";
return { focusRect, inspectorSide };
};
export const WidgetEditFocusOverlay = ({ focusRect }: { focusRect: FocusRect | null }) => (
<Portal>
{focusRect ? (
<>
<Overlay fixed backgroundOpacity={0} zIndex={getDefaultZIndex("modal")} />
<Box
aria-hidden
className={classes.focusFrame}
style={{
top: focusRect.top,
left: focusRect.left,
width: focusRect.width,
height: focusRect.height,
zIndex: getDefaultZIndex("modal"),
}}
/>
</>
) : (
<Overlay fixed color="#000" backgroundOpacity={0.58} blur={1} zIndex={getDefaultZIndex("modal")} />
)}
</Portal>
);
@@ -1,23 +1,109 @@
.panel { .workspace {
min-width: 0;
}
.settings {
min-width: 0;
container-type: inline-size;
}
.switchGrid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: 1fr;
align-items: stretch; gap: var(--mantine-spacing-lg);
gap: var(--mantine-spacing-sm); 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 { .switchOption {
display: flex; display: flex;
height: 100%; cursor: pointer;
min-height: 4.25rem; height: 4.5rem;
align-items: center; align-items: center;
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
border: 1px solid var(--mantine-color-default-border); border: 1px solid var(--mantine-color-default-border);
@@ -35,7 +121,7 @@
flex-direction: row-reverse; flex-direction: row-reverse;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--mantine-spacing-sm); gap: var(--mantine-spacing-md);
} }
.switchOption :global(.mantine-Switch-labelWrapper) { .switchOption :global(.mantine-Switch-labelWrapper) {
@@ -43,48 +129,63 @@
flex: 1; flex: 1;
} }
.switchOption :global(.mantine-Switch-description) {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.footer { .footer {
position: sticky; position: sticky;
z-index: 2; z-index: 3;
bottom: 0; bottom: calc(var(--mantine-spacing-md) * -1);
margin-inline: calc(var(--mantine-spacing-md) * -1); margin-inline: calc(var(--mantine-spacing-md) * -1);
margin-bottom: calc(var(--mantine-spacing-md) * -1); margin-bottom: calc(var(--mantine-spacing-md) * -1);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border); border-top: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 96%, transparent); background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.footerActions { @keyframes dock-widget {
margin-inline-start: auto; from {
} opacity: 0;
transform: translate(-1.25rem, 1rem) scale(0.92);
}
@container (min-width: 30rem) { to {
.switchGrid { opacity: 1;
grid-template-columns: repeat(2, minmax(0, 1fr)); transform: none;
grid-auto-rows: 1fr;
} }
} }
.focusFrame { @media (max-width: 68em) {
position: fixed; .workspace[data-with-preview][data-with-settings] {
pointer-events: none; grid-template-columns: 1fr;
border: 2px solid var(--mantine-primary-color-filled); }
border-radius: var(--mantine-radius-md);
box-shadow: .previewPanel {
0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent), position: static;
0 0 2.5rem color-mix(in srgb, var(--mantine-primary-color-filled) 28%, transparent), max-width: 44rem;
0 0 0 100vmax rgb(0 0 0 / 0.58); }
clip-path: inset(-100vmax);
transition: .previewDivider {
top 160ms ease, display: none;
left 160ms ease, }
width 160ms ease,
height 160ms ease; .previewCanvas {
height: 24rem;
}
.settingsPanel {
max-height: none;
overflow: visible;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.focusFrame { .previewViewport {
transition: none; animation: none;
} }
} }
+512 -232
View File
@@ -1,18 +1,38 @@
"use client"; "use client";
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import type { ComponentType, FormEvent } from "react"; import type { ComponentType, FormEvent, PropsWithChildren } from "react";
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core"; 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, 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,12 +41,13 @@ 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 { useWidgetEditFocus, WidgetEditFocusOverlay } from "./widget-edit-focus-overlay";
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"; import classes from "./widget-edit-modal.module.css";
@@ -37,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>;
} }
@@ -51,21 +83,225 @@ 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;
onPreviewChange?: (value: WidgetEditModalState) => void;
onPreviewRestore?: () => void;
integrationData: IntegrationSelectOption[]; integrationData: IntegrationSelectOption[];
integrationSupport: boolean; integrationSupport: boolean;
settings: SettingsContextProps; settings: SettingsContextProps;
itemId?: string; itemId?: string;
boardId?: string; boardId?: string;
focusTargetId?: string;
appId?: string; appId?: string;
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[],
@@ -86,18 +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 hasPreviewedRef = useRef(false); const [previewSize, setPreviewSize] = useState<WidgetEditModalSize | null>(() => {
const hasSavedRef = useRef(false); const resize = innerProps.previewResize;
const previewCallbacksRef = useRef({ if (!resize) return null;
onPreviewChange: innerProps.onPreviewChange, return normalizePreviewSize(resize.initialSize, resize.maximumSize);
onPreviewRestore: innerProps.onPreviewRestore,
}); });
previewCallbacksRef.current = {
onPreviewChange: innerProps.onPreviewChange,
onPreviewRestore: innerProps.onPreviewRestore,
};
const widgetFormId = useId(); const widgetFormId = useId();
const { focusRect, inspectorSide } = useWidgetEditFocus(innerProps.itemId, innerProps.focusTargetId);
z.config({ z.config({
customError: zodErrorMap(t), customError: zodErrorMap(t),
@@ -129,47 +359,25 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1); if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations); if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations);
const widgetFormSchema = z.object({
options: optionsSchema,
integrationIds: integrationIdsSchema,
});
const applyPreview = (value: WidgetEditModalState) => {
if (!innerProps.onPreviewChange) return;
hasPreviewedRef.current = true;
innerProps.onPreviewChange(value);
};
const form = useForm({ const form = useForm({
mode: "controlled", mode: "controlled",
initialValues: { initialValues: innerProps.value,
options: innerProps.value.options, validate: schemaResolver(
integrationIds: innerProps.value.integrationIds, z.object({
}, options: optionsSchema,
validate: schemaResolver(widgetFormSchema, { sync: true }), integrationIds: integrationIdsSchema,
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
borderColor: z.string(),
}),
}),
{ sync: true },
),
validateInputOnBlur: true, validateInputOnBlur: true,
validateInputOnChange: true, validateInputOnChange: true,
onValuesChange: (values) => {
if (!widgetFormSchema.safeParse(values).success) return;
applyPreview({ ...values, advancedOptions });
},
}); });
const { openModal } = useModalAction(WidgetAdvancedOptionsModal); const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
useEffect(
() => () => {
if (!hasPreviewedRef.current || hasSavedRef.current) return;
const { onPreviewChange, onPreviewRestore } = previewCallbacksRef.current;
if (onPreviewRestore) {
onPreviewRestore();
return;
}
onPreviewChange?.(innerProps.value);
},
[innerProps.value],
);
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false; const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const appId = innerProps.appId; const appId = innerProps.appId;
@@ -245,11 +453,13 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
} }
} }
innerProps.onSuccessfulEdit({ innerProps.onSuccessfulEdit(
...values, {
advancedOptions, ...values,
}); advancedOptions,
hasSavedRef.current = true; },
previewSize ?? undefined,
);
actions.closeModal(); actions.closeModal();
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -271,202 +481,272 @@ 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 const selectedIntegrationKinds = innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id)) .filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind); .map(({ kind }) => kind);
const visibleOptions = Object.entries(options).filter(([, value]) => { const visibleOptions = Object.entries(options).filter(([, value]) => {
const Input = getInputForType(value.type); const Input = getInputForType(value.type);
if (!Input) return false; if (!Input) {
return false;
}
return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds); return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds);
}); });
const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch"); const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch");
const otherOptions = 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 = (
<form id={widgetFormId} onSubmit={onFormSubmit}> <Box
<Stack className={classes.settings} gap="md"> className={classes.workspace}
{canConfigureWidget && innerProps.integrationSupport && ( data-with-preview={innerProps.previewComponent ? true : undefined}
<WidgetIntegrationSelect data-with-settings={hasWidgetSettings ? true : undefined}
label={tItem("field.integrations.label")} >
data={innerProps.integrationData} {innerProps.previewComponent && (
canSelectMultiple={maxIntegrations > 1} <WidgetEditPreview
withAsterisk={integrationsRequired} kind={innerProps.kind}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} Component={innerProps.previewComponent}
{...form.getInputProps("integrationIds")} definition={definition}
/> state={{ ...form.values, advancedOptions }}
)} itemId={innerProps.itemId}
{canConfigureWidget && switchOptions.length > 0 && ( boardId={innerProps.boardId ?? board?.id}
<Box className={classes.switchGrid}> dimensions={previewDimensions}
{switchOptions.map(([key, value]) => { integrationData={innerProps.integrationData}
const Input = getInputForType(value.type); onChangeOptions={handlePreviewOptionsChange}
if (!Input) return null; resize={resizeControls}
PreviewWrapper={innerProps.previewWrapper}
/>
)}
{innerProps.previewComponent && hasWidgetSettings && (
<Divider orientation="vertical" className={classes.previewDivider} />
)}
<form id={widgetFormId} className={classes.settingsForm} onSubmit={onFormSubmit} hidden={!hasWidgetSettings}>
{hasWidgetSettings && (
<Stack className={classes.settingsPanel} gap="sm">
<Group gap="xs" wrap="nowrap">
<IconSettings size={16} />
<Text fw={600} size="sm">
{tItem("settings.label")}
</Text>
</Group>
{canConfigureWidget && innerProps.integrationSupport && (
<Box className={classes.fullWidthField}>
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
</Box>
)}
{canConfigureWidget && (
<Stack gap="sm">
{switchOptions.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm">
{switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
return ( return (
<Box key={key} className={classes.switchOption}> <Box
<Input key={key}
kind={innerProps.kind} className={classes.switchOption}
property={key} data-option-type={value.type}
options={value as never} onClick={(event) => {
initialOptions={innerProps.value.options} const target = event.target;
itemId={innerProps.itemId} if (target instanceof Element && target.closest("label, input")) return;
boardId={innerProps.boardId ?? board?.id} const input = event.currentTarget.querySelector<HTMLInputElement>("input[type=checkbox]");
/> if (!input?.disabled) input?.click();
</Box> }}
); >
})} <Input
</Box> kind={innerProps.kind}
)} property={key}
{canConfigureWidget && options={value as never}
otherOptions.map(([key, value]) => { initialOptions={innerProps.value.options}
const Input = getInputForType(value.type); itemId={innerProps.itemId}
if (!Input) return null; 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>
})} );
</Stack> })}
</form> </Stack>
)}
</Stack>
)}
</form>
</Box>
);
const footer = (
<Box className={classes.footer}>
<Group justify="space-between" wrap="nowrap">
<Button
variant="subtle"
type="button"
onClick={() =>
openModal({
advancedOptions,
onSuccess: setAdvancedOptions,
})
}
>
{tItem("advancedOptions.label")}
</Button>
<Group gap="xs" wrap="nowrap">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Box>
); );
return ( return (
<> <FormProvider form={form}>
<WidgetEditFocusOverlay focusRect={focusRect} /> <Stack gap="sm">
<FormProvider form={form}> {showResourceTabs ? (
<Stack className={classes.panel} data-widget-inspector-side={inspectorSide} gap={0}> <Tabs value={activeTab} onChange={setActiveTab}>
<Box pb="md"> <Tabs.List grow>
{showResourceTabs ? ( <Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
<Tabs value={activeTab} onChange={setActiveTab}> {showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
<Tabs.List grow> {showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab> </Tabs.List>
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>} <Tabs.Panel value="widget" pt="sm">
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>} {widgetFormContent}
</Tabs.List> </Tabs.Panel>
<Tabs.Panel value="widget" pt="md"> {showAppTab && (
{widgetFormContent} <Tabs.Panel value="app" pt="sm">
</Tabs.Panel> {appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
{showAppTab && ( </Tabs.Panel>
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Stack>
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
) : (
widgetFormContent
)} )}
</Box> {showIntegrationTab && (
<Group className={classes.footer} justify="space-between" wrap="wrap"> <Tabs.Panel value="integration" pt="sm">
<Button <Stack>
variant="subtle" {displayedIntegrationId === null && (
type="button" <>
onClick={() => <Text size="sm" c="dimmed">
openModal({ {tItem("integration.description")}
advancedOptions, </Text>
onSuccess: (value) => { {selectedIntegrations.map((integration) => {
setAdvancedOptions(value); const canEdit = editableIntegrationIds.includes(integration.id);
applyPreview({ ...form.getValues(), advancedOptions: value }); return (
}, <Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
}) <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
} <IntegrationAvatar kind={integration.kind} size="sm" />
> <Stack gap={0} style={{ minWidth: 0 }}>
{tItem("advancedOptions.label")} <Text fw={600} size="sm" truncate>
</Button> {integration.name}
<Group className={classes.footerActions} gap="xs" justify="flex-end" wrap="wrap"> </Text>
<Button type="button" onClick={actions.closeModal} variant="subtle" color="gray"> <Text size="xs" c="dimmed" truncate>
{tCommon("action.cancel")} {integration.url}
</Button> </Text>
<Button type="submit" form={widgetFormId} loading={isSubmitting}> </Stack>
{tCommon("action.saveChanges")} </Group>
</Button> {canEdit ? (
</Group> <Button
</Group> type="button"
</Stack> variant="light"
</FormProvider> leftSection={<IconPencil size={16} />}
</> onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
) : (
widgetFormContent
)}
{footer}
</Stack>
</FormProvider>
); );
}).withOptions({ }).withOptions({
keepMounted: true, keepMounted: true,
defaultTitle(t) { defaultTitle(t) {
return t("item.edit.title"); return t("item.edit.title");
}, },
presentation: "inspector",
withOverlay: false,
size: modalSizeForm, size: modalSizeForm,
closeOnClickOutside: false, closeOnClickOutside: false,
}); });
@@ -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;
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more