Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
978b3c7f33 |
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,15 +194,11 @@ 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>
|
||||
</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>
|
||||
<img
|
||||
src={editItemCurrent}
|
||||
alt="App tile highlighted beside the Edit item side editor"
|
||||
style={{ maxWidth: "620px" }}
|
||||
/>
|
||||
<figcaption>The selected tile stays highlighted while the rest of the board is subdued.</figcaption>
|
||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
||||
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
|
||||
</figure>
|
||||
|
||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||
|
||||
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 68 KiB |
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { use, useCallback, useId, useState } from "react";
|
||||
import { use, useCallback, useState } from "react";
|
||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
@@ -32,7 +32,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
||||
const previewId = useId();
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||
@@ -59,7 +58,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
||||
const originalState = state;
|
||||
openWidgetEditModal(
|
||||
{
|
||||
kind,
|
||||
@@ -68,14 +66,11 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
},
|
||||
onPreviewChange: setState,
|
||||
onPreviewRestore: () => setState(originalState),
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
|
||||
),
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
focusTargetId: previewId,
|
||||
},
|
||||
{
|
||||
title(translate) {
|
||||
@@ -91,7 +86,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
} finally {
|
||||
setIsEditorLoading(false);
|
||||
}
|
||||
}, [currentDefinition, kind, openWidgetEditModal, previewId, settings, state, t, utils]);
|
||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((editMode) => !editMode);
|
||||
@@ -115,7 +110,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
|
||||
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>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
|
||||
@@ -184,10 +184,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.widgetPanel[data-editing] .panelActions {
|
||||
padding-inline-end: 2.75rem;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
min-width: 0;
|
||||
min-height: 3.75rem;
|
||||
|
||||
@@ -30,13 +30,6 @@ interface UpdateItemIntegrations {
|
||||
newIntegrations: string[];
|
||||
}
|
||||
|
||||
interface UpdateItemSettings {
|
||||
itemId: string;
|
||||
options: Record<string, unknown>;
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
integrationIds: string[];
|
||||
}
|
||||
|
||||
export const useItemActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
@@ -84,20 +77,6 @@ export const useItemActions = () => {
|
||||
[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(
|
||||
(input: Omit<MoveAndResizeItemInput, "layoutId">) => {
|
||||
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
|
||||
@@ -126,7 +105,6 @@ export const useItemActions = () => {
|
||||
updateItemOptions,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
updateItemSettings,
|
||||
duplicateItem,
|
||||
createItem,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
}
|
||||
|
||||
.settingsButton {
|
||||
position: absolute;
|
||||
top: -34px;
|
||||
right: 14px;
|
||||
z-index: 201;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
@@ -58,3 +62,11 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settingsButton {
|
||||
right: 4px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
@@ -297,7 +297,6 @@ const LoadedBoardItemContent = ({
|
||||
role={isPreview ? "region" : undefined}
|
||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||
data-advanced-focus-surface={isPreview || undefined}
|
||||
data-advanced-focus-item-id={isAdvanced ? item.id : undefined}
|
||||
data-grid-item-content
|
||||
radius={board.itemRadius}
|
||||
p={isAdvanced ? undefined : 0}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getWidgetName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
@@ -34,7 +33,6 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
};
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useI18n("item");
|
||||
@@ -42,7 +40,8 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { updateItemSettings, duplicateItem, removeItem } = useItemActions();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { integrations: integrationData, section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
|
||||
@@ -56,11 +55,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
const openEditModal = () => {
|
||||
const originalSettings = {
|
||||
options: item.options,
|
||||
advancedOptions: item.advancedOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
};
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
@@ -70,14 +64,19 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
options: item.options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onPreviewChange: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
},
|
||||
onPreviewRestore: () => {
|
||||
updateItemSettings({ itemId: item.id, ...originalSettings });
|
||||
},
|
||||
onSuccessfulEdit: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: options,
|
||||
});
|
||||
updateItemAdvancedOptions({
|
||||
itemId: item.id,
|
||||
newAdvancedOptions: advancedOptions,
|
||||
});
|
||||
updateItemIntegrations({
|
||||
itemId: item.id,
|
||||
newIntegrations: integrationIds,
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
onIntegrationSaved: resetErrorBoundary,
|
||||
@@ -90,7 +89,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
@@ -108,10 +106,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 30 }}
|
||||
className={itemContentClasses.settingsButton}
|
||||
data-menu-open={isMenuOpen || undefined}
|
||||
data-board-widget-settings
|
||||
|
||||
@@ -277,7 +277,6 @@ const ItemSelectModalContent = ({
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||
@@ -347,7 +346,6 @@ const ItemSelectModalContent = ({
|
||||
integrationData: availableIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
||||
|
||||
@@ -157,8 +157,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
presentation: "inspector",
|
||||
withOverlay: false,
|
||||
size: modalSizeForm,
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export const WidgetContextMenu = ({
|
||||
const tCommon = useI18n("common.action");
|
||||
const settings = useSettings();
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { updateItemSettings } = useItemActions();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: hasSupportedIntegrations,
|
||||
@@ -154,27 +154,19 @@ export const WidgetContextMenu = ({
|
||||
}) ?? [];
|
||||
|
||||
const openEditModal = useCallback(() => {
|
||||
const originalSettings = {
|
||||
options: item.options,
|
||||
advancedOptions: item.advancedOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
};
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options,
|
||||
options: item.options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onPreviewChange: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
},
|
||||
onPreviewRestore: () => {
|
||||
updateItemSettings({ itemId: item.id, ...originalSettings });
|
||||
},
|
||||
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) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
@@ -197,8 +189,6 @@ export const WidgetContextMenu = ({
|
||||
),
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -207,16 +197,16 @@ export const WidgetContextMenu = ({
|
||||
},
|
||||
);
|
||||
}, [
|
||||
board.id,
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
handleRefetch,
|
||||
openModal,
|
||||
options,
|
||||
persistBoard,
|
||||
settings,
|
||||
updateItemSettings,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
updateItemOptions,
|
||||
]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
padding: 4px 40px 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--background-color);
|
||||
|
||||
|
Before Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 589 KiB |
|
Before Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 395 KiB |
|
Before Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 651 KiB |
@@ -7,11 +7,9 @@
|
||||
.inspectorContent {
|
||||
--modal-content-flex: 0 0 100%;
|
||||
--modal-content-max-height: min(88dvh, 52rem);
|
||||
--modal-size: 100%;
|
||||
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 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-height: 100dvh;
|
||||
--modal-content-max-height: 100dvh;
|
||||
--modal-size: min(34rem, 46vw);
|
||||
|
||||
width: min(34rem, 46vw);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.inspectorInner:has([data-widget-inspector-side="left"]) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ export type CreateModalOptions = Pick<
|
||||
| "centered"
|
||||
| "keepMounted"
|
||||
| "withCloseButton"
|
||||
| "withOverlay"
|
||||
| "zIndex"
|
||||
| "scrollAreaComponent"
|
||||
| "yOffset"
|
||||
|
||||
@@ -1904,8 +1904,7 @@
|
||||
}
|
||||
},
|
||||
"widgetQuery": {
|
||||
"loading": "Loading widget data",
|
||||
"stale": "Could not refresh {widget}; showing saved data"
|
||||
"loading": "Loading widget data"
|
||||
},
|
||||
"total": "Total",
|
||||
"filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
|
||||
import {
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from "./display";
|
||||
|
||||
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
|
||||
const t = useI18n("widget.airQuality");
|
||||
const query = clientApi.widget.airQuality.atLocation.useQuery({
|
||||
latitude: options.location.latitude,
|
||||
longitude: options.location.longitude,
|
||||
@@ -45,11 +44,6 @@ export default function AirQualityWidget({ options, width, height, displayMode }
|
||||
) : (
|
||||
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
|
||||
)}
|
||||
{query.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={query.error} label={t("name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { storedContentToPlainText } from "./content";
|
||||
|
||||
@@ -239,7 +238,6 @@ const AnchorNoteWidgetContent = ({
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<WidgetQueryErrorIndicator error={noteQuery.error} label={t("name")} />
|
||||
{(isEditing || canEdit) && (
|
||||
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
|
||||
{isEditing ? (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./app.module.css";
|
||||
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">) {
|
||||
const tCommon = useI18n("common");
|
||||
const tApp = useI18n("widget.app");
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
||||
@@ -127,11 +126,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
</Suspense>
|
||||
) : null}
|
||||
</AppLink>
|
||||
{appQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={appQuery.error} label={tApp("name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getIconUrl } from "@homarr/definitions";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
|
||||
export default function ArchiveTeamWarriorWidget({
|
||||
integrationIds,
|
||||
@@ -50,7 +49,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
const t = useI18n("widget.archiveTeamWarrior");
|
||||
const tWidgetCommon = useI18n("widget.common");
|
||||
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 projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
|
||||
@@ -74,7 +73,6 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Group>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<WidgetQueryErrorIndicator error={statusQuery.error} label={t("name")} />
|
||||
<Badge size="sm" color={getStatusColor(statusKey)}>
|
||||
{t(`status.${statusKey}`)}
|
||||
</Badge>
|
||||
|
||||
@@ -6,11 +6,9 @@ import { getQueryKey } from "@trpc/react-query";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatBytes, formatDuration } from "@homarr/common";
|
||||
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { AudioStatsContent } from "./audio-stats-content";
|
||||
@@ -23,8 +21,6 @@ export default function AudioStatsWidget({
|
||||
displayMode = "compact",
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"audioStats">) {
|
||||
const tAudio = useI18n("widget.audioStats");
|
||||
const tMediaServer = useI18n("widget.mediaServer");
|
||||
const statsInput = { integrationId: integrationIds[0] ?? "" };
|
||||
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
|
||||
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
|
||||
@@ -56,9 +52,6 @@ export default function AudioStatsWidget({
|
||||
if (displayMode === "compact") {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
|
||||
</Box>
|
||||
{summary}
|
||||
</Box>
|
||||
);
|
||||
@@ -73,10 +66,8 @@ export default function AudioStatsWidget({
|
||||
return (
|
||||
<Stack h="100%" gap="lg" p="md">
|
||||
<div style={{ minHeight: 150 }}>{summary}</div>
|
||||
{(statsError || streamsError || hasStreamErrors) && (
|
||||
{hasStreamErrors && (
|
||||
<Group justify="flex-end">
|
||||
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
|
||||
<WidgetQueryErrorIndicator error={streamsError} label={tMediaServer("name")} />
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -28,7 +28,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { getBeszelAlertsQueryInput } from "./display";
|
||||
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
|
||||
@@ -126,7 +125,6 @@ export default function BeszelAlertsWidget({
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<Group gap={0}>
|
||||
<WidgetQueryErrorIndicator error={alertsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
import { isBeszelGridMetricVisible } from "./display";
|
||||
@@ -460,7 +459,6 @@ export default function BeszelSystemGridWidget({
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||
const t = useI18n("widget.beszel");
|
||||
const tFeature = useI18n("widget.beszelSystemGrid");
|
||||
const tCommon = useI18n("common");
|
||||
const board = useRequiredBoard();
|
||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||
@@ -485,7 +483,6 @@ export default function BeszelSystemGridWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
<Center h="100%">
|
||||
@@ -514,7 +511,6 @@ export default function BeszelSystemGridWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
<Box
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
@@ -144,7 +143,6 @@ export default function BeszelSystemStatsWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
</Group>
|
||||
<Center h="100%">
|
||||
@@ -184,7 +182,6 @@ export default function BeszelSystemStatsWidget({
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<Group gap={0}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
import { getBeszelTableVisibleMetricKeys } from "./display";
|
||||
@@ -453,7 +452,6 @@ export default function BeszelSystemTableWidget({
|
||||
<div style={{ position: "relative", height: "100%" }}>
|
||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||
<Group gap={0}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
@@ -54,7 +53,6 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
|
||||
events={[]}
|
||||
failedIntegrations={[]}
|
||||
isPending={false}
|
||||
queryError={undefined}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
/>
|
||||
@@ -106,7 +104,6 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
|
||||
events={events}
|
||||
failedIntegrations={failedIntegrations}
|
||||
isPending={isPending}
|
||||
queryError={calendarQuery.error}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
@@ -120,7 +117,6 @@ interface CalendarBaseProps {
|
||||
events: CalendarEventWithSource[];
|
||||
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
|
||||
isPending: boolean;
|
||||
queryError: unknown;
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
options: WidgetComponentProps<"calendar">["options"];
|
||||
@@ -132,13 +128,11 @@ const CalendarBase = ({
|
||||
events,
|
||||
failedIntegrations,
|
||||
isPending,
|
||||
queryError,
|
||||
month,
|
||||
setMonth,
|
||||
options,
|
||||
displayMode,
|
||||
}: CalendarBaseProps) => {
|
||||
const t = useI18n("widget.calendar");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const { firstDayOfWeek } = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
@@ -170,8 +164,6 @@ const CalendarBase = ({
|
||||
events={agendaEvents}
|
||||
failedIntegrations={failedIntegrations}
|
||||
isPending={isPending}
|
||||
queryError={queryError}
|
||||
queryErrorLabel={t("name")}
|
||||
isEditMode={isEditMode}
|
||||
locale={locale}
|
||||
month={month}
|
||||
@@ -180,8 +172,6 @@ const CalendarBase = ({
|
||||
);
|
||||
}
|
||||
|
||||
const hasErrors = failedIntegrations.length > 0 || Boolean(queryError);
|
||||
|
||||
return (
|
||||
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
|
||||
<Calendar
|
||||
@@ -194,13 +184,11 @@ const CalendarBase = ({
|
||||
date={month}
|
||||
maxLevel="month"
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
static={isEditMode}
|
||||
className={classes.calendar}
|
||||
w="100%"
|
||||
h="100%"
|
||||
styles={{
|
||||
calendarHeaderControl: {
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
borderRadius: "md",
|
||||
height: isSmall ? "1.5rem" : undefined,
|
||||
width: isSmall ? "1.5rem" : undefined,
|
||||
@@ -249,17 +237,16 @@ const CalendarBase = ({
|
||||
<CalendarDay
|
||||
date={dayjs(tileDate).toDate()}
|
||||
events={eventsForDate}
|
||||
disabled={isEditMode || eventsForDate.length === 0}
|
||||
disabled={eventsForDate.length === 0}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{hasErrors && (
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group className={classes.errorIndicator} gap={0} pos="absolute">
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
@@ -270,8 +257,6 @@ interface CalendarAgendaProps {
|
||||
events: CalendarEventWithSource[];
|
||||
failedIntegrations: CalendarBaseProps["failedIntegrations"];
|
||||
isPending: boolean;
|
||||
queryError: unknown;
|
||||
queryErrorLabel: string;
|
||||
isEditMode: boolean;
|
||||
locale: string;
|
||||
month: Date;
|
||||
@@ -282,8 +267,6 @@ const CalendarAgenda = ({
|
||||
events,
|
||||
failedIntegrations,
|
||||
isPending,
|
||||
queryError,
|
||||
queryErrorLabel,
|
||||
isEditMode,
|
||||
locale,
|
||||
month,
|
||||
@@ -331,7 +314,6 @@ const CalendarAgenda = ({
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
import { WeatherDescription } from "../weather/icon";
|
||||
|
||||
@@ -25,17 +24,16 @@ export const ClockWeatherSummary = ({
|
||||
animateIcon,
|
||||
detailed,
|
||||
}: ClockWeatherSummaryProps) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useI18n("common");
|
||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const weather = weatherQuery.data;
|
||||
|
||||
if (!weather) {
|
||||
return isInitialWidgetQueryPending(weatherQuery) ? (
|
||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
||||
) : (
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
);
|
||||
if (isInitialWidgetQueryPending(weatherQuery)) {
|
||||
return <Loader size="xs" aria-label={tCommon("action.loading")} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||
@@ -72,7 +70,6 @@ export const ClockWeatherSummary = ({
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -149,8 +149,8 @@ export function HomarrDataTable<T>({
|
||||
defaultColumnProps={{
|
||||
...defaultColumnProps,
|
||||
noWrap: defaultColumnProps?.noWrap ?? true,
|
||||
draggable: !isEditMode,
|
||||
resizable: !isEditMode,
|
||||
draggable: true,
|
||||
resizable: true,
|
||||
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
|
||||
}}
|
||||
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
|
||||
|
||||
@@ -5,11 +5,10 @@ import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
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", () => ({
|
||||
useI18n: () => (key: string, values?: { widget?: string }) => {
|
||||
if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`;
|
||||
useI18n: () => (key: string) => {
|
||||
if (key === "loading") return "Loading widget data";
|
||||
return key;
|
||||
},
|
||||
@@ -26,7 +25,7 @@ const matchMedia = (query: string) => ({
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
|
||||
describe("WidgetQueryErrorIndicator", () => {
|
||||
describe("WidgetQueryLoadingState", () => {
|
||||
let host: HTMLDivElement | undefined;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
@@ -36,28 +35,6 @@ describe("WidgetQueryErrorIndicator", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("uses a localized label without exposing the raw failure", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("matchMedia", matchMedia);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
const secret = "http://admin:password@private.local/path?token=secret";
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull();
|
||||
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
|
||||
});
|
||||
|
||||
test("announces the loading state to assistive technology", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("matchMedia", matchMedia);
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { Center, Loader, VisuallyHidden } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface WidgetQueryErrorIndicatorProps {
|
||||
error: unknown;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
|
||||
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
|
||||
const t = useI18n("common.widgetQuery");
|
||||
if (!error) return null;
|
||||
|
||||
const accessibleLabel = t("stale", { widget: label });
|
||||
return (
|
||||
<Tooltip label={accessibleLabel} position="left" withArrow>
|
||||
<ActionIcon
|
||||
component="span"
|
||||
color="orange"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
role="note"
|
||||
aria-label={accessibleLabel}
|
||||
tabIndex={0}
|
||||
style={{ cursor: "help" }}
|
||||
>
|
||||
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetQueryLoadingState() {
|
||||
const t = useI18n("common.widgetQuery");
|
||||
return (
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils";
|
||||
@@ -148,9 +146,6 @@ const FailedSourceBadges = ({
|
||||
{t("source.unavailable", { source: source.integrationName })}
|
||||
</Badge>
|
||||
))}
|
||||
<Box ml="auto">
|
||||
<WidgetQueryErrorIndicator error={queryError} label={getIntegrationName("coolify")} />
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -35,7 +35,6 @@ import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
||||
import actionTargetClasses from "../../common/action-target.module.css";
|
||||
import classes from "./component.module.css";
|
||||
import TimerModal from "./TimerModal";
|
||||
@@ -165,7 +164,6 @@ export default function DnsHoleControlsWidget({
|
||||
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={summaryResults} />
|
||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
</Box>
|
||||
{controlAllButtonsVisible && (
|
||||
|
||||
@@ -32,7 +32,6 @@ import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
||||
|
||||
export default function DnsHoleSummaryWidget({
|
||||
options,
|
||||
@@ -71,7 +70,6 @@ export default function DnsHoleSummaryWidget({
|
||||
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={summaries} />
|
||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={tDns("name")} />
|
||||
</Group>
|
||||
</Box>
|
||||
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
|
||||
|
||||
@@ -55,7 +55,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { formatLocalizedDateTime } from "../common/locale";
|
||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||
@@ -314,7 +313,6 @@ export default function DownloadClientsWidget({
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={currentItems ?? []} />
|
||||
<WidgetQueryErrorIndicator error={downloadsQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Accordion, 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 { clientApi } from "@homarr/api/client";
|
||||
@@ -9,7 +9,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { formatUptime } from "../system-health";
|
||||
import { getClusterAccordionDefault, getClusterVisibleSections } from "./accordion-state";
|
||||
@@ -71,9 +70,6 @@ export const ClusterHealthMonitoring = ({
|
||||
const isTiny = displayMode !== "advanced" && width < 256;
|
||||
return (
|
||||
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
||||
</Box>
|
||||
{(isAdvanced || options.showUptime) && !isTiny && (
|
||||
<Group justify="center" wrap="nowrap">
|
||||
<Text fz="xs" fw={700} c="dimmed" ta="center">
|
||||
|
||||
@@ -45,7 +45,7 @@ import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-stor
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { CpuRing } from "./rings/cpu-ring";
|
||||
@@ -81,7 +81,6 @@ export const SystemHealthMonitoring = ({
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={healthResults} />
|
||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { useWidgetRuntimeActions } from "../../runtime-hooks";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
||||
import classes from "./component.module.css";
|
||||
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
||||
|
||||
@@ -32,7 +31,6 @@ export default function ImmichAlbumCarouselWidget({
|
||||
}: WidgetComponentProps<"immich-albumCarousel">) {
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const t = useI18n("widget.immich-albumCarousel");
|
||||
|
||||
const albumQuery = clientApi.widget.immich.getAlbum.useQuery(
|
||||
{
|
||||
@@ -71,9 +69,6 @@ export default function ImmichAlbumCarouselWidget({
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 3 }}>
|
||||
<WidgetQueryErrorIndicator error={albumQuery.error} label={t("name")} />
|
||||
</Box>
|
||||
{album.assets.length === 0 || photoAssets.length === 0 ? (
|
||||
<NoPhotosInAlbum />
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { 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 { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../../runtime-hooks";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -103,11 +103,6 @@ export default function ImmichServerStatsWidget({
|
||||
return (
|
||||
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
|
||||
{statsContent}
|
||||
{statsQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -121,15 +116,10 @@ export default function ImmichServerStatsWidget({
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("albumLimit", { count: MAX_ADVANCED_ALBUMS })}
|
||||
</Text>
|
||||
<WidgetQueryErrorIndicator error={statsQuery.error ?? albumsQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
{isInitialWidgetQueryPending(albumsQuery) ? (
|
||||
<WidgetQueryLoadingState />
|
||||
) : albumsQuery.error && albumsQuery.data === undefined ? (
|
||||
<Center h="100%" p="md">
|
||||
<WidgetQueryErrorIndicator error={albumsQuery.error} label={t("name")} />
|
||||
</Center>
|
||||
) : albums.length === 0 ? (
|
||||
<WidgetEmptyState />
|
||||
) : (
|
||||
|
||||
@@ -29,7 +29,6 @@ import { useWidgetRuntimeActions } from "../runtime-hooks";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import classes from "./component.module.css";
|
||||
import { getIndexerDisplayStatus } from "./status";
|
||||
@@ -118,7 +117,6 @@ export default function IndexerManagerWidget({
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<WidgetQueryErrorIndicator error={indexersQuery.error} label={t("title")} />
|
||||
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
|
||||
<ActionIcon
|
||||
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
|
||||
|
||||
@@ -28,7 +28,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
@@ -164,15 +163,10 @@ export default function MediaMissingWidget({
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
const queryFailure = <WidgetQueryErrorIndicator error={mediaQuery.error} label={t("name")} />;
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
<Group justify="flex-end" px="xs">
|
||||
{queryFailure}
|
||||
{partialFailures}
|
||||
</Group>
|
||||
{partialFailures}
|
||||
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{showMissing && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
@@ -211,10 +205,7 @@ export default function MediaMissingWidget({
|
||||
h="100%"
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<Group justify="flex-end" px="xs">
|
||||
{queryFailure}
|
||||
{partialFailures}
|
||||
</Group>
|
||||
{partialFailures}
|
||||
<Tabs.List grow>
|
||||
{showMissing && (
|
||||
<Tabs.Tab
|
||||
|
||||
@@ -33,7 +33,7 @@ import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function MediaReleasesWidget({
|
||||
@@ -43,7 +43,6 @@ export default function MediaReleasesWidget({
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaReleases">) {
|
||||
const t = useI18n("widget.mediaReleases");
|
||||
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
|
||||
const response = getUsableWidgetQueryData(releasesQuery);
|
||||
|
||||
@@ -70,10 +69,9 @@ export default function MediaReleasesWidget({
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{(failedIntegrations.length > 0 || releasesQuery.error) && (
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group pos="absolute" top={4} right={4} gap={0}>
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -40,7 +40,6 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["metadata"]>["transcoding"];
|
||||
@@ -262,14 +261,12 @@ export default function MediaServerWidget({
|
||||
aria-label={tSearch("placeholder")}
|
||||
leftSection={<IconSearch size="var(--mantine-font-size-xs)" aria-hidden />}
|
||||
/>
|
||||
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
{!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && (
|
||||
{!isAdvanced && hasFailedIntegrations && (
|
||||
<Group px="xs" justify="flex-end">
|
||||
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -26,7 +25,6 @@ import { views } from ".";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { HealthCheckStatus } from "./health-check-status";
|
||||
import { QueuePanel } from "./panels/queue.panel";
|
||||
@@ -115,16 +113,9 @@ export default function MediaTranscodingWidget({
|
||||
const queuePageCount = totalQueuePages ?? 1;
|
||||
const isTiny = !isAdvanced && (width < 280 || height < 140);
|
||||
const footerLayout = getTranscodingFooterLayout(width, height);
|
||||
const queryIndicator = (
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
<WidgetQueryErrorIndicator error={transcodingQuery.error} label={t("name")} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<Stack gap="xs" h="100%" p="xs" pos="relative">
|
||||
{queryIndicator}
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={widthLayout.advancedColumns} spacing="sm">
|
||||
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
|
||||
@@ -155,7 +146,6 @@ export default function MediaTranscodingWidget({
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%" pos="relative">
|
||||
{queryIndicator}
|
||||
{view === "workers" ? (
|
||||
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
|
||||
) : view === "queue" ? (
|
||||
|
||||
@@ -17,10 +17,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
|
||||
const t = useI18n("item.edit");
|
||||
const theme = useMantineTheme();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
...innerProps.advancedOptions,
|
||||
title: innerProps.advancedOptions.title ?? "",
|
||||
},
|
||||
initialValues: innerProps.advancedOptions,
|
||||
});
|
||||
const handleSubmit = (values: BoardItemAdvancedOptions) => {
|
||||
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,90 +0,0 @@
|
||||
.panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings {
|
||||
min-width: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.switchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.switchOption {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 4.25rem;
|
||||
align-items: center;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.switchOption > *,
|
||||
.switchOption :global(.mantine-Switch-root),
|
||||
.switchOption :global(.mantine-Switch-body) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.switchOption :global(.mantine-Switch-body) {
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.switchOption :global(.mantine-Switch-labelWrapper) {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
bottom: 0;
|
||||
margin-inline: calc(var(--mantine-spacing-md) * -1);
|
||||
margin-bottom: calc(var(--mantine-spacing-md) * -1);
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 96%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
@container (min-width: 30rem) {
|
||||
.switchGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.focusFrame {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
border: 2px solid var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow:
|
||||
0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent),
|
||||
0 0 2.5rem color-mix(in srgb, var(--mantine-primary-color-filled) 28%, transparent),
|
||||
0 0 0 100vmax rgb(0 0 0 / 0.58);
|
||||
clip-path: inset(-100vmax);
|
||||
transition:
|
||||
top 160ms ease,
|
||||
left 160ms ease,
|
||||
width 160ms ease,
|
||||
height 160ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.focusFrame {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ComponentType, FormEvent } from "react";
|
||||
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { schemaResolver } from "@mantine/form";
|
||||
@@ -11,7 +11,7 @@ import { objectEntries } from "@homarr/common";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
@@ -26,10 +26,8 @@ import { OPTIONS_SUPER_REFINE } from "../options";
|
||||
import type { IntegrationSelectOption } from "../widget-integration-select";
|
||||
import { WidgetIntegrationSelect } from "../widget-integration-select";
|
||||
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
|
||||
import { useWidgetEditFocus, WidgetEditFocusOverlay } from "./widget-edit-focus-overlay";
|
||||
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
|
||||
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
|
||||
import classes from "./widget-edit-modal.module.css";
|
||||
|
||||
export interface WidgetEditModalState {
|
||||
options: Record<string, unknown>;
|
||||
@@ -52,14 +50,11 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
definition: WidgetDefinition;
|
||||
value: WidgetEditModalState;
|
||||
onSuccessfulEdit: (value: WidgetEditModalState) => void;
|
||||
onPreviewChange?: (value: WidgetEditModalState) => void;
|
||||
onPreviewRestore?: () => void;
|
||||
integrationData: IntegrationSelectOption[];
|
||||
integrationSupport: boolean;
|
||||
settings: SettingsContextProps;
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
focusTargetId?: string;
|
||||
appId?: string;
|
||||
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
|
||||
onIntegrationSaved?: () => void;
|
||||
@@ -86,18 +81,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
|
||||
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const hasPreviewedRef = useRef(false);
|
||||
const hasSavedRef = useRef(false);
|
||||
const previewCallbacksRef = useRef({
|
||||
onPreviewChange: innerProps.onPreviewChange,
|
||||
onPreviewRestore: innerProps.onPreviewRestore,
|
||||
});
|
||||
previewCallbacksRef.current = {
|
||||
onPreviewChange: innerProps.onPreviewChange,
|
||||
onPreviewRestore: innerProps.onPreviewRestore,
|
||||
};
|
||||
const widgetFormId = useId();
|
||||
const { focusRect, inspectorSide } = useWidgetEditFocus(innerProps.itemId, innerProps.focusTargetId);
|
||||
|
||||
z.config({
|
||||
customError: zodErrorMap(t),
|
||||
@@ -129,47 +112,25 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
|
||||
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({
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
options: innerProps.value.options,
|
||||
integrationIds: innerProps.value.integrationIds,
|
||||
},
|
||||
validate: schemaResolver(widgetFormSchema, { sync: true }),
|
||||
initialValues: innerProps.value,
|
||||
validate: schemaResolver(
|
||||
z.object({
|
||||
options: optionsSchema,
|
||||
integrationIds: integrationIdsSchema,
|
||||
advancedOptions: z.object({
|
||||
customCssClasses: z.array(z.string()),
|
||||
borderColor: z.string(),
|
||||
}),
|
||||
}),
|
||||
{ sync: true },
|
||||
),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
onValuesChange: (values) => {
|
||||
if (!widgetFormSchema.safeParse(values).success) return;
|
||||
|
||||
applyPreview({ ...values, advancedOptions });
|
||||
},
|
||||
});
|
||||
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 canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const appId = innerProps.appId;
|
||||
@@ -249,7 +210,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
...values,
|
||||
advancedOptions,
|
||||
});
|
||||
hasSavedRef.current = true;
|
||||
actions.closeModal();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -271,202 +231,202 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const selectedIntegrationKinds = innerProps.integrationData
|
||||
.filter(({ id }) => form.values.integrationIds.includes(id))
|
||||
.map(({ kind }) => kind);
|
||||
const visibleOptions = Object.entries(options).filter(([, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
if (!Input) return false;
|
||||
return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds);
|
||||
});
|
||||
const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch");
|
||||
const otherOptions = visibleOptions.filter(([, value]) => value.type !== "switch");
|
||||
|
||||
const widgetFormContent = (
|
||||
<form id={widgetFormId} onSubmit={onFormSubmit}>
|
||||
<Stack className={classes.settings} gap="md">
|
||||
{canConfigureWidget && innerProps.integrationSupport && (
|
||||
<WidgetIntegrationSelect
|
||||
label={tItem("field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={maxIntegrations > 1}
|
||||
withAsterisk={integrationsRequired}
|
||||
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
{canConfigureWidget && switchOptions.length > 0 && (
|
||||
<Box className={classes.switchGrid}>
|
||||
{switchOptions.map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
if (!Input) return null;
|
||||
<Stack>
|
||||
{canConfigureWidget && innerProps.integrationSupport && (
|
||||
<WidgetIntegrationSelect
|
||||
label={tItem("field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={maxIntegrations > 1}
|
||||
withAsterisk={integrationsRequired}
|
||||
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
{canConfigureWidget &&
|
||||
Object.entries(options).map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
|
||||
return (
|
||||
<Box key={key} className={classes.switchOption}>
|
||||
<Input
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
itemId={innerProps.itemId}
|
||||
boardId={innerProps.boardId ?? board?.id}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
{canConfigureWidget &&
|
||||
otherOptions.map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
if (!Input) return null;
|
||||
if (
|
||||
!Input ||
|
||||
value.shouldHide?.(
|
||||
form.values.options as never,
|
||||
innerProps.integrationData
|
||||
.filter(({ id }) => form.values.integrationIds.includes(id))
|
||||
.map(({ kind }) => kind),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
itemId={innerProps.itemId}
|
||||
boardId={innerProps.boardId ?? board?.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WidgetEditFocusOverlay focusRect={focusRect} />
|
||||
<FormProvider form={form}>
|
||||
<Stack className={classes.panel} data-widget-inspector-side={inspectorSide} gap={0}>
|
||||
<Box pb="md">
|
||||
{showResourceTabs ? (
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
|
||||
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
|
||||
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="md">
|
||||
{widgetFormContent}
|
||||
</Tabs.Panel>
|
||||
{showAppTab && (
|
||||
<Tabs.Panel value="app" pt="md">
|
||||
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
{showIntegrationTab && (
|
||||
<Tabs.Panel value="integration" pt="md">
|
||||
<Stack>
|
||||
{displayedIntegrationId === null && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{tItem("integration.description")}
|
||||
</Text>
|
||||
{selectedIntegrations.map((integration) => {
|
||||
const canEdit = editableIntegrationIds.includes(integration.id);
|
||||
return (
|
||||
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{integration.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={() => beginEditingIntegration(integration.id)}
|
||||
aria-label={tItem("integration.editLabel", { name: integration.name })}
|
||||
>
|
||||
{tItem("integration.action")}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="end">
|
||||
{tItem("integration.fullAccessRequired")}
|
||||
</Text>
|
||||
)}
|
||||
</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>
|
||||
<Group className={classes.footer} justify="space-between" wrap="wrap">
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
itemId={innerProps.itemId}
|
||||
boardId={innerProps.boardId ?? board?.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showResourceTabs ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess: setAdvancedOptions,
|
||||
})
|
||||
}
|
||||
>
|
||||
{tItem("advancedOptions.label")}
|
||||
</Button>
|
||||
) : (
|
||||
<ModalFormFooter
|
||||
onCancel={actions.closeModal}
|
||||
leftSection={
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess: (value) => {
|
||||
setAdvancedOptions(value);
|
||||
applyPreview({ ...form.getValues(), advancedOptions: value });
|
||||
},
|
||||
onSuccess: setAdvancedOptions,
|
||||
})
|
||||
}
|
||||
>
|
||||
{tItem("advancedOptions.label")}
|
||||
</Button>
|
||||
<Group className={classes.footerActions} gap="xs" justify="flex-end" wrap="wrap">
|
||||
<Button type="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>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={onFormSubmit}>
|
||||
<FormProvider form={form}>
|
||||
{showResourceTabs ? (
|
||||
<Stack>
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
|
||||
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
|
||||
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="md">
|
||||
{widgetFormContent}
|
||||
</Tabs.Panel>
|
||||
{showAppTab && (
|
||||
<Tabs.Panel value="app" pt="md">
|
||||
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
{showIntegrationTab && (
|
||||
<Tabs.Panel value="integration" pt="md">
|
||||
<Stack>
|
||||
{displayedIntegrationId === null && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{tItem("integration.description")}
|
||||
</Text>
|
||||
{selectedIntegrations.map((integration) => {
|
||||
const canEdit = editableIntegrationIds.includes(integration.id);
|
||||
return (
|
||||
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{integration.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={() => beginEditingIntegration(integration.id)}
|
||||
aria-label={tItem("integration.editLabel", { name: integration.name })}
|
||||
>
|
||||
{tItem("integration.action")}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="end">
|
||||
{tItem("integration.fullAccessRequired")}
|
||||
</Text>
|
||||
)}
|
||||
</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>
|
||||
<Box
|
||||
pos="sticky"
|
||||
bottom={0}
|
||||
style={{
|
||||
marginInline: "calc(var(--mantine-spacing-md) * -1)",
|
||||
marginBottom: "calc(var(--mantine-spacing-md) * -1)",
|
||||
paddingInline: "var(--mantine-spacing-md)",
|
||||
paddingBlock: "var(--mantine-spacing-sm)",
|
||||
background: "var(--mantine-color-body)",
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{tCommon("action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
widgetFormContent
|
||||
)}
|
||||
</FormProvider>
|
||||
</>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
keepMounted: true,
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
presentation: "inspector",
|
||||
withOverlay: false,
|
||||
size: modalSizeForm,
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -9,7 +9,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
||||
import { getNetworkControllerStatusLayout } from "./layout";
|
||||
import { WifiVariant } from "./variants/wifi-variant";
|
||||
import { WiredVariant } from "./variants/wired-variant";
|
||||
@@ -35,7 +34,6 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
),
|
||||
[results],
|
||||
);
|
||||
const t = useI18n("widget.networkControllerStatus");
|
||||
const tWidgetCommon = useI18n("widget.common");
|
||||
const tCommon = useI18n("common");
|
||||
const locale = useCurrentIntlLocale();
|
||||
@@ -49,7 +47,6 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
const queryIndicators = (
|
||||
<Group gap={0} justify="flex-end">
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { WidgetComponentProps } from "../../definition";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import { getBinaryStatusKey } from "../../common/locale";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
||||
import type { NetworkControllerMatrixMetricKey, NetworkControllerMatrixSectionKey } from "./display";
|
||||
import { getNetworkControllerMatrix } from "./display";
|
||||
|
||||
@@ -57,7 +56,6 @@ export default function NetworkControllerSummaryWidget({
|
||||
const queryIndicators = (
|
||||
<Group gap={0} justify="flex-end">
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getNotificationDisplay } from "./display";
|
||||
import classes from "./component.module.css";
|
||||
@@ -67,11 +66,6 @@ export default function NotificationsWidget({
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
|
||||
<Stack w="100%" gap="xs">
|
||||
{notificationsQuery.error && (
|
||||
<Group justify="flex-end">
|
||||
<WidgetQueryErrorIndicator error={notificationsQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
)}
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{failedIntegrations.map((integration) => (
|
||||
|
||||
@@ -29,13 +29,6 @@
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.queryIndicator {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.compactRoot {
|
||||
padding: clamp(4px, 3cqi, 8px);
|
||||
gap: 2px;
|
||||
|
||||
@@ -16,13 +16,12 @@ import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "./layout-utils";
|
||||
@@ -278,9 +277,6 @@ export default function PatchMonWidget({
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.queryIndicator}>
|
||||
<WidgetQueryErrorIndicator error={statsQuery.error} label={getIntegrationName("patchmon")} />
|
||||
</div>
|
||||
{showHero && (
|
||||
<ComplianceHero
|
||||
compliancePercent={compliancePercent}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { AveragesSection } from "./averages";
|
||||
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
|
||||
@@ -72,7 +71,6 @@ export default function SpeedtestTrackerWidget({
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md" p="md">
|
||||
@@ -131,7 +129,6 @@ export default function SpeedtestTrackerWidget({
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
|
||||
{dashboardData.length > 1 && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
function round(value: number) {
|
||||
@@ -196,11 +196,6 @@ export default function StockPriceWidget({
|
||||
return (
|
||||
<Box h="100%" w="100%" pos="relative">
|
||||
{content}
|
||||
{stockQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={stockQuery.error} label={t("name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
@@ -253,7 +253,6 @@ export default function SystemResources({
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
);
|
||||
const emptyState = (
|
||||
|
||||
@@ -4,13 +4,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
|
||||
import { SystemResourceCPUChart } from "./chart/cpu-chart";
|
||||
import { SystemResourceGPUChart } from "./chart/gpu-chart";
|
||||
@@ -84,7 +82,6 @@ export default function SystemResources({
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"systemResources">) {
|
||||
const t = useI18n("widget.systemResources");
|
||||
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
|
||||
const results = getUsableWidgetQueryData(healthQuery) ?? [];
|
||||
const data = results.filter(
|
||||
@@ -98,7 +95,6 @@ export default function SystemResources({
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import type { TracearrDashboardData } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import { formatTotalTracearrBitrate } from "./bitrate";
|
||||
import { RecentActivityList } from "./recent-activity-section";
|
||||
@@ -186,7 +184,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("tracearr")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid
|
||||
@@ -223,7 +220,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("tracearr")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs" p="xs">
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -20,7 +19,6 @@ import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -212,7 +210,6 @@ function TraefikWidgetContent({
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||
</Box>
|
||||
@@ -265,7 +262,6 @@ function TraefikWidgetContent({
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md" p="md">
|
||||
@@ -281,7 +277,6 @@ function TraefikWidgetContent({
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list
|
||||
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
|
||||
import type { TimeFrame } from "./umami-utils";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
|
||||
interface UmamiContentProps {
|
||||
integrationIds: string[];
|
||||
@@ -270,14 +270,6 @@ export function UmamiContent({
|
||||
<Box className={classes.chartSurface} mt={4} style={{ flex: 1, minHeight: 0 }}>
|
||||
{displayMode === "advanced" ? advancedContent : selectedView}
|
||||
</Box>
|
||||
{(statsQuery.error || activeVisitorsQuery.error || multiEventQuery.error) && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator
|
||||
error={statsQuery.error ?? activeVisitorsQuery.error ?? multiEventQuery.error}
|
||||
label={t("name")}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
|
||||
|
||||
const statusColors: Record<UpsStatus, string> = {
|
||||
@@ -87,7 +86,6 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={summariesQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<WidgetEmptyState />
|
||||
</Box>
|
||||
@@ -118,7 +116,6 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={summariesQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
{displayMode === "advanced" ? (
|
||||
|
||||
@@ -6,14 +6,12 @@ import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatNumber } from "@homarr/common";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
import { aggregateUptimeKumaDashboards } from "./aggregate";
|
||||
@@ -254,7 +252,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
|
||||
</Group>
|
||||
{summaryContent}
|
||||
</Box>
|
||||
@@ -291,7 +288,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="lg" p="md">
|
||||
@@ -307,7 +303,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
{summaryContent}
|
||||
|
||||
@@ -110,10 +110,6 @@
|
||||
outline-offset: 0.125rem;
|
||||
}
|
||||
|
||||
.errorIndicator {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@media (any-pointer: coarse) {
|
||||
.detailsButton,
|
||||
.forecastButton,
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AdvancedWeather } from "./advanced";
|
||||
import classes from "./component.module.css";
|
||||
@@ -20,7 +19,6 @@ export default function WeatherWidget({
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"weather">) {
|
||||
const t = useI18n("widget.weather");
|
||||
const input = {
|
||||
latitude: options.location.latitude,
|
||||
longitude: options.location.longitude,
|
||||
@@ -40,11 +38,6 @@ export default function WeatherWidget({
|
||||
<CompactWeather height={height} isEditMode={isEditMode} options={options} weather={weather} width={width} />
|
||||
)}
|
||||
</Box>
|
||||
{weatherQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4} className={classes.errorIndicator}>
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -41,7 +40,7 @@ const WudWidgetContent = ({
|
||||
isAdvanced: boolean;
|
||||
}) => {
|
||||
const t = useI18n("widget.wud");
|
||||
const [data, statsQuery] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
|
||||
const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
@@ -115,11 +114,6 @@ const WudWidgetContent = ({
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="xs" h="100%">
|
||||
{Boolean(statsQuery.error) && (
|
||||
<Group justify="flex-end">
|
||||
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
)}
|
||||
{showTitle && !isTiny && (
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between" miw={0}>
|
||||
<Group gap="xs" wrap="nowrap" miw={0}>
|
||||
|
||||