From 26d12d9fad4f3701cdb16d3ab77b872a3e0e3ccc Mon Sep 17 00:00:00 2001 From: Bergasha Date: Thu, 27 Aug 2026 09:07:05 +1000 Subject: [PATCH 01/11] Fix system resources widget overflow and initial chart flicker The chart card and its outer widget shell both had overflow:visible, letting chart lines bleed past their rounded borders into neighboring widgets. Compact-mode charts also stretched to fill the widget's full resized height instead of staying compact like other widgets. Additionally, the history used to build the chart needs 2+ points to render a line - it started empty and only got its first real point on the next poll (up to 30s later), so the chart would flash between a single-value fallback and the actual graph. Both the initial render and the first state update now seed with the current value duplicated, so a flat line renders immediately with no flicker. --- .../system-resources/chart/common-chart.tsx | 2 +- .../src/system-resources/component.tsx | 33 +++++++++++-------- packages/widgets/src/widget-card-shell.tsx | 1 - 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/widgets/src/system-resources/chart/common-chart.tsx b/packages/widgets/src/system-resources/chart/common-chart.tsx index 121cd1182..cc396f8e5 100644 --- a/packages/widgets/src/system-resources/chart/common-chart.tsx +++ b/packages/widgets/src/system-resources/chart/common-chart.tsx @@ -53,7 +53,7 @@ export const CommonChart = ({ ref={ref} h={"100%"} pos={"relative"} - style={{ overflow: "visible", border: `1px solid ${borderColor}` }} + style={{ overflow: "hidden", border: `1px solid ${borderColor}` }} p={0} bg={backgroundColor} radius={board.itemRadius} diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index f17b1cfab..6ef2f6419 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -110,14 +110,13 @@ export default function SystemResources({ lastUpdatedAt.current = dataUpdatedAt; setHistoryByIntegration((previous) => Object.fromEntries( - data.map((entry) => [ - entry.integrationId, - appendBoundedHistory( - previous[entry.integrationId] ?? [], - toChartItem(entry.healthInfo), - ADVANCED_HISTORY_SIZE, - ), - ]), + data.map((entry) => { + const currentItem = toChartItem(entry.healthInfo); + // Seed with the current item when there's no history yet, so the first real + // update lands at 2 points instead of dropping back to the single-value fallback. + const seed = previous[entry.integrationId] ?? [currentItem]; + return [entry.integrationId, appendBoundedHistory(seed, currentItem, ADVANCED_HISTORY_SIZE)]; + }), ), ); }, [dataUpdatedAt, data]); @@ -126,7 +125,9 @@ export default function SystemResources({ const panelWidth = width / panelColumns; const renderCharts = (entry: (typeof data)[number], availableWidth: number, availableHeight: number) => { const currentItem = toChartItem(entry.healthInfo); - const fullHistory = historyByIntegration[entry.integrationId] ?? [currentItem]; + // Duplicate the first point so the chart has two points to draw a line from + // immediately, instead of showing the single-value fallback until the next poll. + const fullHistory = historyByIntegration[entry.integrationId] ?? [currentItem, currentItem]; const history = isAdvanced ? fullHistory : fullHistory.slice(-COMPACT_HISTORY_SIZE); return ( @@ -225,20 +226,24 @@ const SystemCharts = ({ const showNetwork = visibleCharts.includes("network"); const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode; const chartCount = visibleCharts.length; - const chartColumns = isAdvanced && width >= 560 ? 2 : 1; + const chartColumns = isAdvanced ? (width >= 1100 ? 4 : width >= 560 ? 2 : 1) : 1; const compactChartGap = 8; + const compactRowHeight = 56; const chartHeight = isAdvanced - ? 180 - : `calc((100% - ${Math.max(0, chartCount - 1) * compactChartGap}px) / ${Math.max(1, chartCount)})`; + ? 110 + : Math.max( + 28, + Math.min(compactRowHeight, (height - Math.max(0, chartCount - 1) * compactChartGap) / Math.max(1, chartCount)), + ); return ( - + {showTitle && ( {integrationName} )} - + {chartCount === 0 && (
diff --git a/packages/widgets/src/widget-card-shell.tsx b/packages/widgets/src/widget-card-shell.tsx index a8843c5c4..c5b9f93f4 100644 --- a/packages/widgets/src/widget-card-shell.tsx +++ b/packages/widgets/src/widget-card-shell.tsx @@ -11,7 +11,6 @@ import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; import classes from "./widget-card-shell.module.css"; const getOverflow = (kind: WidgetKind, hasCustomCssClasses: boolean): CSSProperties => { - if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" }; if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" }; if (hasCustomCssClasses) return {}; return { overflowX: "hidden", overflowY: "auto" }; From 73de83b48f8e840b9670ca69e3e862e9bafba63e Mon Sep 17 00:00:00 2001 From: Bergasha Date: Thu, 27 Aug 2026 10:45:54 +1000 Subject: [PATCH 02/11] Address CodeRabbit review on system resources widget PR - Render the chart tooltip via a portal so it isn't clipped by the card's overflow:hidden (needed to stop the chart line itself bleeding past its rounded corners). - Move chartColumns' width breakpoints into a lookup table and memoize the computed value, instead of an inline ternary. --- .../src/system-resources/chart/common-chart.tsx | 7 ++++++- .../widgets/src/system-resources/component.tsx | 14 +++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/widgets/src/system-resources/chart/common-chart.tsx b/packages/widgets/src/system-resources/chart/common-chart.tsx index cc396f8e5..e839f4423 100644 --- a/packages/widgets/src/system-resources/chart/common-chart.tsx +++ b/packages/widgets/src/system-resources/chart/common-chart.tsx @@ -127,7 +127,12 @@ export const CommonChart = ({ }, }} tooltipAnimationDuration={200} - tooltipProps={tooltipProps} + tooltipProps={{ + // Render via portal so the tooltip isn't clipped by the card's overflow:hidden + // (needed to stop the chart line itself bleeding past its rounded corners). + portal: typeof document !== "undefined" ? document.body : undefined, + ...tooltipProps, + }} withTooltip={height >= 64} yAxisProps={yAxisProps} fillOpacity={chartType === "area" ? 0.3 : undefined} diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index 6ef2f6419..f077659bd 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -19,6 +19,15 @@ const COMPACT_HISTORY_SIZE = 15; const ADVANCED_HISTORY_SIZE = 60; const ALL_SYSTEM_CHARTS = ["cpu", "memory", "gpu", "network"] as const; +const ADVANCED_CHART_COLUMN_BREAKPOINTS = [ + { minWidth: 1100, columns: 4 }, + { minWidth: 560, columns: 2 }, + { minWidth: 0, columns: 1 }, +] as const; + +const getAdvancedChartColumns = (width: number): number => + ADVANCED_CHART_COLUMN_BREAKPOINTS.find(({ minWidth }) => width >= minWidth)?.columns ?? 1; + type SystemChart = WidgetComponentProps<"systemResources">["options"]["visibleCharts"][number]; type ChartItem = { @@ -226,7 +235,10 @@ const SystemCharts = ({ const showNetwork = visibleCharts.includes("network"); const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode; const chartCount = visibleCharts.length; - const chartColumns = isAdvanced ? (width >= 1100 ? 4 : width >= 560 ? 2 : 1) : 1; + const chartColumns = useMemo( + () => (isAdvanced ? getAdvancedChartColumns(width) : 1), + [isAdvanced, width], + ); const compactChartGap = 8; const compactRowHeight = 56; const chartHeight = isAdvanced From 78a4f0e513155f54e4d7c3e6117a3e7dda80781c Mon Sep 17 00:00:00 2001 From: Bergasha Date: Thu, 27 Aug 2026 13:34:57 +1000 Subject: [PATCH 03/11] Fix container widgets clipping and label overlap A container's own visible card is inset from its allocated board cell by the board's standard per-item gap, but items inside it are positioned in a coordinate space sized to the un-inset column/row count - so without correcting for that, items (not just the grid's own box) spilled past the container's right/bottom edge by that inset amount, worse the busier/more zoomed-out the board is. Scales the container's inner grid content down to fit the actual card size, using the same zoom mechanism the board's own top-level canvas uses for its whole-board zoom, just one level deeper. Corrects --board-canvas-ui-scale to compensate so icon/text/custom-CSS sizing inside the container isn't affected - computed as a plain JS number rather than a CSS calc() referencing the property's own inherited value, since custom properties don't have a sequential/temporal order within the same element and doing so is a circular reference that silently invalidates the whole property for every descendant. Also fixes the collapsible container's label bar poking above the card's rounded top border via a negative offset - it now sits flush inside it. --- .../board/sections/container-section.tsx | 2 +- .../board/sections/grid/section-grid.tsx | 53 +++++++++++++++++-- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/nextjs/src/components/board/sections/container-section.tsx b/apps/nextjs/src/components/board/sections/container-section.tsx index 7e5284f1f..7e4e180ed 100644 --- a/apps/nextjs/src/components/board/sections/container-section.tsx +++ b/apps/nextjs/src/components/board/sections/container-section.tsx @@ -67,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => { + )} ); } diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 568cb8030..8abd39cbd 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -40,7 +40,6 @@ export default function MediaReleasesWidget({ options, integrationIds, width, - height, displayMode, }: WidgetComponentProps<"mediaReleases">) { const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds }); @@ -63,7 +62,7 @@ export default function MediaReleasesWidget({ {releases.map((item, index) => ( {!isAdvanced && index !== 0 && options.layout === "poster" && } - + ))} @@ -82,30 +81,27 @@ interface ItemProps { item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number]; options: WidgetComponentProps<"mediaReleases">["options"]; isAdvanced: boolean; - width: number; - height: number; } -const formatReleaseDate = (value: unknown, locale: string, compact: boolean) => { +const formatReleaseDate = (value: unknown, locale: string) => { const date = toValidDate(value); if (!date) return "—"; return Intl.DateTimeFormat(locale, { - month: compact ? "short" : "2-digit", + month: "2-digit", year: "numeric", day: "2-digit", - ...(compact ? {} : { hour: "2-digit", minute: "2-digit", hour12: false }), + hour: "2-digit", + minute: "2-digit", + hour12: false, }).format(date); }; -const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { +const Item = ({ item, options, isAdvanced }: ItemProps) => { const locale = useCurrentIntlLocale(); const t = useI18n("widget.mediaReleases"); const length = formatLength(item.length, item.type, t); - const isCompact = !isAdvanced && (width < 340 || height < 180); - const isTiny = !isAdvanced && (width < 220 || height < 105); - const showPoster = (isAdvanced || options.layout === "poster") && !isTiny; - const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360); - const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource)); + const showPoster = isAdvanced || options.layout === "poster"; + const showSide = isAdvanced || options.showType || options.showSource; const href = getSafeApplicationUrl(item.href); return ( @@ -129,7 +125,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { target={href ? "_blank" : undefined} rel={href ? SAFE_NEW_TAB_REL : undefined} pos="relative" - p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6} + p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4} h="100%" title={item.title} style={ @@ -158,57 +154,61 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { }} /> )} - + {showPoster && ( )} - + - + {item.title} - {item.subtitle !== undefined && !isTiny && ( - + {item.subtitle !== undefined && ( + {item.subtitle} )} - - - {length !== undefined && !isTiny && ( + + + {length !== undefined && ( <> )} - {item.producer !== undefined && showExtendedMetadata && ( + {item.producer !== undefined && ( <> )} - {item.rating !== undefined && showExtendedMetadata && ( + {item.rating !== undefined && ( <> )} - {item.price !== undefined && showExtendedMetadata && ( + {item.price !== undefined && ( <> )} - {item.tags.length > 0 && (isAdvanced || !isCompact) && ( + {item.tags.length > 0 && ( ) { const interactIntegrationIds = new Set( useIntegrationsWithInteractAccess() @@ -69,8 +68,7 @@ export default function MediaServerWidget({ ["options"]; } -const MediaRequestCard = ({ - request, - isTiny, - isDense, - showIntegrationSource, - canInteract, - options, -}: MediaRequestCardProps) => { +const MediaRequestCard = ({ request, isTiny, showIntegrationSource, canInteract, options }: MediaRequestCardProps) => { const board = useRequiredBoard(); const t = useI18n("widget.mediaRequests-requestList"); const requestHref = getSafeApplicationUrl(request.href); @@ -124,11 +114,21 @@ const MediaRequestCard = ({ return ( + - + {!isTiny && ( )} - - - {request.name || "unknown"} - - {request.status === "pending" ? ( - - ) : ( - - )} - - - + + {toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")} @@ -206,27 +172,52 @@ const MediaRequestCard = ({ )} - {!isTiny && ( - - - - {(request.requestedBy?.displayName ?? "") || "unknown"} - - + + + + {(request.requestedBy?.displayName ?? "") || t("unknown")} + + + + + + {request.name || t("unknown")} + + {request.status === "pending" ? ( + + ) : ( + )} diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 07a67bd34..92aaac4c6 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -100,9 +100,7 @@ export default function MediaServerWidget({ }, ] satisfies { name: keyof RequestStats; icon: Icon; number: number }[]; - const isTiny = width < 256 || height < 180; - const showSectionTitles = height >= 180; - const visibleStats = data.slice(0, height < 120 ? 4 : 8); + const isTiny = width < 256; const showIntegrationLegend = requestStats.integrations.length > 1 && height >= 240; return ( @@ -121,13 +119,11 @@ export default function MediaServerWidget({ style={{ pointerEvents: isEditMode ? "none" : undefined, overflow: "hidden" }} > - {showSectionTitles && ( - - {t("titles.stats.main")} - - )} + + {t("titles.stats.main")} + - {visibleStats.map((stat) => ( + {data.map((stat) => ( - {showSectionTitles && ( - - {t("titles.users.main")} ({t("titles.users.requests")}) - - )} + + {t("titles.users.main")} ({t("titles.users.requests")}) + {requestStats.users.map((user) => { diff --git a/packages/widgets/src/media-server/component.spec.tsx b/packages/widgets/src/media-server/component.spec.tsx index fe43d916d..55557b8b2 100644 --- a/packages/widgets/src/media-server/component.spec.tsx +++ b/packages/widgets/src/media-server/component.spec.tsx @@ -106,10 +106,10 @@ describe("SessionDetailsPopover", () => { }); describe("getMediaServerColumnVisibility", () => { - test("keeps a stable responsive column model from the zero-sized first render", () => { - expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false }); - expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false }); - expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false }); + test("keeps every compact column visible at every width", () => { + expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: true, status: true }); + expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: true }); + expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true }); expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true }); expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true }); }); diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index f4b67a648..6967fdcff 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -35,6 +35,7 @@ import { objectEntries } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; import { useI18n } from "@homarr/translation/client"; +import { zoomCompensatedSize } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; @@ -48,11 +49,9 @@ type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transc type SortColumn = "user" | "currentlyPlaying" | "status"; type SortState = { column: SortColumn; descending: boolean } | null; -export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({ - user: isAdvanced || width >= 300, - // The user (26%) and status (22%) columns together need a floor here, otherwise the - // currentlyPlaying column - the primary content - gets squeezed to almost nothing. - status: isAdvanced || width >= 540, +export const getMediaServerColumnVisibility = (_width: number, _isAdvanced: boolean) => ({ + user: true, + status: true, }); function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus { @@ -171,7 +170,6 @@ export default function MediaServerWidget({ options, integrationIds, width, - height, isEditMode, displayMode, }: WidgetComponentProps<"mediaServer">) { @@ -411,61 +409,76 @@ export default function MediaServerWidget({ - {(isAdvanced || height >= 144) && ( - - - - - {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { - count: flatSessions.length, - })} - - {totalBitrateLabel && (isAdvanced || width >= 300) && ( - <> + + + + + {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { + count: flatSessions.length, + })} + + {totalBitrateLabel && ( + <> + {isAdvanced && ( • - - {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} - - - )} - - - {uniqueIntegrations.map((integration) => ( - - - {(isAdvanced || width >= 480) && ( - - {integration.integrationName} - - )} - - ))} - + )} + + {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} + + + )} - )} + + {uniqueIntegrations.map((integration) => ( + + + + {integration.integrationName} + + + ))} + + ); } diff --git a/packages/widgets/src/minecraft/server-status/component.tsx b/packages/widgets/src/minecraft/server-status/component.tsx index d8ce9d293..38b9d423e 100644 --- a/packages/widgets/src/minecraft/server-status/component.tsx +++ b/packages/widgets/src/minecraft/server-status/component.tsx @@ -15,6 +15,7 @@ export default function MinecraftServerStatusWidget({ options, width, height, + displayMode, }: WidgetComponentProps<"minecraftServerStatus">) { const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options); const t = useI18n("widget.minecraftServerStatus"); @@ -34,10 +35,11 @@ export default function MinecraftServerStatusWidget({ const { data } = result; const title = options.title.trim().length > 0 ? options.title : options.domain; - const isDense = width < 220 || height < 120; - const showServerIcon = !isDense && height >= 144; - const showMetadata = width >= 260 && height >= 180; - const showCapacity = width >= 180 && height >= 140; + const isAdvanced = displayMode === "advanced"; + const isDense = isAdvanced && (width < 220 || height < 120); + const showServerIcon = !isAdvanced || (!isDense && height >= 144); + const showMetadata = isAdvanced && width >= 260 && height >= 180; + const showCapacity = isAdvanced && width >= 180 && height >= 140; const iconSize = Math.max(40, Math.min(80, width * 0.45, height * 0.45)); const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0; diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index ea5d1b8e3..5cb24cd9b 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -281,6 +281,7 @@ const WidgetEditPreview = ({ integrationIds={state.integrationIds} width={sourceWidth} height={sourceHeight} + displayScale={previewScale} isEditMode={isPendingCustomWidget} displayMode="compact" boardId={boardId} diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx index 2159b4c71..de38bfa72 100644 --- a/packages/widgets/src/network-controller/network-status/component.tsx +++ b/packages/widgets/src/network-controller/network-status/component.tsx @@ -1,7 +1,6 @@ "use client"; -import { useMemo } from "react"; -import { Badge, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Badge, Box, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; @@ -13,8 +12,7 @@ import { getNetworkControllerStatusLayout } from "./layout"; import { WifiVariant } from "./variants/wifi-variant"; import { WiredVariant } from "./variants/wired-variant"; -const neutralBorderColor = - "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))"; +const neutralBorderColor = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))"; export default function NetworkControllerNetworkStatusWidget({ options, @@ -22,17 +20,18 @@ export default function NetworkControllerNetworkStatusWidget({ displayMode, width, height, + displayScale = 1, }: WidgetComponentProps<"networkControllerStatus">) { const summaryQuery = clientApi.widget.networkController.summary.useQuery({ integrationIds, }); - const results = getUsableWidgetQueryData(summaryQuery) ?? []; - const summaries = useMemo( - () => - results.filter( - (result): result is typeof result & { summary: NonNullable } => result.summary !== null, - ), - [results], + const isAdvanced = displayMode === "advanced"; + let results = summaryQuery.data ?? []; + if (isAdvanced) { + results = getUsableWidgetQueryData(summaryQuery) ?? []; + } + const summaries = results.filter( + (result): result is typeof result & { summary: NonNullable } => result.summary !== null, ); const tWidgetCommon = useI18n("widget.common"); const tCommon = useI18n("common"); @@ -42,14 +41,55 @@ export default function NetworkControllerNetworkStatusWidget({ const countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0); const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0); const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0); - const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content }); - const isAdvanced = displayMode === "advanced"; + let responsiveWidth = width; + let responsiveHeight = height; + if (!isAdvanced && Number.isFinite(displayScale) && displayScale > 0) { + responsiveWidth *= displayScale; + responsiveHeight *= displayScale; + } + const layout = getNetworkControllerStatusLayout({ + width: responsiveWidth, + height: responsiveHeight, + displayMode, + content: options.content, + }); + const indicatorResults = + results.length > 0 || !summaryQuery.error + ? results + : integrationIds.map((integrationId) => ({ integrationId, error: "query" })); const queryIndicators = ( - + ); + if (!isAdvanced) { + return ( + + + {queryIndicators} + + {options.content === "wifi" ? ( + + ) : ( + + )} + + ); + } + if (summaryQuery.isPending || summaries.length === 0) { return ( @@ -63,92 +103,40 @@ export default function NetworkControllerNetworkStatusWidget({ ); } - if (isAdvanced) { - return ( - - {queryIndicators} - 1 ? layout.sourceColumns : 1} spacing="sm"> - {summaries.map(({ integration, summary, updatedAt }) => ( - - - - - - {integration.name} - - {updatedAt && ( - - {tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })} - - )} - - - {integration.kind} - - - - - - - - - - - - - ))} - - - ); - } - return ( - + {queryIndicators} - - {layout.showWifi && ( - - + 1 ? layout.sourceColumns : 1} spacing="sm"> + {summaries.map(({ integration, summary, updatedAt }) => ( + + + + + + {integration.name} + + {updatedAt && ( + + {tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })} + + )} + + + {integration.kind} + + + + + + + + + + + - )} - {layout.showWired && ( - - - - )} + ))} - + ); } diff --git a/packages/widgets/src/network-controller/network-status/layout.spec.ts b/packages/widgets/src/network-controller/network-status/layout.spec.ts index edc58f04d..36b1c9918 100644 --- a/packages/widgets/src/network-controller/network-status/layout.spec.ts +++ b/packages/widgets/src/network-controller/network-status/layout.spec.ts @@ -7,7 +7,7 @@ describe("network controller status layout", () => { { name: "minimum compact", input: { width: 160, height: 90, displayMode: "compact" as const, content: "wifi" as const }, - expected: { padding: "xs", columns: 1, showWifi: true, showWired: false, horizontalStats: false }, + expected: { padding: 4, columns: 1, showWifi: true, showWired: false, horizontalStats: true }, }, { name: "short compact", @@ -58,7 +58,13 @@ describe("network controller status layout", () => { ).toMatchObject({ padding: "sm", horizontalStats: false }); expect( getNetworkControllerStatusLayout({ width: 199, height: 119, displayMode: "compact", content: "wifi" }), - ).toMatchObject({ padding: "xs", horizontalStats: false }); + ).toMatchObject({ padding: 4, horizontalStats: true }); + expect( + getNetworkControllerStatusLayout({ width: 199, height: 80, displayMode: "compact", content: "wifi" }), + ).toMatchObject({ inlineStats: false }); + expect( + getNetworkControllerStatusLayout({ width: 199, height: 79, displayMode: "compact", content: "wifi" }), + ).toMatchObject({ inlineStats: true }); expect( getNetworkControllerStatusLayout({ width: 960, height: 320, displayMode: "advanced", content: "wifi" }), ).toMatchObject({ sourceColumns: 2 }); diff --git a/packages/widgets/src/network-controller/network-status/layout.ts b/packages/widgets/src/network-controller/network-status/layout.ts index 9b869a511..265e63ccb 100644 --- a/packages/widgets/src/network-controller/network-status/layout.ts +++ b/packages/widgets/src/network-controller/network-status/layout.ts @@ -11,7 +11,7 @@ interface NetworkControllerStatusLayoutInput { } export interface NetworkControllerStatusLayout { - padding: "xs" | "sm" | "md"; + padding: 4 | "xs" | "sm" | "md"; columns: 1 | 2; sourceColumns: 1 | 2; showWifi: boolean; @@ -20,6 +20,7 @@ export interface NetworkControllerStatusLayout { withBorder: boolean; compact: boolean; horizontalStats: boolean; + inlineStats: boolean; } export const getNetworkControllerStatusLayout = ({ @@ -29,16 +30,18 @@ export const getNetworkControllerStatusLayout = ({ content, }: NetworkControllerStatusLayoutInput): NetworkControllerStatusLayout => { const isAdvanced = displayMode === "advanced"; + const compact = !isAdvanced && (width < 240 || height < 180); return { - padding: isAdvanced ? "md" : height < 120 ? "xs" : "sm", + padding: isAdvanced ? "md" : height < 120 ? 4 : "sm", columns: isAdvanced && width >= 560 ? 2 : 1, sourceColumns: isAdvanced && width >= 960 ? 2 : 1, showWifi: isAdvanced || content === "wifi", showWired: isAdvanced || content === "wired", cardPadding: isAdvanced ? "md" : 0, withBorder: isAdvanced, - compact: !isAdvanced, - horizontalStats: !isAdvanced && height < 150 && width >= 200, + compact, + horizontalStats: compact && height < 150, + inlineStats: compact && height < 80, }; }; diff --git a/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx b/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx index e2d0e279b..9d4f62faa 100644 --- a/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx +++ b/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx @@ -1,14 +1,44 @@ -import { Stack, Text } from "@mantine/core"; +import { Group, Stack, Text } from "@mantine/core"; export const StatRow = ({ label, value, + advanced = false, compact = false, + inline = false, }: { label: string; value: string | number; + advanced?: boolean; compact?: boolean; + inline?: boolean; }) => { + if (!advanced && compact && inline) { + return ( + + + {value} + + + {label} + + + ); + } + + if (!advanced) { + return ( + + + {value} + + + {label} + + + ); + } + return ( diff --git a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx index 67b6ac28b..93657f267 100644 --- a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx +++ b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx @@ -2,22 +2,50 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { IconWifi } from "@tabler/icons-react"; import { useI18n } from "@homarr/translation/client"; -import { iconSizes } from "@homarr/ui"; +import { iconSizes, zoomCompensatedSize } from "@homarr/ui"; import { StatRow } from "./stat-row"; export const WifiVariant = ({ countGuests, countUsers, + advanced = false, compact = false, horizontal = false, + inline = false, }: { countUsers: number; countGuests: number; + advanced?: boolean; compact?: boolean; horizontal?: boolean; + inline?: boolean; }) => { const t = useI18n("widget.networkControllerStatus.card"); + if (!advanced) { + const iconSize = horizontal ? 20 : 24; + let titleMargin: 0 | "xs" | "md" = "md"; + if (compact) titleMargin = "xs"; + if (horizontal) titleMargin = 0; + let statsSpacing: number | "xs" | "lg" = "lg"; + if (compact) statsSpacing = "xs"; + if (horizontal) statsSpacing = 4; + return ( + <> + + + + {t("variants.wifi.name")} + + + + + + + + ); + } + return ( @@ -29,8 +57,8 @@ export const WifiVariant = ({ - - + + ); diff --git a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx index 4000adb10..cb5db36cd 100644 --- a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx +++ b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx @@ -2,22 +2,50 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { IconNetwork } from "@tabler/icons-react"; import { useI18n } from "@homarr/translation/client"; -import { iconSizes } from "@homarr/ui"; +import { iconSizes, zoomCompensatedSize } from "@homarr/ui"; import { StatRow } from "./stat-row"; export const WiredVariant = ({ countGuests, countUsers, + advanced = false, compact = false, horizontal = false, + inline = false, }: { countUsers: number; countGuests: number; + advanced?: boolean; compact?: boolean; horizontal?: boolean; + inline?: boolean; }) => { const t = useI18n("widget.networkControllerStatus.card"); + if (!advanced) { + const iconSize = horizontal ? 20 : 24; + let titleMargin: 0 | "xs" | "md" = "md"; + if (compact) titleMargin = "xs"; + if (horizontal) titleMargin = 0; + let statsSpacing: number | "xs" | "lg" = "lg"; + if (compact) statsSpacing = "xs"; + if (horizontal) statsSpacing = 4; + return ( + <> + + + + {t("variants.wired.name")} + + + + + + + + ); + } + return ( @@ -29,8 +57,8 @@ export const WiredVariant = ({ - - + + ); diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx index 09870e667..873f28077 100644 --- a/packages/widgets/src/network-controller/summary/component.tsx +++ b/packages/widgets/src/network-controller/summary/component.tsx @@ -1,7 +1,7 @@ "use client"; import type { CSSProperties } from "react"; -import { Badge, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core"; +import { Box, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core"; import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react"; import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; @@ -27,13 +27,16 @@ dayjs.extend(duration); export default function NetworkControllerSummaryWidget({ integrationIds, width, - height, displayMode, }: WidgetComponentProps<"networkControllerSummary">) { const summaryQuery = clientApi.widget.networkController.summary.useQuery({ integrationIds, }); - const results = getUsableWidgetQueryData(summaryQuery) ?? []; + const isAdvanced = displayMode === "advanced"; + let results = summaryQuery.data ?? []; + if (isAdvanced) { + results = getUsableWidgetQueryData(summaryQuery) ?? []; + } const summaries = results.filter( (result): result is typeof result & { summary: NonNullable; updatedAt: Date } => result.summary !== null && result.updatedAt !== undefined, @@ -51,17 +54,18 @@ export default function NetworkControllerSummaryWidget({ unknown: tDns("status.unknown"), }; - const isDense = height < 160; - const showDetails = height >= 180 && width >= 240; - const columns = width >= 720 ? 2 : 1; - const isAdvanced = displayMode === "advanced"; + const indicatorResults = + results.length > 0 || !summaryQuery.error + ? results + : integrationIds.map((integrationId) => ({ integrationId, error: "query" })); const queryIndicators = ( - + ); + const firstSummary = summaries[0]; - if (isPending || summaries.length === 0) { + if (isPending || !firstSummary) { return ( {queryIndicators} @@ -74,190 +78,152 @@ export default function NetworkControllerSummaryWidget({ ); } - if (isAdvanced) { - const controllerColumns = width >= 960 ? 2 : 1; - const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1; + if (!isAdvanced) { + const summary = firstSummary.summary; return ( - - {queryIndicators} - - {summaries.map(({ integration, summary, updatedAt }) => ( - + + {queryIndicators} + +
+ + + } > - - - - - {integration.name} - - - {integration.kind} - - - - - {t("advanced.updated")} - - - {new Date(updatedAt).toLocaleString(locale)} - - - - - {getNetworkControllerMatrix(summary).map((section) => ( - - - - - - - {getMatrixSectionLabel(section.key, t)} - - - - {statusLabels[getBinaryStatusKey(section.status)]} - - - {section.metrics.length > 0 && ( - - {section.metrics.map((metric) => ( - - - {getMatrixMetricLabel(metric.key, t)} - - - {formatMatrixMetric(metric.key, metric.value)} - - - ))} - - )} - - - ))} - - - - ))} - - + {t("card.wan")} + + + } + > + + WWW + + {summary.www.latency}ms + + + + + } + > + {t("card.wifi")} + + + } + > + + {t("card.vpn.label")} + + {t("card.vpn.countConnected", { count: summary.vpn.users })} + + + + +
+ ); } + const controllerColumns = width >= 960 ? 2 : 1; + const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1; + return ( - + {queryIndicators} - - {summaries.map(({ integration, summary }) => ( - 1} - bg="transparent" - style={{ borderColor: neutralSurfaceBorder }} - > - - {summaries.length > 1 && ( - + + {summaries.map(({ integration, summary, updatedAt }) => ( + + + + {integration.name} - + {integration.kind} - - - )} - - - } - > - {t("card.wan")} - - - } - > - - {t("card.web")} - - {summary.www.latency}ms{showDetails ? ` · ${summary.www.ping}ms` : ""} - - - - } - > - {t("card.wifi")} - {showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""} - - {showDetails && ( - - } + + + + {t("advanced.updated")} + + + {new Date(updatedAt).toLocaleString(locale)} + + + + + {getNetworkControllerMatrix(summary).map((section) => ( + - {t("card.lan")} · {summary.lan.users + summary.lan.guests} - - )} - - } - > - - {t("card.vpn.label")} - - {t("card.vpn.countConnected", { count: summary.vpn.users })} - - - - + + + + + + {getMatrixSectionLabel(section.key, t)} + + + + {statusLabels[getBinaryStatusKey(section.status)]} + + + {section.metrics.length > 0 && ( + + {section.metrics.map((metric) => ( + + + {getMatrixMetricLabel(metric.key, t)} + + + {formatMatrixMetric(metric.key, metric.value)} + + + ))} + + )} + + + ))} + ))} - +
); } @@ -280,26 +246,23 @@ const getMatrixSectionLabel = ( return t(`card.${key}` as never); }; -const neutralSurfaceBorder = - "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))"; +const neutralSurfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))"; const StatusIcon = ({ status, label, - size, style, }: { status?: "enabled" | "disabled"; label: string; - size?: number | string; style?: CSSProperties; }) => { const mantineTheme = useMantineTheme(); if (status === "enabled") { - return ; + return ; } if (status === "disabled") { - return ; + return ; } - return ; + return ; }; diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx index b020ed9bf..375ea8ad2 100644 --- a/packages/widgets/src/notifications/component.tsx +++ b/packages/widgets/src/notifications/component.tsx @@ -52,21 +52,22 @@ export default function NotificationsWidget({ const failedIntegrations = notificationIntegrations.filter( (integration): integration is typeof integration & { error: string } => Boolean(integration.error), ); + const isAdvanced = displayMode === "advanced"; const isDense = width < 280 || height < 180; const isRoomy = width >= 360 && height >= 220; const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4; const notificationDisplay = getNotificationDisplay({ displayMode, hideLogos: options.hideLogos, - isRoomy, - bodyLineClamp, + isRoomy: isAdvanced && isRoomy, + bodyLineClamp: isAdvanced ? bodyLineClamp : 4, }); - const columns = width >= 720 ? 2 : 1; - const spacing = isRoomy ? "sm" : "xs"; + const columns = isAdvanced && width >= 720 ? 2 : 1; + const spacing = !isAdvanced || isRoomy ? "sm" : "xs"; return ( - - + + {failedIntegrations.length > 0 && ( {failedIntegrations.map((integration) => ( @@ -93,34 +94,39 @@ export default function NotificationsWidget({ href={href} target={href ? "_blank" : undefined} rel={href ? SAFE_NEW_TAB_REL : undefined} - className={columns > 1 ? classes.card : classes.row} + className={isAdvanced ? (columns > 1 ? classes.card : classes.row) : undefined} radius={board.itemRadius} w="100%" - p={isRoomy ? "sm" : isDense ? 6 : "xs"} + p={!isAdvanced || isRoomy ? "sm" : isDense ? 6 : "xs"} style={{ color: "inherit", textDecoration: "none", }} > - + {notificationDisplay.showLogos && notification.source?.iconUrl && ( )} - + {notification.title && ( - + {notification.title} )} @@ -134,7 +140,7 @@ export default function NotificationsWidget({ ? (notification.source?.name ?? notification.integrationName) : undefined } - dense={isDense} + dense={isAdvanced && isDense} /> @@ -159,11 +165,7 @@ const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; den return ( - + {timeAgo} diff --git a/packages/widgets/src/paperless-ngx/component.module.css b/packages/widgets/src/paperless-ngx/component.module.css index 0d98c79eb..a945ea3f5 100644 --- a/packages/widgets/src/paperless-ngx/component.module.css +++ b/packages/widgets/src/paperless-ngx/component.module.css @@ -4,11 +4,16 @@ flex-direction: column; height: 100%; min-height: 0; - padding: clamp(6px, 3cqi, 14px); - gap: clamp(4px, 2cqi, 10px); + padding: clamp(6px, 3cqi, 12px); + gap: clamp(4px, 2cqi, 8px); overflow: hidden; } +.rootShort { + padding: 4px; + gap: 2px; +} + .hero { display: flex; align-items: center; @@ -16,8 +21,7 @@ gap: clamp(8px, 3cqi, 18px); flex-shrink: 0; padding-bottom: clamp(4px, 2cqi, 10px); - border-bottom: 1px solid - rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)); + border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); } .heroExpanded { @@ -81,13 +85,18 @@ .grid { display: grid; flex: 1; + width: 100%; min-height: 0; - gap: clamp(4px, 2cqi, 10px); + gap: clamp(4px, 2cqi, 8px); grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr)); grid-auto-rows: 1fr; align-content: stretch; } +.gridShort { + gap: 2px; +} + .statTile { display: flex; flex-direction: column; @@ -95,22 +104,37 @@ justify-content: center; min-height: 0; min-width: 0; - gap: clamp(2px, 1.5cqi, 5px); - padding: clamp(4px, 2.5cqi, 10px); + gap: clamp(2px, 1.5cqi, 4px); + padding: clamp(4px, 2cqi, 8px); border-radius: var(--mantine-radius-md); - border: 1px solid - rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)); - background-color: transparent; + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); overflow: hidden; } +.statTileShort { + gap: 2px; + padding: 3px; +} + +.statTileNarrowShort { + flex-direction: row; + gap: 2px; + padding: 1px 3px; +} + .statIcon { color: var(--mantine-color-dimmed); flex-shrink: 0; + line-height: 0; } .statLabel { - font-size: clamp(8px, 2.5cqi, 12px); + flex-shrink: 0; + font-size: clamp( + calc(8px * var(--board-canvas-ui-scale, 1)), + calc(2.5cqi * var(--board-canvas-ui-scale, 1)), + calc(11px * var(--board-canvas-ui-scale, 1)) + ); color: var(--mantine-color-dimmed); line-height: 1.2; text-align: center; @@ -120,12 +144,38 @@ white-space: nowrap; } +.statLabelShort { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .statValue { - font-size: clamp(14px, 5.5cqi, 24px); + flex-shrink: 0; + font-size: clamp( + calc(13px * var(--board-canvas-ui-scale, 1)), + calc(5cqi * var(--board-canvas-ui-scale, 1)), + calc(20px * var(--board-canvas-ui-scale, 1)) + ); font-weight: 700; - color: var(--mantine-color-text); + color: var(--mantine-primary-color-filled); line-height: 1.1; text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statValueNarrowShort { + font-size: calc(11px * var(--board-canvas-ui-scale, 1)); + line-height: 1; } .emptyState { diff --git a/packages/widgets/src/paperless-ngx/component.tsx b/packages/widgets/src/paperless-ngx/component.tsx index 25b40da61..a455c11b0 100644 --- a/packages/widgets/src/paperless-ngx/component.tsx +++ b/packages/widgets/src/paperless-ngx/component.tsx @@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client"; import { useI18n } from "@homarr/translation/client"; import { zoomCompensatedSize } from "@homarr/ui"; +import { getCompactStatLayout } from "../common/compact-stat-layout"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; @@ -32,11 +33,41 @@ const statIcons = { const gridHiddenWhenHeroShown = new Set(["documentsTotal", "documentsInbox"]); const gridColsByWidth = [ - { minWidth: 380, cols: 3 }, + { minWidth: 300, cols: 3 }, { minWidth: 220, cols: 2 }, { minWidth: 0, cols: 1 }, ] as const; +const rootClassByLayout = { + default: "", + short: classes.rootShort, + narrowShort: classes.rootShort, +} as const; + +const gridClassByLayout = { + default: "", + short: classes.gridShort, + narrowShort: classes.gridShort, +} as const; + +const statTileClassByLayout = { + default: "", + short: classes.statTileShort, + narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`, +} as const; + +const statLabelClassByLayout = { + default: "", + short: classes.statLabelShort, + narrowShort: classes.statLabelShort, +} as const; + +const statValueClassByLayout = { + default: "", + short: "", + narrowShort: classes.statValueNarrowShort, +} as const; + const ringSizeByWidth = [ { minWidth: 400, size: 88 }, { minWidth: 320, size: 72 }, @@ -76,6 +107,8 @@ export default function PaperlessNgxWidget({ options, width, height, + displayScale = 1, + displayMode = "compact", }: WidgetComponentProps<"paperlessNgx">) { const t = useI18n("widget.paperlessNgx"); const tCommon = useI18n("common"); @@ -116,19 +149,31 @@ export default function PaperlessNgxWidget({ .map(([, statKey]) => statKey) .filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey))); - const gridCols = Math.max(1, Math.min(visibleStatKeys.length, getGridCols(width, height))); - const ringSize = getRingSize(Math.min(width, height * 2)); - const iconSize = getIconSize(width); + let responsiveWidth = width; + let responsiveHeight = height; + if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) { + responsiveWidth *= displayScale; + responsiveHeight *= displayScale; + } + const advanced = displayMode === "advanced"; + const layout = getCompactStatLayout({ + width: responsiveWidth, + height: responsiveHeight, + visibleCount: visibleStatKeys.length, + compactDisplay: !advanced, + defaultColumns: getGridCols(responsiveWidth), + defaultIconSize: getIconSize(responsiveWidth), + }); + const ringSize = getRingSize(responsiveWidth); const ringLabelSize = getRingLabelSize(ringSize); const hasContent = showHero || visibleStatKeys.length > 0; const heroLayoutClass = heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats]; - const showInboxRing = options.showInboxRing && height >= 120; - const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing]; + const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing]; const visibleHeroParts = Object.entries(heroPartVisibility).filter( - ([partKey, optionKey]) => options[optionKey as keyof typeof options] && (partKey !== "ring" || showInboxRing), + ([, optionKey]) => options[optionKey as keyof typeof options], ); const heroPartRenderers = { @@ -162,7 +207,7 @@ export default function PaperlessNgxWidget({ } as const; return ( -
+
{showHero && (
{visibleHeroParts.map(([partKey]) => ( @@ -180,14 +225,19 @@ export default function PaperlessNgxWidget({ )} {visibleStatKeys.length > 0 && ( -
+
{visibleStatKeys.map((statKey) => { const Icon = statIcons[statKey]; return ( -
- - {statValues[statKey]} - {t(statKey)} +
+ + + {statValues[statKey]} + + {t(statKey)}
); })} @@ -205,9 +255,9 @@ function computeInboxProgress(total: number, inbox: number): number { return Math.round((inbox / total) * 100); } -function getGridCols(width: number, height: number): number { +function getGridCols(width: number): number { const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth); - return Math.min(match?.cols ?? 1, height < 140 ? 2 : 5); + return match?.cols ?? 1; } function getRingSize(width: number): number { diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index 6592433e8..f109fe9f1 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -65,7 +65,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }: defaultValue: {}, }); const isAdvanced = displayMode === "advanced"; - const columns = Math.max(1, Math.floor(width / 420)); + const columns = isAdvanced ? Math.max(1, Math.floor(width / 420)) : 1; const relativeDateOptions = useMemo( () => ({ diff --git a/packages/widgets/src/rssFeed/component.module.scss b/packages/widgets/src/rssFeed/component.module.scss index 60fe1a072..e8274ac96 100644 --- a/packages/widgets/src/rssFeed/component.module.scss +++ b/packages/widgets/src/rssFeed/component.module.scss @@ -24,6 +24,18 @@ flex-shrink: 0; } +.backgroundImage { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(5px); + transform: scaleX(-1.05) scaleZ(-1.05); + opacity: 0.25; +} + @media (prefers-reduced-motion: reduce) { .entry { transition: none; diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index 2d2cb92bf..158fbd06e 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Text } from "@mantine/core"; +import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { IconAlertTriangle, IconClock, IconServerOff } from "@tabler/icons-react"; import dayjs from "dayjs"; @@ -104,9 +104,10 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC descriptionLines, }); const spacing = isRoomy ? "sm" : "xs"; + const isNarrow = width < 128 * 3; return ( - + {warning && ( {warning} )} - - {feed.entries.map((feedEntry) => { - const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl }); - return ( - - - {feedEntry.enclosure !== undefined && entryDisplay.showImage && ( - - )} - - - - {feedEntry.title} - - {entryDisplay.showDescription && feedEntry.description && ( - - {feedDescriptionToText(feedEntry.description)} - + {isAdvanced ? ( + + {feed.entries.map((feedEntry) => { + const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl }); + return ( + + + {feedEntry.enclosure !== undefined && entryDisplay.showImage && ( + )} - - - - - ); - })} - + + + {feedEntry.title} + + {entryDisplay.showDescription && feedEntry.description && ( + + {feedDescriptionToText(feedEntry.description)} + + )} + + + + + + ); + })} + + ) : ( + + {feed.entries.map((feedEntry) => { + const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl }); + return ( + + {feedEntry.enclosure !== undefined && ( + + )} + + {feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && ( + {feedEntry.title} + )} + + + {feedEntry.title} + + {!options.hideDescription && feedEntry.description && ( + + {feedDescriptionToText(feedEntry.description)} + + )} + {feedEntry.published && } + + + + ); + })} + + )} ); } diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index 664d39ce1..8247f19cb 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -98,14 +98,15 @@ export default function SmartHomeEntityStateWidget({ if (entityError && entity === undefined) throw entityError; - const isTiny = isSmartHomeTiny(width, height); + const isAdvanced = displayMode === "advanced"; + const isTiny = isAdvanced ? isSmartHomeTiny(width, height) : width < 128; const advancedAttributes = [ { key: "unit" as const, value: entity?.attributes.unit_of_measurement }, { key: "deviceClass" as const, value: entity?.attributes.device_class }, { key: "icon" as const, value: entity?.attributes.icon }, ].filter(({ value }) => typeof value === "string" && value.length > 0); const displayName = - displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string" + isAdvanced && typeof entity?.attributes.friendly_name === "string" ? entity.attributes.friendly_name : options.displayName; const knownStates = { @@ -133,20 +134,34 @@ export default function SmartHomeEntityStateWidget({ }} >
- - - {state} - {attribute} - - - {displayName} - + + {isAdvanced ? ( + <> + + {state} + {attribute} + + + {displayName} + + + ) : ( + <> + + {displayName} + + + {state} + {attribute} + + + )} {queryErrorLabel && ( {queryErrorLabel} )} - {displayMode === "advanced" && entity && ( + {isAdvanced && entity && ( <> {advancedAttributes.map(({ key, value }) => ( diff --git a/packages/widgets/src/speedtest-tracker/averages.tsx b/packages/widgets/src/speedtest-tracker/averages.tsx index 103bed3fb..08f7dd4ac 100644 --- a/packages/widgets/src/speedtest-tracker/averages.tsx +++ b/packages/widgets/src/speedtest-tracker/averages.tsx @@ -10,12 +10,20 @@ import { formatStatsSpeed } from "./helpers"; import { SectionLabel } from "./section-label"; import { SpeedStatCard } from "./speed-stat-card"; -export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTrackerStats; width?: number }) { +export function AveragesSection({ + stats, + width = 500, + compactSurface = false, +}: { + stats: SpeedtestTrackerStats; + width?: number; + compactSurface?: boolean; +}) { const t = useI18n("widget.speedtestTracker"); return ( - + {t("averages")} · {t("tests", { count: stats.total_results.toString() })} @@ -25,6 +33,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke value={formatStatsSpeed(stats.download)} label={t("avgDownload")} compact + legacySurface={compactSurface} /> diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx index 74f7258ec..8ccae86e4 100644 --- a/packages/widgets/src/speedtest-tracker/component.tsx +++ b/packages/widgets/src/speedtest-tracker/component.tsx @@ -11,7 +11,7 @@ import { IntegrationErrorIndicator } from "../common/integration-error-indicator import { getUsableWidgetQueryData } from "../common/query-state"; import { WidgetEmptyState } from "../common/empty-state"; import { AveragesSection } from "./averages"; -import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers"; +import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards } from "./helpers"; import { LatestResultSection } from "./latest-result"; import { RecentResultsSection } from "./recent-results"; @@ -51,16 +51,19 @@ export default function SpeedtestTrackerWidget({ const advancedSectionWidth = width >= 720 ? width / 2 : width; const latest = showLatestResult && combined.latestResult && ( - + ); const averages = showStats && combined.stats && ( - + ); - const compactSections = getCompactSections(height, { - latest: Boolean(latest), - chart: hasChart, - averages: Boolean(averages), - }); if (displayMode === "advanced") { const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1; @@ -136,15 +139,15 @@ export default function SpeedtestTrackerWidget({ {successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")} )} - {(compactSections.latest || compactSections.averages) && ( + {(latest || averages) && ( - {compactSections.latest &&
{latest}
} - {compactSections.averages &&
{averages}
} + {latest &&
{latest}
} + {averages &&
{averages}
}
)} - {compactSections.chart && ( + {hasChart && (
- +
)} {noSectionsEnabled && ( diff --git a/packages/widgets/src/speedtest-tracker/latest-result.tsx b/packages/widgets/src/speedtest-tracker/latest-result.tsx index 8359b7cca..f7764736f 100644 --- a/packages/widgets/src/speedtest-tracker/latest-result.tsx +++ b/packages/widgets/src/speedtest-tracker/latest-result.tsx @@ -10,7 +10,15 @@ import { formatResultSpeed } from "./helpers"; import { SectionLabel } from "./section-label"; import { SpeedStatCard } from "./speed-stat-card"; -export function LatestResultSection({ result, width = 500 }: { result: SpeedtestTrackerResult; width?: number }) { +export function LatestResultSection({ + result, + width = 500, + compactSurface = false, +}: { + result: SpeedtestTrackerResult; + width?: number; + compactSurface?: boolean; +}) { const t = useI18n("widget.speedtestTracker"); const locale = useCurrentIntlLocale(); const timestamp = Intl.DateTimeFormat(locale, { @@ -25,7 +33,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest return ( - {t("latestResult")} + {t("latestResult")} {timestamp} @@ -36,18 +44,21 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest color="blue" value={formatResultSpeed(result, "download")} label={t("download")} + legacySurface={compactSurface} /> {result.healthy !== null && ( )} diff --git a/packages/widgets/src/speedtest-tracker/recent-results.tsx b/packages/widgets/src/speedtest-tracker/recent-results.tsx index beb8de735..a640912ba 100644 --- a/packages/widgets/src/speedtest-tracker/recent-results.tsx +++ b/packages/widgets/src/speedtest-tracker/recent-results.tsx @@ -114,30 +114,40 @@ function formatTooltipDate(label: number | string | undefined, locale: string): export function RecentResultsSection({ results, showPingGraph, + uppercaseLabels = false, }: { results: SpeedtestTrackerResult[]; showPingGraph: boolean; + uppercaseLabels?: boolean; }) { const t = useI18n("widget.speedtestTracker"); return ( - {t("recentResults")} + {t("recentResults")}
- - {showPingGraph && } + + {showPingGraph && }
); } -function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult[]; showLabel: boolean }) { +function SpeedSubChart({ + results, + showLabel, + uppercaseLabels, +}: { + results: SpeedtestTrackerResult[]; + showLabel: boolean; + uppercaseLabels: boolean; +}) { const t = useI18n("widget.speedtestTracker"); const { ref, height } = useElementSize(); return (
- {showLabel && {t("recentResultsSpeed")}} + {showLabel && {t("recentResultsSpeed")}}
{height > 0 && }
@@ -145,13 +155,13 @@ function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult ); } -function PingSubChart({ results }: { results: SpeedtestTrackerResult[] }) { +function PingSubChart({ results, uppercaseLabels }: { results: SpeedtestTrackerResult[]; uppercaseLabels: boolean }) { const t = useI18n("widget.speedtestTracker"); const { ref, height } = useElementSize(); return (
- {t("recentResultsPing")} + {t("recentResultsPing")}
{height > 0 && }
diff --git a/packages/widgets/src/speedtest-tracker/section-label.tsx b/packages/widgets/src/speedtest-tracker/section-label.tsx index ce7e306d2..d18cdc1a5 100644 --- a/packages/widgets/src/speedtest-tracker/section-label.tsx +++ b/packages/widgets/src/speedtest-tracker/section-label.tsx @@ -1,9 +1,15 @@ import type { ReactNode } from "react"; import { Text } from "@mantine/core"; -export function SectionLabel({ children }: { children: ReactNode }) { +export function SectionLabel({ children, uppercase = false }: { children: ReactNode; uppercase?: boolean }) { return ( - + {children} ); diff --git a/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx b/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx index af475bfbe..7372be195 100644 --- a/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx +++ b/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx @@ -13,9 +13,17 @@ export interface SpeedStatCardProps { value: string; label: string; compact?: boolean; + legacySurface?: boolean; } -export function SpeedStatCard({ icon: Icon, color, value, label, compact = false }: SpeedStatCardProps) { +export function SpeedStatCard({ + icon: Icon, + color, + value, + label, + compact = false, + legacySurface = false, +}: SpeedStatCardProps) { const { ref, height, width } = useElementSize(); const board = useRequiredBoard(); const isWide = width > height + 20; @@ -23,7 +31,8 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false const surfaceColor = `var(--mantine-color-${color}-filled)`; const accentColor = `var(--mantine-color-${color}-5)`; const surfaceAlpha = compact ? 0.1 : 0.14; - const surfaceBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`; + const modernBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`; + const surfaceBackground = legacySurface ? `var(--mantine-color-${color}-light)` : modernBackground; const surfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))"; return ( @@ -32,7 +41,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false ref={ref} p={compact ? "xs" : "sm"} radius={board.itemRadius} - withBorder + withBorder={!legacySurface} bg={surfaceBackground} h="100%" style={{ flex: 1, minWidth: 0, borderColor: surfaceBorder }} diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx index 9e7cf4d67..2fe0c9829 100644 --- a/packages/widgets/src/system-disks/component.tsx +++ b/packages/widgets/src/system-disks/component.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip } from "@mantine/core"; +import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip, useComputedColorScheme } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; @@ -28,12 +28,12 @@ const advancedColumnBreakpoints = [ { minimumWidth: 0, columns: 1 }, ] as const; -const getSystemDisksLayout = (width: number, height: number, diskCount: number, isAdvanced: boolean) => { +const getSystemDisksLayout = (width: number, diskCount: number, isAdvanced: boolean) => { if (!isAdvanced) { return { columns: 1, - showSecondaryText: height >= 160 && width >= 260, - showTemperature: height >= 100 && width >= 220, + showSecondaryText: false, + showTemperature: true, }; } @@ -113,6 +113,7 @@ const SystemDiskCard = ({ showTemperature, }: SystemDiskCardProps) => { const board = useRequiredBoard(); + const colorScheme = useComputedColorScheme("light"); const t = useI18n("widget.systemDisks"); const valueRef = useRef(null); const [valueFits, setValueFits] = useState(true); @@ -149,6 +150,10 @@ const SystemDiskCard = ({ .join(" · "); const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; + const legacyBackground = colorScheme === "dark" ? "dark.7" : "gray.1"; + const cardBackground = isAdvanced ? backgroundColor : legacyBackground; + const progressBackground = healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)"; + const legacyProgressColor = healthy ? "green" : "red"; return (
- + {deviceName} {integrationName && ( @@ -174,7 +179,11 @@ const SystemDiskCard = ({ {integrationName} )} - + {displayText} {!healthy && {unhealthyLabel}} @@ -195,10 +204,13 @@ const SystemDiskCard = ({ )}
- {showTemperature && (hasTemperature || isAdvanced) ? {temperatureText} : null} + {showTemperature && (hasTemperature || isAdvanced) ? ( + {temperatureText} + ) : null}
@@ -219,7 +231,6 @@ export default function SystemResources({ integrationIds, options, width, - height, displayMode, }: WidgetComponentProps<"systemDisks">) { const t = useI18n("widget.systemDisks"); @@ -247,8 +258,8 @@ export default function SystemResources({ }); const isAdvanced = displayMode === "advanced"; const layout = useMemo( - () => getSystemDisksLayout(width, height, disks.length, isAdvanced), - [disks.length, height, isAdvanced, width], + () => getSystemDisksLayout(width, disks.length, isAdvanced), + [disks.length, isAdvanced, width], ); const queryIndicators = ( diff --git a/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx b/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx index 7d168fcc7..756c7e65b 100644 --- a/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx +++ b/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx @@ -11,6 +11,7 @@ export const CombinedNetworkTrafficChart = ({ usageOverTime, hasShadow, labelDisplayMode, + advanced = false, }: { usageOverTime: { up: number; @@ -18,6 +19,7 @@ export const CombinedNetworkTrafficChart = ({ }[]; hasShadow: boolean; labelDisplayMode: LabelDisplayModeOption; + advanced?: boolean; }) => { const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down })); const t = useI18n("widget.systemResources.card"); @@ -35,6 +37,7 @@ export const CombinedNetworkTrafficChart = ({ yAxisProps={{ domain: [0, "dataMax"] }} chartType={hasShadow ? "area" : "line"} labelDisplayMode={labelDisplayMode} + advanced={advanced} tooltipProps={{ content: ({ payload }) => { return ( diff --git a/packages/widgets/src/system-resources/chart/common-chart.tsx b/packages/widgets/src/system-resources/chart/common-chart.tsx index e839f4423..7902b8705 100644 --- a/packages/widgets/src/system-resources/chart/common-chart.tsx +++ b/packages/widgets/src/system-resources/chart/common-chart.tsx @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { ReactNode } from "react"; +import type { CSSProperties, ReactNode } from "react"; import type { AreaChartSeries } from "@mantine/charts"; import { AreaChart, LineChart } from "@mantine/charts"; -import { Card, Center, Group, Stack, Text, useMantineTheme } from "@mantine/core"; +import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core"; import { useElementSize, useHover, useMergedRef } from "@mantine/hooks"; import type { TooltipProps, YAxisProps } from "recharts"; @@ -23,6 +23,7 @@ export const CommonChart = ({ yAxisProps, lastValue, chartType = "line", + advanced = false, }: { data: Record[]; dataKey: string; @@ -34,30 +35,32 @@ export const CommonChart = ({ yAxisProps?: Omit; lastValue?: string; chartType?: "line" | "area"; + advanced?: boolean; }) => { const { ref: elementSizeRef, height } = useElementSize(); const theme = useMantineTheme(); + const colorScheme = useComputedColorScheme("light"); const board = useRequiredBoard(); const { hovered, ref: hoverRef } = useHover(); const ref = useMergedRef(elementSizeRef, hoverRef); - const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; - const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; + const opacity = board.opacity / 100; + let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`; + const cardStyle: CSSProperties = { overflow: "hidden" }; + const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] }; + if (advanced) { + backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; + cardStyle.border = + "1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; + chartRootStyle.backgroundColor = backgroundColor; + } const ChartComponent = chartType === "line" ? LineChart : AreaChart; const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon"; const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon"; return ( - + {data.length > 1 && height > 40 && !hovered && ( - {showIcon && 100 ? 20 : 14)} stroke={1.5} />} + {showIcon && ( + 100 ? 20 : 14)} + stroke={1.5} + /> + )} {showText && ( 100 ? "md" : "xs"} fw={"bold"}> {title} @@ -83,7 +92,7 @@ export const CommonChart = ({ )} )} - {data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && ( + {advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
{lastValue} @@ -93,13 +102,19 @@ export const CommonChart = ({ {data.length <= 1 ? (
- {showIcon && 100 ? 20 : 14)} stroke={1.5} />} + {showIcon && ( + 100 ? 20 : 14)} + stroke={1.5} + /> + )} {showText && ( 100 ? "md" : "xs"} fw={"bold"} ta="center"> {title} )} - {lastValue && ( + {advanced && lastValue && ( 100 ? "md" : "xs"} fw={600} ta="center"> {lastValue} @@ -119,13 +134,7 @@ export const CommonChart = ({ withYAxis={false} withDots={false} bg={backgroundColor} - styles={{ - root: { - padding: 5, - borderRadius: theme.radius[board.itemRadius], - backgroundColor, - }, - }} + styles={{ root: chartRootStyle }} tooltipAnimationDuration={200} tooltipProps={{ // Render via portal so the tooltip isn't clipped by the card's overflow:hidden diff --git a/packages/widgets/src/system-resources/chart/cpu-chart.tsx b/packages/widgets/src/system-resources/chart/cpu-chart.tsx index 572a90b51..2922b737a 100644 --- a/packages/widgets/src/system-resources/chart/cpu-chart.tsx +++ b/packages/widgets/src/system-resources/chart/cpu-chart.tsx @@ -10,10 +10,12 @@ export const SystemResourceCPUChart = ({ cpuUsageOverTime, hasShadow, labelDisplayMode, + advanced = false, }: { cpuUsageOverTime: number[]; hasShadow: boolean; labelDisplayMode: LabelDisplayModeOption; + advanced?: boolean; }) => { const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage })); @@ -31,6 +33,7 @@ export const SystemResourceCPUChart = ({ chartType={hasShadow ? "area" : "line"} yAxisProps={{ domain: [0, 100] }} labelDisplayMode={labelDisplayMode} + advanced={advanced} tooltipProps={{ content: ({ payload }) => { const value = payload[0] ? Number(payload[0].value) : 0; diff --git a/packages/widgets/src/system-resources/chart/gpu-chart.tsx b/packages/widgets/src/system-resources/chart/gpu-chart.tsx index 40cf6ea2f..51d7ea9e5 100644 --- a/packages/widgets/src/system-resources/chart/gpu-chart.tsx +++ b/packages/widgets/src/system-resources/chart/gpu-chart.tsx @@ -10,10 +10,12 @@ export const SystemResourceGPUChart = ({ gpuUsageOverTime, hasShadow, labelDisplayMode, + advanced = false, }: { gpuUsageOverTime: number[]; hasShadow: boolean; labelDisplayMode: LabelDisplayModeOption; + advanced?: boolean; }) => { const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage })); @@ -31,6 +33,7 @@ export const SystemResourceGPUChart = ({ chartType={hasShadow ? "area" : "line"} yAxisProps={{ domain: [0, 100] }} labelDisplayMode={labelDisplayMode} + advanced={advanced} tooltipProps={{ content: ({ payload }) => { const value = payload[0] ? Number(payload[0].value) : 0; diff --git a/packages/widgets/src/system-resources/chart/memory-chart.tsx b/packages/widgets/src/system-resources/chart/memory-chart.tsx index d636b8fc9..187ecb670 100644 --- a/packages/widgets/src/system-resources/chart/memory-chart.tsx +++ b/packages/widgets/src/system-resources/chart/memory-chart.tsx @@ -12,11 +12,13 @@ export const SystemResourceMemoryChart = ({ totalCapacityInBytes, hasShadow, labelDisplayMode, + advanced = false, }: { memoryUsageOverTime: number[]; totalCapacityInBytes: number; hasShadow: boolean; labelDisplayMode: LabelDisplayModeOption; + advanced?: boolean; }) => { const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage })); const t = useI18n("widget.systemResources.card"); @@ -35,6 +37,7 @@ export const SystemResourceMemoryChart = ({ title={t("memory")} icon={IconBrain} labelDisplayMode={labelDisplayMode} + advanced={advanced} yAxisProps={{ domain: [0, totalCapacityInBytes] }} lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined} chartType={hasShadow ? "area" : "line"} diff --git a/packages/widgets/src/system-resources/chart/network-traffic.tsx b/packages/widgets/src/system-resources/chart/network-traffic.tsx index 3881923c5..44d81b17e 100644 --- a/packages/widgets/src/system-resources/chart/network-traffic.tsx +++ b/packages/widgets/src/system-resources/chart/network-traffic.tsx @@ -12,11 +12,13 @@ export const NetworkTrafficChart = ({ isUp, hasShadow, labelDisplayMode, + advanced = false, }: { usageOverTime: number[]; isUp: boolean; hasShadow: boolean; labelDisplayMode: LabelDisplayModeOption; + advanced?: boolean; }) => { const chartData = usageOverTime.map((usage, index) => ({ index, usage })); const t = useI18n("widget.systemResources.card"); @@ -36,6 +38,7 @@ export const NetworkTrafficChart = ({ lastValue={formatByteRate(Math.round(latest))} chartType={hasShadow ? "area" : "line"} labelDisplayMode={labelDisplayMode} + advanced={advanced} tooltipProps={{ content: ({ payload }) => { const value = payload[0] ? Number(payload[0].value) : 0; diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index f077659bd..e91bc2978 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -68,7 +68,7 @@ export const getVisibleSystemCharts = ({ configuredCharts, hasGpu, hasNetwork, - height, + height: _height, isAdvanced, }: { configuredCharts: readonly SystemChart[]; @@ -81,7 +81,7 @@ export const getVisibleSystemCharts = ({ const availableCharts = selectedCharts.filter( (chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork), ); - return isAdvanced ? [...availableCharts] : availableCharts.slice(0, getCompactChartBudget(height)); + return [...availableCharts]; }; export default function SystemResources({ @@ -235,10 +235,7 @@ const SystemCharts = ({ const showNetwork = visibleCharts.includes("network"); const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode; const chartCount = visibleCharts.length; - const chartColumns = useMemo( - () => (isAdvanced ? getAdvancedChartColumns(width) : 1), - [isAdvanced, width], - ); + const chartColumns = useMemo(() => (isAdvanced ? getAdvancedChartColumns(width) : 1), [isAdvanced, width]); const compactChartGap = 8; const compactRowHeight = 56; const chartHeight = isAdvanced @@ -269,6 +266,7 @@ const SystemCharts = ({ cpuUsageOverTime={items.map((item) => item.cpu)} hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> )} @@ -279,6 +277,7 @@ const SystemCharts = ({ totalCapacityInBytes={memoryCapacityInBytes} hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> )} @@ -288,6 +287,7 @@ const SystemCharts = ({ gpuUsageOverTime={items.map((item) => item.gpu)} hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> )} @@ -299,12 +299,14 @@ const SystemCharts = ({ isUp={false} hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> network.up)} isUp hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> ) : ( @@ -313,6 +315,7 @@ const SystemCharts = ({ usageOverTime={networkItems} hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> ))} diff --git a/packages/widgets/src/timetable/component.tsx b/packages/widgets/src/timetable/component.tsx index 784dbd1cd..16fd607b6 100644 --- a/packages/widgets/src/timetable/component.tsx +++ b/packages/widgets/src/timetable/component.tsx @@ -65,17 +65,12 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined; const isAdvanced = displayMode === "advanced"; const isDense = !isAdvanced && (width < 300 || height < 160); - const showTitle = isAdvanced || height >= 112; - const showLine = isAdvanced || width >= 200; - const showPlatform = isAdvanced || width >= 320; return ( - {showTitle && ( - - {t("title", { station: station.label })} - - )} + + {t("title", { station: station.label })} + {compactStaleWarning && displayMode === "compact" && ( )) @@ -144,23 +137,11 @@ interface TimetableEntryView { platform: { name: string; hasChanged: boolean } | null; } -function DepartureRow({ - entry, - dense, - showLine, - showPlatform, - locale, -}: { - entry: TimetableEntryView; - dense: boolean; - showLine: boolean; - showPlatform: boolean; - locale: string; -}) { +function DepartureRow({ entry, dense, locale }: { entry: TimetableEntryView; dense: boolean; locale: string }) { return ( - {showLine && entry.line && ( + {entry.line && ( - {showPlatform && entry.platform && ( - + {entry.platform && ( + {entry.platform.name} )} diff --git a/packages/widgets/src/tracearr/component.spec.ts b/packages/widgets/src/tracearr/component.spec.ts index 3a678fc2f..c289f04e8 100644 --- a/packages/widgets/src/tracearr/component.spec.ts +++ b/packages/widgets/src/tracearr/component.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getAdvancedColumnWidth, getCompactSectionVisibility, getTracearrSectionVisibility } from "./component"; +import { getAdvancedColumnWidth, getTracearrSectionVisibility } from "./component"; import { attachTracearrSource } from "./source"; describe("Tracearr source ownership", () => { @@ -30,32 +30,6 @@ describe("Tracearr advanced layout", () => { }); }); -describe("Tracearr compact disclosure", () => { - it("keeps urgent violations visible in a short widget", () => { - expect( - getCompactSectionVisibility({ - height: 160, - showStreams: true, - showViolations: true, - hasViolations: true, - showRecentActivity: true, - }), - ).toEqual({ violations: true, recentActivity: false }); - }); - - it("reveals secondary history as height becomes available", () => { - expect( - getCompactSectionVisibility({ - height: 400, - showStreams: true, - showViolations: true, - hasViolations: false, - showRecentActivity: true, - }), - ).toEqual({ violations: true, recentActivity: true }); - }); -}); - describe("Tracearr advanced disclosure", () => { it("shows all sections even when compact presentation toggles are disabled", () => { expect( diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index 74d5542d5..b2793cbc7 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -33,54 +33,27 @@ export const getTracearrSectionVisibility = (options: TracearrSectionOptions, is recentActivity: isAdvanced || options.showRecentActivity, }); -export const getCompactSectionVisibility = ({ - height, - showStreams, - showViolations, - hasViolations, - showRecentActivity, -}: { - height: number; - showStreams: boolean; - showViolations: boolean; - hasViolations: boolean; - showRecentActivity: boolean; -}) => ({ - violations: showViolations && (hasViolations || height >= 240 || !showStreams), - recentActivity: showRecentActivity && (height >= 360 || (!showStreams && (!showViolations || !hasViolations))), -}); - export default function TracearrWidget({ options, integrationIds, width, - height, displayMode = "compact", }: WidgetComponentProps<"tracearr">) { if (integrationIds.length === 0) { throw new NoIntegrationDataError(); } - return ( - - ); + return ; } interface TracearrContentProps { integrationIds: string[]; options: WidgetComponentProps<"tracearr">["options"]; width: number; - height: number; displayMode: "compact" | "advanced"; } -function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) { +function TracearrContent({ integrationIds, options, width, displayMode }: TracearrContentProps) { const t = useI18n("widget.tracearr"); const dashboardQuery = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds }); const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? []; @@ -157,7 +130,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } const sectionVisibility = getTracearrSectionVisibility(options, isAdvanced); const noSectionsEnabled = Object.values(sectionVisibility).every((isVisible) => !isVisible); - const compactLimit = height < 220 ? 2 : height < 360 ? 4 : 8; const sourcedStreams = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) => attachTracearrSource(dashboard.streams.data, { integrationId, integrationName }), ); @@ -167,15 +139,7 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } const sourcedRecentActivity = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) => attachTracearrSource(dashboard.recentActivity?.data ?? [], { integrationId, integrationName }), ); - const streams = isAdvanced ? sourcedStreams : sourcedStreams.slice(0, compactLimit); const showSource = isAdvanced || dashboardData.length > 1; - const compactSections = getCompactSectionVisibility({ - height, - showStreams: sectionVisibility.streams, - showViolations: sectionVisibility.violations, - hasViolations: sourcedViolations.length > 0, - showRecentActivity: sectionVisibility.recentActivity, - }); if (isAdvanced) { const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT; @@ -196,18 +160,18 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } {successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")} {sectionVisibility.stats && ( - + )} {sectionVisibility.streams && ( - + )} {sectionVisibility.violations && ( - + )} {sectionVisibility.recentActivity && ( - + )} @@ -231,12 +195,10 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } {sectionVisibility.stats && ( )} - {compactSections.violations && ( - - )} - {sectionVisibility.streams && } - {compactSections.recentActivity && ( - + {sectionVisibility.streams && } + {sectionVisibility.violations && } + {sectionVisibility.recentActivity && ( + )} {noSectionsEnabled && ( diff --git a/packages/widgets/src/tracearr/recent-activity-section.tsx b/packages/widgets/src/tracearr/recent-activity-section.tsx index b52c5f1cc..011fcde6f 100644 --- a/packages/widgets/src/tracearr/recent-activity-section.tsx +++ b/packages/widgets/src/tracearr/recent-activity-section.tsx @@ -10,16 +10,18 @@ type SourcedTracearrHistorySession = SourcedTracearrItem export function RecentActivityList({ sessions, showSource, + transparent = false, }: { sessions: SourcedTracearrHistorySession[]; showSource: boolean; + transparent?: boolean; }) { const t = useI18n("widget.tracearr"); const locale = useCurrentIntlLocale(); return ( - + {t("recentActivity.title")} {sessions.length === 0 ? ( @@ -35,7 +37,7 @@ export function RecentActivityList({ : session.mediaTitle; return ( - + diff --git a/packages/widgets/src/tracearr/stats-section.tsx b/packages/widgets/src/tracearr/stats-section.tsx index 94648306b..d95e8ccf7 100644 --- a/packages/widgets/src/tracearr/stats-section.tsx +++ b/packages/widgets/src/tracearr/stats-section.tsx @@ -8,10 +8,12 @@ export function StatsBar({ stats, summary, width, + transparent = false, }: { stats: TracearrDashboardData["stats"]; summary: TracearrDashboardData["streams"]["summary"]; width: number; + transparent?: boolean; }) { const t = useI18n("widget.tracearr"); const cols = width > 400 ? 4 : width > 250 ? 2 : 1; @@ -22,29 +24,43 @@ export function StatsBar({ icon={} label={t("stats.activeStreams")} value={stats.activeStreams} + transparent={transparent} /> } label={t("stats.totalUsers")} value={stats.totalUsers} + transparent={transparent} /> } label={t("stats.transcodes")} value={`${summary.transcodes}/${summary.total}`} + transparent={transparent} /> } label={t("stats.bandwidth")} value={summary.totalBitrate} + transparent={transparent} /> ); } -function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) { +function StatCard({ + icon, + label, + value, + transparent, +}: { + icon: React.ReactNode; + label: string; + value: string | number; + transparent: boolean; +}) { return ( - + {icon} diff --git a/packages/widgets/src/tracearr/streams-section.tsx b/packages/widgets/src/tracearr/streams-section.tsx index 6a4096146..8d4388491 100644 --- a/packages/widgets/src/tracearr/streams-section.tsx +++ b/packages/widgets/src/tracearr/streams-section.tsx @@ -13,10 +13,12 @@ export function StreamsList({ streams, width, showSource, + transparent = false, }: { streams: SourcedTracearrStream[]; width: number; showSource: boolean; + transparent?: boolean; }) { const t = useI18n("widget.tracearr"); @@ -31,7 +33,13 @@ export function StreamsList({ return ( {streams.map((stream) => ( - + ))} ); @@ -41,10 +49,12 @@ function StreamCard({ stream, compact, showSource, + transparent, }: { stream: SourcedTracearrStream; compact: boolean; showSource: boolean; + transparent: boolean; }) { const t = useI18n("widget.tracearr"); const progressPercent = @@ -65,7 +75,12 @@ function StreamCard({ : null; return ( - + {stream.posterUrl && ( ; export function ViolationsList({ violations, showSource, + transparent = false, }: { violations: SourcedTracearrViolation[]; showSource: boolean; + transparent?: boolean; }) { const t = useI18n("widget.tracearr"); const locale = useCurrentIntlLocale(); return ( - + {t("violations.title")} {violations.length === 0 ? ( @@ -30,7 +32,7 @@ export function ViolationsList({ ) : ( {violations.map((violation) => ( - + { }); describe("Traefik advanced disclosure", () => { - test("shows every protocol regardless of compact toggles and height", () => { - expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, 100, true)).toEqual(["http", "tcp", "udp"]); + test("shows every protocol in advanced mode regardless of compact toggles", () => { + expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, true)).toEqual(["http", "tcp", "udp"]); }); test("shows every unique entry point regardless of compact width limits", () => { diff --git a/packages/widgets/src/traefik/component.tsx b/packages/widgets/src/traefik/component.tsx index b2320f4b6..725ce5c3d 100644 --- a/packages/widgets/src/traefik/component.tsx +++ b/packages/widgets/src/traefik/component.tsx @@ -79,12 +79,11 @@ export const getTraefikSourceDetails = (sources: readonly TraefikDashboardSource export function getTraefikProtocolKeys( options: Pick["options"], "showTcp" | "showUdp">, - height: number, isAdvanced: boolean, ): ProtocolKey[] { const protocols: ProtocolKey[] = ["http"]; - if (isAdvanced || (options.showTcp && height >= 220)) protocols.push("tcp"); - if (isAdvanced || (options.showUdp && height >= 300)) protocols.push("udp"); + if (isAdvanced || options.showTcp) protocols.push("tcp"); + if (isAdvanced || options.showUdp) protocols.push("udp"); return protocols; } @@ -105,7 +104,6 @@ function TraefikWidgetContent({ integrationIds, options, width, - height, displayMode = "compact", }: WidgetComponentProps<"traefik">) { const t = useI18n("widget.traefik"); @@ -143,7 +141,7 @@ function TraefikWidgetContent({ combined.failedEndpoints.length; const totalWarnings = getProtocolWarnings(combined.http) + getProtocolWarnings(combined.tcp) + getUdpWarnings(combined.udp); - const protocolKeys = getTraefikProtocolKeys(options, height, isAdvanced); + const protocolKeys = getTraefikProtocolKeys(options, isAdvanced); const entryPoints = getVisibleTraefikEntryPoints(combined.entryPoints, width, isAdvanced); const displayedVersion = successfulData.length === 1 ? successfulData[0]?.dashboard.version : null; @@ -194,7 +192,7 @@ function TraefikWidgetContent({ ))}
- {(isAdvanced || options.showEntryPoints) && entryPoints.length > 0 && (isAdvanced || height >= 340) && ( + {(isAdvanced || options.showEntryPoints) && entryPoints.length > 0 && (
{entryPoints.map((entryPoint) => ( diff --git a/packages/widgets/src/umami/umami-layout.spec.ts b/packages/widgets/src/umami/umami-layout.spec.ts index b03f5af51..0875696b0 100644 --- a/packages/widgets/src/umami/umami-layout.spec.ts +++ b/packages/widgets/src/umami/umami-layout.spec.ts @@ -6,13 +6,9 @@ describe("Umami layout", () => { test.each([ { width: 260, height: 119, property: "isDense" as const, expected: true }, { width: 260, height: 120, property: "isDense" as const, expected: false }, - { width: 260, height: 139, property: "showXAxis" as const, expected: false }, - { width: 260, height: 140, property: "showXAxis" as const, expected: true }, - { width: 260, height: 95, property: "showSecondaryStats" as const, expected: false }, - { width: 260, height: 96, property: "showSecondaryStats" as const, expected: true }, - { width: 259, height: 150, property: "showDetailedStats" as const, expected: false }, - { width: 260, height: 149, property: "showDetailedStats" as const, expected: false }, - { width: 260, height: 150, property: "showDetailedStats" as const, expected: true }, + { width: 100, height: 80, property: "showXAxis" as const, expected: true }, + { width: 100, height: 80, property: "showSecondaryStats" as const, expected: true }, + { width: 100, height: 80, property: "showDetailedStats" as const, expected: true }, ])("sets $property to $expected at $width x $height", ({ width, height, property, expected }) => { expect(getUmamiLayout({ width, height, displayMode: "compact" })[property]).toBe(expected); }); diff --git a/packages/widgets/src/umami/umami-layout.ts b/packages/widgets/src/umami/umami-layout.ts index 05f02140f..37d8124f0 100644 --- a/packages/widgets/src/umami/umami-layout.ts +++ b/packages/widgets/src/umami/umami-layout.ts @@ -8,9 +8,9 @@ export const getUmamiLayout = ({ width, height, displayMode }: UmamiLayoutInput) const isAdvanced = displayMode === "advanced"; return { isDense: !isAdvanced && height < 120, - showXAxis: isAdvanced || height >= 140, - showSecondaryStats: isAdvanced || height >= 96, - showDetailedStats: isAdvanced || (width >= 260 && height >= 150), + showXAxis: true, + showSecondaryStats: true, + showDetailedStats: true, stackAdvancedContent: isAdvanced && width < 900, }; }; diff --git a/packages/widgets/src/uptime-kuma/aggregate.spec.ts b/packages/widgets/src/uptime-kuma/aggregate.spec.ts index 4982f5331..c4aaf9f18 100644 --- a/packages/widgets/src/uptime-kuma/aggregate.spec.ts +++ b/packages/widgets/src/uptime-kuma/aggregate.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { aggregateUptimeKumaDashboards } from "./aggregate"; -import { getCompactStatLimit, getCompactStatPriority } from "./component"; describe("aggregateUptimeKumaDashboards", () => { it("weights uptime by monitors instead of integrations", () => { @@ -30,26 +29,3 @@ describe("aggregateUptimeKumaDashboards", () => { expect(combined.averageUptimePercent).toBe(25); }); }); - -describe("getCompactStatLimit", () => { - it("reserves compact height for the uptime summary", () => { - expect(getCompactStatLimit(150, true, 4)).toBe(0); - expect(getCompactStatLimit(220, true, 4)).toBe(2); - }); - - it("uses the space for metrics when the summary is disabled", () => { - expect(getCompactStatLimit(100, false, 4)).toBe(2); - expect(getCompactStatLimit(240, false, 4)).toBe(4); - }); -}); - -describe("getCompactStatPriority", () => { - it("puts active failures before healthy totals", () => { - expect(getCompactStatPriority("downCount", 2)).toBeLessThan(getCompactStatPriority("totalMonitors", 10)); - expect(getCompactStatPriority("pausedCount", 1)).toBeLessThan(getCompactStatPriority("upCount", 8)); - }); - - it("moves zero-value problem states behind healthy totals", () => { - expect(getCompactStatPriority("downCount", 0)).toBeGreaterThan(getCompactStatPriority("upCount", 8)); - }); -}); diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index d21a89b13..908d8ed52 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -79,25 +79,6 @@ const heroVariantByRing = { false: classes.heroTextOnly, } as const; -export function getCompactStatLimit(height: number, showHero: boolean, statCount: number): number { - if (showHero) { - if (height < 180) return 0; - if (height < 260) return Math.min(2, statCount); - if (height < 340) return Math.min(4, statCount); - } else { - if (height < 130) return Math.min(2, statCount); - if (height < 220) return Math.min(4, statCount); - } - return statCount; -} - -export function getCompactStatPriority(statKey: keyof typeof statIcons, value: number): number { - if (statKey === "downCount") return value > 0 ? 0 : 4; - if (statKey === "pausedCount") return value > 0 ? 1 : 5; - if (statKey === "totalMonitors") return 2; - return 3; -} - export default function UptimeKumaWidget({ integrationIds, options, @@ -158,16 +139,8 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode .map(([, statKey]) => statKey); const showHero = isAdvanced || options.showAverageUptime; - const showRing = isAdvanced || (options.showUptimeRing && width >= 170 && height >= 110); - const prioritizedStatKeys = isAdvanced - ? enabledStatKeys - : enabledStatKeys.toSorted( - (left, right) => getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]), - ); - const visibleStatKeys = prioritizedStatKeys.slice( - 0, - isAdvanced ? prioritizedStatKeys.length : getCompactStatLimit(height, showHero, prioritizedStatKeys.length), - ); + const showRing = isAdvanced || options.showUptimeRing; + const visibleStatKeys = enabledStatKeys; const hasContent = showHero || visibleStatKeys.length > 0; const heroLayoutClass = @@ -227,24 +200,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode })}
)} - {displayMode === "compact" && height >= 300 && combined.downCount > 0 && ( - - {monitors - .filter((monitor) => monitor.status === "down") - .slice(0, 3) - .map((monitor) => ( - - - {monitor.name} - - - {t("status.down")} ·{" "} - {monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`} - - - ))} - - )}
); From 9c441571bc66606561fb35b2a38c19b84ccbf1ce Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 27 Aug 2026 11:08:27 +0200 Subject: [PATCH 08/11] fix: improve mobile header and board menus (#6715) * fix: improve mobile header and board menus * fix: keep mobile header logo condensed * refactor: share mobile breakpoint handling --- .../app/[locale]/boards/_layout-creator.tsx | 2 +- .../_components/_profile-avatar-form.tsx | 5 +- .../board/board-switcher.module.css | 20 ++++++ .../src/components/board/board-switcher.tsx | 8 ++- .../items/widget-context-menu.module.css | 14 ++++ .../board/items/widget-context-menu.tsx | 70 ++++++++++++++----- apps/nextjs/src/components/layout/header.tsx | 3 +- .../header/configurable-header.module.css | 63 ++++++++++++++--- .../layout/header/configurable-header.tsx | 69 ++++++++++-------- apps/nextjs/src/components/layout/shell.tsx | 4 +- .../src/components/onboarding/manage-tour.tsx | 4 +- packages/ui/src/hooks/index.ts | 1 + packages/ui/src/hooks/use-is-mobile.ts | 9 +++ 13 files changed, 208 insertions(+), 64 deletions(-) create mode 100644 apps/nextjs/src/components/board/items/widget-context-menu.module.css create mode 100644 packages/ui/src/hooks/use-is-mobile.ts diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index b23b0b081..69b39a38a 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -111,7 +111,7 @@ export const createBoardLayout = ({ } - logoWithTitle={} + logoWithTitle={} actions={headerActions} boardEditAction={headerBoardEditAction} boardSettingsAction={headerBoardSettingsAction} diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx index 128d3c4cc..2a0830a0b 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core"; -import { useDisclosure, useMediaQuery } from "@mantine/hooks"; +import { useDisclosure } from "@mantine/hooks"; import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; @@ -12,6 +12,7 @@ import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { UserAvatar } from "@homarr/ui"; +import { useIsMobile } from "@homarr/ui/hooks"; interface UserProfileAvatarForm { user: RouterOutputs["user"]["getById"]; @@ -25,7 +26,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { }, }); const [opened, { toggle }] = useDisclosure(false); - const isMobile = useMediaQuery("(max-width: 48em)"); + const isMobile = useIsMobile(); const { openConfirmModal } = useConfirmModal(); const tCommon = useI18n("common"); const tManageAvatar = useI18n("user.action.manageAvatar"); diff --git a/apps/nextjs/src/components/board/board-switcher.module.css b/apps/nextjs/src/components/board/board-switcher.module.css index 6843fe497..e4bbc3aea 100644 --- a/apps/nextjs/src/components/board/board-switcher.module.css +++ b/apps/nextjs/src/components/board/board-switcher.module.css @@ -15,6 +15,10 @@ outline: none; } +.results { + min-height: 0; +} + .searchInput { border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent); background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent); @@ -44,3 +48,19 @@ border-color: var(--mantine-primary-color-filled); box-shadow: var(--mantine-shadow-md); } + +@media (max-width: $mantine-breakpoint-sm) { + .modalContent { + height: calc(100dvh - var(--modal-y-offset) * 2); + } + + .modalBody, + .content { + min-height: 0; + height: 100%; + } + + .results { + flex: 1; + } +} diff --git a/apps/nextjs/src/components/board/board-switcher.tsx b/apps/nextjs/src/components/board/board-switcher.tsx index 8b9505aae..786ad582a 100644 --- a/apps/nextjs/src/components/board/board-switcher.tsx +++ b/apps/nextjs/src/components/board/board-switcher.tsx @@ -228,7 +228,13 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { radius="xl" classNames={{ input: classes.searchInput }} /> - + {results}
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.module.css b/apps/nextjs/src/components/board/items/widget-context-menu.module.css new file mode 100644 index 000000000..80c9cf80d --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-context-menu.module.css @@ -0,0 +1,14 @@ +.mobileDrawerContent { + height: min(80dvh, rem(640px)); + max-height: 80dvh; + overscroll-behavior: contain; +} + +.mobileDrawerBody { + padding-bottom: max(var(--mantine-spacing-md), env(safe-area-inset-bottom)); +} + +.mobileActionList { + display: flex; + flex-direction: column; +} diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index a4fc9b52a..c8821aac4 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -2,7 +2,7 @@ import type { MutableRefObject, ReactNode, RefObject } from "react"; import { useCallback, useMemo, useState } from "react"; -import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core"; +import { Drawer, Group, Loader, Menu, Text, Tooltip } from "@mantine/core"; import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { useIsFetching, useQueryClient } from "@tanstack/react-query"; @@ -20,6 +20,7 @@ import { getWidgetName } from "@homarr/definitions"; import { translateIfNecessary } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; +import { useIsMobile } from "@homarr/ui/hooks"; import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; @@ -31,6 +32,9 @@ import { useBoardPermissions } from "../permissions/client"; import { useItemActions } from "./item-actions"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; import { matchesWidgetItemQuery } from "./widget-query-scope"; +import classes from "./widget-context-menu.module.css"; + +type ToggleOption = [key: string, option: { type: string; skipContextMenu?: boolean }]; interface WidgetContextMenuProps { item: SectionItem; @@ -134,11 +138,12 @@ export const WidgetContextMenu = ({ [item.id, persistBoard], ); - type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { if (!canConfigureWidget) return []; - const rawOptions = definition.createOptions(settings) as unknown as Record; - return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu); + const rawOptions = definition.createOptions(settings) as unknown as Record; + return Object.entries(rawOptions).filter( + ([, option]) => option.type === "switch" && !option.skipContextMenu, + ) as ToggleOption[]; }, [canConfigureWidget, definition, settings]); const widgetContextActions = @@ -222,6 +227,18 @@ export const WidgetContextMenu = ({ [setItemOptions], ); + const handleOpenAdvancedFocus = useCallback(() => { + if (!sourceRef.current) return; + openAdvancedFocus(item.id, sourceRef.current, { + restoreFocusTarget: + sourceRef.current.querySelector("[data-advanced-focus-trigger]") ?? sourceRef.current, + }); + }, [item.id, openAdvancedFocus, sourceRef]); + + const handleCloseMenu = useCallback(() => { + setMenuOpened(false); + }, []); + if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") { return <>{children}; } @@ -239,21 +256,10 @@ export const WidgetContextMenu = ({ onChange={setMenuOpened} > {children} - + {canOpenAdvancedFocus && ( <> - } - onClick={() => { - if (sourceRef.current) - openAdvancedFocus(item.id, sourceRef.current, { - restoreFocusTarget: - sourceRef.current.querySelector("[data-advanced-focus-trigger]") ?? - sourceRef.current, - }); - }} - > + } onClick={handleOpenAdvancedFocus}> {t("item.advancedFocus.open")} @@ -319,11 +325,39 @@ export const WidgetContextMenu = ({ > {tMenu("settings")} - + ); }; +interface WidgetContextMenuDropdownProps { + opened: boolean; + onClose: () => void; + title: string; + children: ReactNode; +} + +const WidgetContextMenuDropdown = ({ opened, onClose, title, children }: WidgetContextMenuDropdownProps) => { + const isMobile = useIsMobile(); + + if (!isMobile) return {children}; + + return ( + +
+ {children} +
+
+ ); +}; + interface WidgetQueryStatusProps { queryClient: QueryClient; matchesQuery: (queryKey: QueryKey) => boolean; diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index 4f268039e..b35d4f686 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -27,11 +27,12 @@ export const MainHeader = async ({ const session = await auth(); const isAdmin = Boolean(session?.user.permissions.includes("admin")); const isDockerEnabled = isAdmin && env.ENABLE_DOCKER; + const resolvedLogoWithTitle = logoWithTitle ?? ; return ( } - logoWithTitle={logoWithTitle ?? } + logoWithTitle={resolvedLogoWithTitle} actions={actions} boardEditAction={boardEditAction} boardSettingsAction={boardSettingsAction} diff --git a/apps/nextjs/src/components/layout/header/configurable-header.module.css b/apps/nextjs/src/components/layout/header/configurable-header.module.css index 2b851745d..698943cb6 100644 --- a/apps/nextjs/src/components/layout/header/configurable-header.module.css +++ b/apps/nextjs/src/components/layout/header/configurable-header.module.css @@ -2,6 +2,15 @@ overflow: hidden; } +.header[data-desktop-visible="false"] { + display: none; +} + +.desktopContent { + width: 100%; + height: 100%; +} + .content { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -64,6 +73,41 @@ flex: 0 0 auto; } +.mobileContent { + display: none; + align-items: center; + justify-content: space-between; + gap: var(--mantine-spacing-xs); + width: 100%; + height: 100%; + padding-inline: var(--mantine-spacing-xs); +} + +.mobileIdentity, +.mobileActions { + min-width: 0; +} + +.mobileIdentity { + flex: 1 1 auto; + overflow: hidden; +} + +.mobileIdentity > :last-child { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} + +.mobileIdentity h2 { + overflow: hidden; + text-overflow: ellipsis; +} + +.mobileActions { + flex: 0 0 auto; +} + .floatingControls { position: fixed; z-index: var(--homarr-z-index-board-header); @@ -111,18 +155,21 @@ } @media (max-width: $mantine-breakpoint-sm) { - .content { - gap: var(--mantine-spacing-xs); - padding-inline: var(--mantine-spacing-xs); + .header[data-desktop-visible="false"] { + display: block; } - .zone { - gap: var(--mantine-spacing-xs); + .desktopContent { + display: none; } - .search { - width: auto; - flex: 0 0 auto; + .mobileContent { + display: flex; + } + + .floatingControls, + .floatingControlsCorner { + display: none; } } diff --git a/apps/nextjs/src/components/layout/header/configurable-header.tsx b/apps/nextjs/src/components/layout/header/configurable-header.tsx index 5d4bf0ef3..cdbd10f3f 100644 --- a/apps/nextjs/src/components/layout/header/configurable-header.tsx +++ b/apps/nextjs/src/components/layout/header/configurable-header.tsx @@ -2,15 +2,7 @@ import type { ReactNode } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; -import { - AppShellHeader, - Avatar, - Group, - Indicator, - Loader, - Tooltip, - useMantineColorScheme, -} from "@mantine/core"; +import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core"; import { IconBrandDocker, IconHome, @@ -116,14 +108,15 @@ export const ConfigurableHeader = ({ return ( <> - {headerPreferences.visible ? ( - + +
{hasNavigation ? : null} @@ -137,8 +130,31 @@ export const ConfigurableHeader = ({ {actions ? {actions} : null}
- - ) : ( +
+
+ + {hasNavigation ? : null} + + + + {actions} + {boardEditAction} + {boardSettingsAction} + + + + + + + +
+
+ {!headerPreferences.visible ? ( {hasNavigation ? : null} - )} + ) : null} ); @@ -291,7 +307,7 @@ const HeaderItem = ({ - + {getBoardInitial(board.name)} @@ -299,14 +315,7 @@ const HeaderItem = ({ } if (item.id === "logo") { - return ( - - ); + return ; } if (item.id === "boardEdit") return boardEditAction; @@ -416,3 +425,5 @@ const HeaderItem = ({ ); }; + +const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?"; diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index 5f14a7da5..40a3c99f9 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -22,12 +22,12 @@ export const ClientShell = ({ const collapsed = useAtomValue(navigationCollapsedAtom); const backgroundProps = useOptionalBackgroundProps(); const { headerPreferences } = useSettings(); - const isHeaderVisible = hasHeader && headerPreferences.visible; + const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 }; return ( { const t = useI18n("onboardingTour.manage"); const { mutate: completeTour } = clientApi.user.completeTour.useMutation(); - const isMobile = useMediaQuery("(max-width: 48em)"); + const isMobile = useIsMobile(); const pathname = usePathname(); const router = useRouter(); diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index f8552e94a..950536175 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1 +1,2 @@ +export * from "./use-is-mobile"; export * from "./use-translated-mantine-react-table"; diff --git a/packages/ui/src/hooks/use-is-mobile.ts b/packages/ui/src/hooks/use-is-mobile.ts new file mode 100644 index 000000000..8cf9cec1e --- /dev/null +++ b/packages/ui/src/hooks/use-is-mobile.ts @@ -0,0 +1,9 @@ +"use client"; + +import { useMantineTheme } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; + +export const useIsMobile = () => { + const theme = useMantineTheme(); + return useMediaQuery(`(max-width: ${theme.breakpoints.sm})`); +}; From a002b91adde5d223a860dc1cac203db267c94676 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 27 Aug 2026 12:01:36 +0200 Subject: [PATCH 09/11] feat: reduce disruptive UI confirmations (#6688) * feat: reduce disruptive UI confirmations * fix: keep destructive confirmations red --- apps/docs/docs/management/api/index.mdx | 3 + apps/docs/docs/management/boards/index.mdx | 12 +- .../docs/management/certificates/index.mdx | 11 +- .../docs/management/custom-widgets/index.mdx | 3 +- apps/docs/docs/management/users/index.mdx | 10 +- .../boards/[name]/settings/_danger.tsx | 166 +++--- .../boards/[name]/settings/_layout.tsx | 45 +- .../manage/apps/_app-delete-button.tsx | 58 +- .../_components/board-card-menu-dropdown.tsx | 46 +- .../manage/boards/_components/board-card.tsx | 133 +++++ .../_components/create-board-button.tsx | 54 +- .../_components/duplicate-board-form.tsx | 53 +- .../src/app/[locale]/manage/boards/page.tsx | 147 +----- .../custom-widgets/_custom-widget-actions.tsx | 52 +- .../workshop/[id]/_workshop-detail.tsx | 18 +- .../test-connection-certificate.tsx | 156 +++--- .../integrations/_integration-buttons.tsx | 59 +-- .../manage/medias/_actions/delete-media.tsx | 31 +- .../_search-engine-delete-button.tsx | 58 +- .../manage/tools/api/components/api-keys.tsx | 106 ++-- .../tools/api/components/api-page-tabs.tsx | 20 +- .../api/components/copy-api-key-modal.tsx | 34 -- .../tools/api/components/mcp-instructions.tsx | 25 +- .../api/components/new-api-key-alert.tsx | 50 ++ .../app/[locale]/manage/tools/api/page.tsx | 4 +- .../_components/add-certificate.tsx | 40 +- .../_components/certificate-upload-form.tsx | 110 ++++ .../_components/remove-certificate.tsx | 61 ++- .../hostnames/_components/remove-hostname.tsx | 51 +- .../manage/tools/certificates/page.tsx | 5 +- .../_components/_delete-user-button.tsx | 25 +- .../_components/_profile-avatar-form.tsx | 60 +-- .../_components/create-user-stepper.tsx | 45 +- .../users/groups/[id]/_delete-group.tsx | 76 ++- .../groups/[id]/_transfer-group-ownership.tsx | 113 ++-- .../groups/[id]/members/_add-group-member.tsx | 44 +- .../[id]/members/_remove-group-member.tsx | 36 +- .../[locale]/manage/users/groups/_client.tsx | 62 ++- .../groups/_components/group-create-form.tsx | 61 +++ .../app/[locale]/manage/users/groups/page.tsx | 4 +- .../_components/invite-create-button.tsx | 7 +- .../_components/invite-create-form.tsx | 138 +++++ .../users/invites/_components/invite-list.tsx | 76 ++- .../[locale]/manage/users/invites/page.tsx | 12 +- .../app/[locale]/widgets/[kind]/_content.tsx | 36 +- .../widgets/[kind]/_dimension-modal.tsx | 50 -- .../widgets/[kind]/_dimension-popover.tsx | 78 +++ .../src/components/access/access-picker.tsx | 130 +++++ .../components/access/group-access-form.tsx | 40 +- .../components/access/group-select-modal.tsx | 73 --- .../src/components/access/group-select.tsx | 66 +++ .../components/access/user-access-form.tsx | 44 +- .../components/access/user-select-modal.tsx | 103 ---- .../src/components/access/user-select.tsx | 75 +++ .../components/assistant/assistant-panel.tsx | 38 +- .../src/components/board/items/item-menu.tsx | 17 +- .../board/modals/board-rename-modal.tsx | 70 --- .../sections/container/container-menu.tsx | 15 +- .../workshop/workshop-report-form.tsx | 99 ++++ .../workshop/workshop-report-modal.tsx | 80 --- package.json | 1 + .../src/board-create/board-create-form.tsx} | 107 ++-- packages/forms-collection/src/index.tsx | 2 + .../modals-collection/src/boards/index.ts | 2 - .../certificates/add-certificate-modal.tsx | 75 --- .../src/certificates/index.ts | 1 - .../src/groups/add-group-modal.tsx | 57 -- .../modals-collection/src/groups/index.ts | 1 - packages/modals-collection/src/index.ts | 4 - .../modals-collection/src/invites/index.ts | 2 - .../src/invites/invite-copy-modal.tsx | 61 --- .../src/invites/invite-create-modal.tsx | 81 --- .../src/modes/command/global-group.tsx | 35 +- packages/translation/src/lang/en.json | 1 + packages/ui/src/components/index.tsx | 1 + .../src/components/inline-confirm-action.tsx | 239 +++++++++ packages/widgets/src/_inputs/form.ts | 3 +- .../_inputs/widget-location-input.spec.tsx | 8 +- .../src/_inputs/widget-location-input.tsx | 188 +++---- .../src/dns-hole/controls/TimerModal.tsx | 106 ---- .../src/dns-hole/controls/component.tsx | 140 +++-- .../src/dns-hole/controls/timer-popover.tsx | 146 ++++++ .../modals/widget-advanced-options-modal.tsx | 66 --- .../widgets/src/modals/widget-edit-modal.tsx | 232 ++++---- packages/widgets/src/notebook/notebook.tsx | 29 +- .../browser-agent/ux-coherence-happy-paths.sh | 496 ++++++++++++++++++ 86 files changed, 3201 insertions(+), 2277 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/boards/_components/board-card.tsx rename packages/modals-collection/src/boards/duplicate-board-modal.tsx => apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx (62%) delete mode 100644 apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx delete mode 100644 apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx create mode 100644 apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx create mode 100644 apps/nextjs/src/components/access/access-picker.tsx delete mode 100644 apps/nextjs/src/components/access/group-select-modal.tsx create mode 100644 apps/nextjs/src/components/access/group-select.tsx delete mode 100644 apps/nextjs/src/components/access/user-select-modal.tsx create mode 100644 apps/nextjs/src/components/access/user-select.tsx delete mode 100644 apps/nextjs/src/components/board/modals/board-rename-modal.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-report-form.tsx delete mode 100644 apps/nextjs/src/components/workshop/workshop-report-modal.tsx rename packages/{modals-collection/src/boards/add-board-modal.tsx => forms-collection/src/board-create/board-create-form.tsx} (61%) delete mode 100644 packages/modals-collection/src/boards/index.ts delete mode 100644 packages/modals-collection/src/certificates/add-certificate-modal.tsx delete mode 100644 packages/modals-collection/src/certificates/index.ts delete mode 100644 packages/modals-collection/src/groups/add-group-modal.tsx delete mode 100644 packages/modals-collection/src/groups/index.ts delete mode 100644 packages/modals-collection/src/invites/index.ts delete mode 100644 packages/modals-collection/src/invites/invite-copy-modal.tsx delete mode 100644 packages/modals-collection/src/invites/invite-create-modal.tsx create mode 100644 packages/ui/src/components/inline-confirm-action.tsx delete mode 100644 packages/widgets/src/dns-hole/controls/TimerModal.tsx create mode 100644 packages/widgets/src/dns-hole/controls/timer-popover.tsx delete mode 100644 packages/widgets/src/modals/widget-advanced-options-modal.tsx create mode 100755 scripts/browser-agent/ux-coherence-happy-paths.sh diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index f919b7b2e..fab706520 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -29,6 +29,9 @@ Alternatively, you can [open the full interactive API reference](/api-reference) Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key. You can obtain API keys from the "Authentication" tab at the top of the page. API keys will not expire and can be used indefinitely. +When you create a key, its one-time value is shown inline above the key table. Copy it before closing the notice; the +creation action remains disabled until the value has been acknowledged. Deletion uses a two-click inline confirmation +instead of opening a separate dialog. ### API Key Format diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 11d1ba271..3fa95d2d4 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -34,9 +34,11 @@ arrow keys to move through the results and press `Enter` to open the selected bo ## Create a new board -When creating a board, the availability of the board name will be checked for you. +Select **New board** on the boards management page. The creation form expands in the page, so you can enter the board +details without opening a dialog or leaving the board list. The availability of the board name is checked as you type. -![](./img/create-board-modal.png) +The board menu also keeps duplication in context: select **Duplicate board**, choose the new name in the form below that +board, and create the copy without navigating to a separate page. ### Name @@ -56,6 +58,9 @@ This will enable public access for everyone - regardless of whether they are log This can be really useful if you want to have public boards in your company or for family and friends. Modifications for public boards must be allowed explicitly using user groups and their permissions. +Deleting a board from the management list uses an inline safeguard. Select **Delete**, then select **Confirm** in the +same menu item. No separate confirmation dialog is opened. + ## Board settings You navigate to the board settings by clicking on these three dots: @@ -405,8 +410,7 @@ nor the integrations the user does not have access to. Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board. Duplicating a board that contains Custom Widgets also requires an administrator. You can duplicate a board by clicking on the three dots in the list of boards and selecting "Duplicate board". - -![](./img/duplicate-board-modal.png) +The name form expands below that board card and can be cancelled without leaving the list. ## Setting boards as your home board diff --git a/apps/docs/docs/management/certificates/index.mdx b/apps/docs/docs/management/certificates/index.mdx index aa843bfe0..0c2b9853a 100644 --- a/apps/docs/docs/management/certificates/index.mdx +++ b/apps/docs/docs/management/certificates/index.mdx @@ -30,15 +30,12 @@ Navigate to `Management` > `Tools` > `Certificates`. ## Add a certificate -To add a certificate click on the "Add certificate" button. +To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page. ![](./img/add-button.png) -Then select the certificate file you want to add. - -![](./img/add-modal.png) - -And click the "Add" button. +Then select the certificate file you want to add and click the "Add" button. You can cancel to collapse the form without +leaving the page. ## Delete a certificate @@ -46,7 +43,7 @@ To delete a certificate you can click on the trash icon in the certificate card. ![](./img/card.png) -Then confirm the deletion. +The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened. ## Managing it through file system diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 33dcfed89..133c29c4a 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -114,7 +114,8 @@ The install flow always links to the corresponding public Workshop entry under t For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work. Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can -delete submissions. Community moderation is separate from Homarr administration and is not a security boundary. +delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a +dialog. Community moderation is separate from Homarr administration and is not a security boundary. Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr performs definitive validation again at install time. diff --git a/apps/docs/docs/management/users/index.mdx b/apps/docs/docs/management/users/index.mdx index 371ec61a7..9c32ba56f 100644 --- a/apps/docs/docs/management/users/index.mdx +++ b/apps/docs/docs/management/users/index.mdx @@ -108,7 +108,7 @@ However, you can create as many groups as you'd like. ### Creating a group Click on the button `New group` to create a new one. -Homarr will prompt you for a group name. +The group-name form expands above the group list and can be closed without leaving the page. ### Managing a group @@ -259,7 +259,8 @@ An invitation link can be used indefinitely. ### Creating an invitation -Click on the button: +Select the create-invitation button. The expiration form expands above the invitation table instead of opening a +dialog or navigating to another page: ![](./img/create-invitation-button.png) @@ -269,12 +270,12 @@ The default is 4 hours in the future. ![](./img/invitation-expiry-date.png) -Next, click `Create` and you'll get a confirmation with an ID, Token and a link that can be copied: +Next, click `Create` and the same section shows the ID, Token and a link that can be copied: ![](./img/create-invitation-confirmation.png) Both ID and Token are also in the URL. Ensure that you copy either ID & Token or the URL - it will never be shown again. -Clicking the `Copy & close` button will copy the URL to your clipboard. +Use **Copy** to copy the URL, then close the section when you are finished. ### Self-onboarding @@ -293,3 +294,4 @@ If an invitation needs to be deleted before or after the expiry, the delete butt The link will become invalid. If the invitation expires, the link will also expire. +The first click changes the action to **Confirm**; the second click deletes the invitation without opening a dialog. diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx index 9342a78d0..645d25058 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx @@ -1,15 +1,16 @@ "use client"; -import { useCallback } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { Button } from "@mantine/core"; +import { Box, Button, Group, TextInput } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; +import { useZodForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; +import { boardRenameSchema } from "@homarr/validation/board"; -import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => { @@ -17,70 +18,70 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: const t = useI18n("board.setting"); const tCommonAction = useI18n("common.action"); const router = useRouter(); - const { openConfirmModal } = useConfirmModal(); - const { openModal } = useModalAction(BoardRenameModal); + const utils = clientApi.useUtils(); + const [isRenaming, setIsRenaming] = useState(false); + const renameInputRef = useRef(null); + const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), { + initialValues: { + name: board.name, + }, + }); + const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({ + onSettled() { + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getHomeBoard.invalidate(); + }, + }); const { mutate: changeVisibility, isPending: isChangeVisibilityPending } = clientApi.board.changeBoardVisibility.useMutation(); const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation(); - const utils = clientApi.useUtils(); const visibility = board.isPublic ? "public" : "private"; - const onRenameClick = useCallback( - () => - openModal({ + useEffect(() => { + if (isRenaming) renameInputRef.current?.focus(); + }, [isRenaming]); + + const handleRename = renameForm.onSubmit((values) => { + renameBoard( + { id: board.id, name: values.name }, + { + onSuccess() { + router.push(`/boards/${values.name}/settings`); + }, + }, + ); + }); + + const cancelRename = () => { + renameForm.reset(); + setIsRenaming(false); + }; + + const handleVisibilityChange = () => { + changeVisibility( + { id: board.id, - previousName: board.name, - onSuccess: (name) => router.push(`/boards/${name}/settings`), - }), - [board.id, board.name, router, openModal], - ); - - const onVisibilityClick = useCallback(() => { - openConfirmModal({ - title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`), - children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`), - onConfirm: () => { - changeVisibility( - { - id: board.id, - visibility: visibility === "public" ? "private" : "public", - }, - { - onSettled() { - void utils.board.getBoardByName.invalidate({ name: board.name }); - void utils.board.getHomeBoard.invalidate(); - }, - }, - ); + visibility: visibility === "public" ? "private" : "public", }, - }); - }, [ - board.id, - board.name, - changeVisibility, - t, - utils.board.getBoardByName, - utils.board.getHomeBoard, - visibility, - openConfirmModal, - ]); - - const onDeleteClick = useCallback(() => { - openConfirmModal({ - title: t("section.dangerZone.action.delete.confirm.title"), - children: t("section.dangerZone.action.delete.confirm.description"), - onConfirm: () => { - deleteBoard( - { id: board.id }, - { - onSettled: () => { - router.push("/"); - }, - }, - ); + { + onSettled() { + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getHomeBoard.invalidate(); + }, }, - }); - }, [board.id, deleteBoard, router, t, openConfirmModal]); + ); + }; + + const handleDelete = () => { + deleteBoard( + { id: board.id }, + { + onSettled: () => { + router.push("/"); + }, + }, + ); + }; return ( @@ -88,9 +89,28 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: label={t("section.dangerZone.action.rename.label")} description={t("section.dangerZone.action.rename.description")} action={ - + isRenaming ? ( + + + + + + + + ) : ( + + ) } /> {!hideVisibility && ( @@ -98,9 +118,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: label={t("section.dangerZone.action.visibility.label")} description={t(`section.dangerZone.action.visibility.description.${visibility}`)} action={ - + } /> )} @@ -108,9 +134,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: label={tCommonAction("delete")} description={t("section.dangerZone.action.delete.description")} action={ - + } /> diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index 64f36ea03..fa72af452 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -23,9 +23,9 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { createId } from "@homarr/common"; import type { UseFormReturnType } from "@homarr/form"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; import { SectionCard } from "~/components/manage/section-card"; import type { Board } from "../../_types"; @@ -46,7 +46,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync const tCommon = useI18n("common"); const router = useRouter(); const utils = clientApi.useUtils(); - const { openConfirmModal } = useConfirmModal(); const [editingLayoutId, setEditingLayoutId] = useState(null); const [resettingLayoutId, setResettingLayoutId] = useState(null); const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({ @@ -84,29 +83,20 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync } }; - const confirmReset = (layout: FormValues["layouts"][number]) => { - openConfirmModal({ - title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }), - children: tBoard("setting.section.layout.reset.confirm.message"), - confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" }, - onConfirm() { - setResettingLayoutId(layout.id); - void (async () => { - try { - const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id); - if (!canonicalLayout) return; + const handleReset = async (layout: FormValues["layouts"][number]) => { + setResettingLayoutId(layout.id); + try { + const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id); + if (!canonicalLayout) return; - await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); - await utils.board.getBoardByName.invalidate({ name: board.name }); - router.refresh(); - } catch { - // The mutation callback displays the error notification. - } finally { - setResettingLayoutId(null); - } - })(); - }, - }); + await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); + await utils.board.getBoardByName.invalidate({ name: board.name }); + router.refresh(); + } catch { + // The mutation callback displays the error notification. + } finally { + setResettingLayoutId(null); + } }; const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); @@ -236,16 +226,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync {layout.role !== "base" && persistedLayout && ( - + )} {layout.role === "custom" && ( + {isMenuVisible && ( + + + + + + + { + setDuplicateFormKey((value) => value + 1); + setIsDuplicateOpen(true); + }} + /> + + )} + + + + + + setIsDuplicateOpen(false)} /> + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx index f6c9ed735..ce9059784 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx @@ -1,23 +1,55 @@ "use client"; -import { Button } from "@mantine/core"; +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button, Card, Collapse, Group, Stack } from "@mantine/core"; import { IconCategoryPlus } from "@tabler/icons-react"; -import { useModalAction } from "@homarr/modals"; -import { AddBoardModal } from "@homarr/modals-collection"; +import { BoardCreateForm } from "@homarr/forms-collection"; import { useI18n } from "@homarr/translation/client"; -import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; - export const CreateBoardButton = () => { const t = useI18n("management.page.board.action"); - const { openModal } = useModalAction(AddBoardModal); + const router = useRouter(); + const searchParams = useSearchParams(); + const [isOpen, setIsOpen] = useState(searchParams.get("create") === "true"); + const [formKey, setFormKey] = useState(0); + + useEffect(() => { + if (searchParams.get("create") !== "true") return; + + setFormKey((value) => value + 1); + setIsOpen(true); + }, [searchParams]); + + const close = () => { + setIsOpen(false); + setFormKey((value) => value + 1); + if (searchParams.has("create")) router.replace("/manage/boards", { scroll: false }); + }; + + const toggle = () => { + if (isOpen) { + close(); + return; + } + + setFormKey((value) => value + 1); + setIsOpen(true); + }; return ( - - - + + + + + + + + + + ); }; diff --git a/packages/modals-collection/src/boards/duplicate-board-modal.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx similarity index 62% rename from packages/modals-collection/src/boards/duplicate-board-modal.tsx rename to apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx index 9d9830757..3e0fff888 100644 --- a/packages/modals-collection/src/boards/duplicate-board-modal.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx @@ -1,29 +1,32 @@ +"use client"; + +import { useRouter } from "next/navigation"; import { Button, Group, Stack, Text, TextInput } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { useZodForm } from "@homarr/form"; +import { BoardNameAvailability, useBoardNameStatus } from "@homarr/forms-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { boardDuplicateSchema } from "@homarr/validation/board"; -import { createModal } from "../../../modals/src/creator"; -import { useBoardNameStatus } from "./add-board-modal"; - -interface InnerProps { +interface DuplicateBoardFormProps { board: { id: string; name: string; }; + onCancel: () => void; } -export const DuplicateBoardModal = createModal(({ actions, innerProps }) => { +export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps) => { const tBoard = useI18n("board"); const tCommon = useI18n("common"); + const router = useRouter(); const form = useZodForm(boardDuplicateSchema.omit({ id: true }), { mode: "controlled", initialValues: { - name: innerProps.board.name, + name: board.name, }, }); const boardNameStatus = useBoardNameStatus(form.values.name); @@ -36,20 +39,21 @@ export const DuplicateBoardModal = createModal(({ actions, innerProp return (
{ - // Prevent submit before name availability check if (!boardNameStatus.canSubmit) return; + await mutateAsync( { ...values, - id: innerProps.board.id, + id: board.id, }, { onSuccess() { - actions.closeModal(); showSuccessNotification({ title: tBoard("action.duplicate.notification.success.title"), message: tBoard("action.duplicate.notification.success.message"), }); + onCancel(); + router.refresh(); }, onError() { showErrorNotification({ @@ -61,39 +65,26 @@ export const DuplicateBoardModal = createModal(({ actions, innerProp ); })} > - + - {tBoard("action.duplicate.message", { name: innerProps.board.name })} + {tBoard("action.duplicate.message", { name: board.name })} - - {boardNameStatus.description.icon ? : null} - {boardNameStatus.description.label} - - ) : null - } + description={} withAsterisk /> - - - - ); -}).withOptions({ - defaultTitle(t) { - return t("board.action.duplicate.title"); - }, -}); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index c20cf6c74..e3b3a1286 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -1,38 +1,15 @@ -import { - ActionIcon, - Badge, - Button, - Card, - CardSection, - Grid, - GridCol, - Group, - Menu, - MenuTarget, - Text, - Tooltip, -} from "@mantine/core"; -import { - IconDeviceMobile, - IconDotsVertical, - IconHomeFilled, - IconLayoutDashboard, - IconLock, - IconWorld, -} from "@tabler/icons-react"; +import { Grid, GridCol } from "@mantine/core"; +import { IconLayoutDashboard } from "@tabler/icons-react"; -import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { constructBoardPermissions } from "@homarr/auth/shared"; import { getI18n } from "@homarr/translation/server"; -import { Link, UserAvatar } from "@homarr/ui"; -import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { NoResults } from "~/components/no-results"; -import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; +import { BoardCard } from "./_components/board-card"; import { CreateBoardButton } from "./_components/create-board-button"; export default async function ManageBoardsPage() { @@ -42,24 +19,14 @@ export default async function ManageBoardsPage() { const canCreateBoards = session?.user.permissions.includes("board-create"); return ( - - - - ) : undefined - } - floatingPrimaryAction={canCreateBoards} - > + + {canCreateBoards && ( + + + + )} {boards.length === 0 && ( - + )} {boards.length > 0 && ( @@ -92,97 +59,3 @@ export default async function ManageBoardsPage() { ); } - -interface BoardCardProps { - board: RouterOutputs["board"]["getManageOverview"][number]; - isMenuVisible: boolean; - labels: { - visibility: string; - preview: string; - unknownCreator: string; - home: string; - homeTooltip: string; - mobileHome: string; - mobileHomeTooltip: string; - open: string; - settings: string; - }; -} - -const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => { - const VisibilityIcon = board.isPublic ? IconWorld : IconLock; - - return ( - - - - - - - - - - - - {board.name} - - - - - {board.isHome && ( - - }> - {labels.home} - - - )} - - {board.isMobileHome && ( - - }> - {labels.mobileHome} - - - )} - - {board.creator && ( - - - - )} - - - - - - - - {isMenuVisible && ( - - - - - - - - - )} - - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index 8e376330b..e6ad0594b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -20,10 +20,9 @@ import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; -import { Link } from "@homarr/ui"; +import { InlineConfirmMenuItem, Link } from "@homarr/ui"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { downloadJson } from "~/components/custom-widgets/download"; @@ -40,7 +39,7 @@ interface WidgetRef { export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { const t = useI18n("customWidget"); - const { openConfirmModal } = useConfirmModal(); + const tCommon = useI18n("common"); const deleteMutation = clientApi.customWidget.delete.useMutation(); const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation(); @@ -173,30 +172,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { }; const handleDelete = () => { - openConfirmModal({ - title: t("action.delete"), - children: t("action.deleteConfirm", { name: widget.name }), - onConfirm: () => { - deleteMutation.mutate( - { id: widget.id }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("action.delete"), - message: t("notification.deleted", { name: widget.name }), - }); - void utils.customWidget.list.invalidate(); - void utils.customWidget.available.invalidate(); - void utils.widget.customApi.getData.invalidate(); - void revalidatePathActionAsync("/manage/custom-widgets"); - }, - onError: () => { - showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); - }, - }, - ); + deleteMutation.mutate( + { id: widget.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("action.delete"), + message: t("notification.deleted", { name: widget.name }), + }); + void utils.customWidget.list.invalidate(); + void utils.customWidget.available.invalidate(); + void utils.widget.customApi.getData.invalidate(); + void revalidatePathActionAsync("/manage/custom-widgets"); + }, + onError: () => { + showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); + }, }, - }); + ); }; return ( @@ -263,14 +256,15 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { )} {!widget.migrationRequired && ( - } - onClick={handleDelete} + onConfirm={handleDelete} + confirmLabel={tCommon("action.confirm")} disabled={deleteMutation.isPending} > {t("action.delete")} - + )} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx index d357f37b5..77cd96cba 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx @@ -35,7 +35,7 @@ import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer"; import { getWorkshopWebUrl } from "~/components/workshop/workshop-client"; -import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal"; +import { WorkshopReportForm } from "~/components/workshop/workshop-report-form"; import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots"; import { useWorkshopSession } from "~/components/workshop/workshop-session"; import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control"; @@ -213,6 +213,13 @@ export function WorkshopDetail({ id }: { id: string }) { + + @@ -241,6 +248,8 @@ export function WorkshopDetail({ id }: { id: string }) { leftSection={} disabled={!session.user} onClick={reportControls.open} + aria-expanded={reportOpened} + aria-controls="workshop-report-form" > {t("report")} @@ -259,13 +268,6 @@ export function WorkshopDetail({ id }: { id: string }) { - - ); } diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx index c338e06ac..07b3c6da2 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx @@ -6,11 +6,12 @@ import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { createId, getMantineColor } from "@homarr/common"; import { createDocumentationLink } from "@homarr/definitions"; -import { createModal, useConfirmModal, useModalAction } from "@homarr/modals"; -import { AddCertificateModal } from "@homarr/modals-collection"; +import { createModal, useModalAction } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useCurrentLocale, useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; +import { CertificateUploadForm } from "~/app/[locale]/manage/tools/certificates/_components/certificate-upload-form"; import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types"; interface CertificateErrorDetailsProps { @@ -23,80 +24,71 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP const { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; const [showRetryButton, setShowRetryButton] = useState(false); + const [showUploadForm, setShowUploadForm] = useState(false); - const { openModal: openUploadModal } = useModalAction(AddCertificateModal); - const { openConfirmModal } = useConfirmModal(); - const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation(); - const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation(); + const { mutateAsync: trustHostnameAsync, isPending: isTrustHostnamePending } = + clientApi.certificates.trustHostnameMismatch.useMutation(); + const { mutateAsync: addCertificateAsync, isPending: isTrustSelfSignedPending } = + clientApi.certificates.addCertificate.useMutation(); const rootCertificate = getHeighestCertificate(error.data.certificate); - const handleTrustHostname = () => { + const handleTrustHostname = async () => { const { hostname } = new URL(url); - openConfirmModal({ - title: tError("certificate.hostnameMismatch.confirm.title"), - children: tError("certificate.hostnameMismatch.confirm.message"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await trustHostnameAsync( - { - hostname, - certificate: error.data.certificate.pem, - }, - { - onSuccess() { - showSuccessNotification({ - title: tError("certificate.hostnameMismatch.notification.success.title"), - message: tError("certificate.hostnameMismatch.notification.success.message"), - }); - setShowRetryButton(true); - }, - onError() { - showErrorNotification({ - title: tError("certificate.hostnameMismatch.notification.error.title"), - message: tError("certificate.hostnameMismatch.notification.error.message"), - }); - }, - }, - ); + await trustHostnameAsync( + { + hostname, + certificate: error.data.certificate.pem, }, - }); + { + onSuccess() { + showSuccessNotification({ + title: tError("certificate.hostnameMismatch.notification.success.title"), + message: tError("certificate.hostnameMismatch.notification.success.message"), + }); + setShowRetryButton(true); + }, + onError() { + showErrorNotification({ + title: tError("certificate.hostnameMismatch.notification.error.title"), + message: tError("certificate.hostnameMismatch.notification.error.message"), + }); + }, + }, + ); }; - const handleTrustSelfSigned = () => { + const handleTrustSelfSigned = async () => { const { hostname } = new URL(url); - openConfirmModal({ - title: tError("certificate.selfSigned.confirm.title"), - children: tError("certificate.selfSigned.confirm.message"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - const formData = new FormData(); - formData.append( - "file", - new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, { - type: "application/x-x509-ca-cert", - }), - ); - await addCertificateAsync(formData, { - onSuccess() { - showSuccessNotification({ - title: tError("certificate.selfSigned.notification.success.title"), - message: tError("certificate.selfSigned.notification.success.message"), - }); - setShowRetryButton(true); - }, - onError() { - showErrorNotification({ - title: tError("certificate.selfSigned.notification.error.title"), - message: tError("certificate.selfSigned.notification.error.message"), - }); - }, + const formData = new FormData(); + formData.append( + "file", + new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, { + type: "application/x-x509-ca-cert", + }), + ); + await addCertificateAsync(formData, { + onSuccess() { + showSuccessNotification({ + title: tError("certificate.selfSigned.notification.success.title"), + message: tError("certificate.selfSigned.notification.success.message"), + }); + setShowRetryButton(true); + }, + onError() { + showErrorNotification({ + title: tError("certificate.selfSigned.notification.error.title"), + message: tError("certificate.selfSigned.notification.error.message"), }); }, }); }; const description = {tError(`certificate.description.${error.data.reason}`)}; + let trustConfirmLabel = tError("certificate.selfSigned.confirm.title"); + if (error.data.reason === "hostnameMismatch") { + trustConfirmLabel = tError("certificate.hostnameMismatch.confirm.title"); + } if (!isAdmin) { return ( @@ -130,30 +122,36 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP {(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) || error.data.reason === "hostnameMismatch" ? ( - + ) : null} {error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? ( - + + {!showUploadForm && ( + + )} + {showUploadForm && ( + + setShowUploadForm(false)} + onSuccess={() => { + setShowRetryButton(true); + setShowUploadForm(false); + }} + /> + + )} + ) : null} ); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx index d10cd25d2..9f42967a5 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx @@ -1,13 +1,12 @@ "use client"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface DeleteIntegrationActionButtonProps { integration: { id: string; name: string }; @@ -16,46 +15,42 @@ interface DeleteIntegrationActionButtonProps { export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => { const t = useI18n("integration.page.delete"); const tCommon = useI18n("common"); - const { openConfirmModal } = useConfirmModal(); const utils = clientApi.useUtils(); const { mutateAsync, isPending } = clientApi.integration.delete.useMutation(); + const onConfirm = () => + mutateAsync( + { id: integration.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: tCommon("notification.delete.success"), + message: t("notification.success.message"), + }); + void revalidatePathActionAsync("/manage/integrations"); + void utils.integration.invalidate(); + }, + onError: () => { + showErrorNotification({ + title: tCommon("notification.delete.error"), + message: t("notification.error.message"), + }); + }, + }, + ); + return ( - { - openConfirmModal({ - title: t("title"), - children: t("message", integration), - onConfirm: () => { - void mutateAsync( - { id: integration.id }, - { - onSuccess: () => { - showSuccessNotification({ - title: tCommon("notification.delete.success"), - message: t("notification.success.message"), - }); - void revalidatePathActionAsync("/manage/integrations"); - void utils.integration.invalidate(); - }, - onError: () => { - showErrorNotification({ - title: tCommon("notification.delete.error"), - message: t("notification.error.message"), - }); - }, - }, - ); - }, - }); - }} aria-label={tCommon("action.deleteNamed", { name: integration.name })} > - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx b/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx index 5f1b2f954..fb0579db0 100644 --- a/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx +++ b/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx @@ -1,40 +1,41 @@ "use client"; -import { ActionIcon, Tooltip } from "@mantine/core"; +import { Tooltip } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface DeleteMediaProps { media: RouterOutputs["media"]["getPaginated"]["items"][number]; } export const DeleteMedia = ({ media }: DeleteMediaProps) => { - const { openConfirmModal } = useConfirmModal(); const t = useI18n("media"); + const tCommon = useI18n("common"); const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation(); - const onClick = () => { - openConfirmModal({ - title: t("action.delete.label"), - children: t.rich("action.delete.description", { bName: () => {media.name} }), - // eslint-disable-next-line no-restricted-syntax - onConfirm: async () => { - await mutateAsync({ id: media.id }); - await revalidatePathActionAsync("/manage/medias"); - }, - }); + const onConfirm = async () => { + await mutateAsync({ id: media.id }); + await revalidatePathActionAsync("/manage/medias"); }; return ( - + - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx index 15a0c03bd..ab63a55a5 100644 --- a/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx @@ -1,15 +1,14 @@ "use client"; import { useCallback } from "react"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface SearchEngineDeleteButtonProps { searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number]; @@ -18,48 +17,41 @@ interface SearchEngineDeleteButtonProps { export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => { const t = useI18n("search.engine.page.delete"); const actionT = useI18n("common.action"); - const { openConfirmModal } = useConfirmModal(); const { mutate, isPending } = clientApi.searchEngine.delete.useMutation(); - const onClick = useCallback(() => { - openConfirmModal({ - title: t("title"), - children: t("message", { - name: searchEngine.name, - }), - onConfirm: () => { - mutate( - { id: searchEngine.id }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("notification.success.title"), - message: t("notification.success.message"), - }); - void revalidatePathActionAsync("/manage/search-engines"); - }, - onError: () => { - showErrorNotification({ - title: t("notification.error.title"), - message: t("notification.error.message"), - }); - }, - }, - ); + const onConfirm = useCallback(() => { + mutate( + { id: searchEngine.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("notification.success.title"), + message: t("notification.success.message"), + }); + void revalidatePathActionAsync("/manage/search-engines"); + }, + onError: () => { + showErrorNotification({ + title: t("notification.error.title"), + message: t("notification.error.message"), + }); + }, }, - }); - }, [searchEngine, mutate, t, openConfirmModal]); + ); + }, [searchEngine.id, mutate, t]); return ( - - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx index 53852934d..55073fe92 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx @@ -1,34 +1,67 @@ "use client"; -import { useCallback, useMemo } from "react"; -import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core"; +import { useCallback, useMemo, useState } from "react"; +import { Button, Group, Stack, Text, Title } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; -import type { MRT_ColumnDef } from "mantine-react-table"; +import type { MRT_ColumnDef, MRT_Row, MRT_TableInstance } from "mantine-react-table"; import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { invariantTechnicalLabels } from "@homarr/definitions"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { UserAvatar } from "@homarr/ui"; +import { InlineConfirmActionIcon, UserAvatar } from "@homarr/ui"; -import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal"; +import { NewApiKeyAlert } from "./new-api-key-alert"; interface ApiKeysManagementProps { apiKeys: RouterOutputs["apiKeys"]["getAll"]; + onCreated: () => void; } -export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { - const { openModal } = useModalAction(CopyApiKeyModal); - const { openConfirmModal } = useConfirmModal(); +type ApiKey = RouterOutputs["apiKeys"]["getAll"][number]; +type ApiKeyCellProps = { row: MRT_Row }; + +interface ApiKeysTableMeta { + handleDelete: (id: string) => Promise; + confirmLabel: string; + deleteTitle: string; + isPendingDelete: boolean; +} + +const ApiKeyCreatedByCell = ({ row }: ApiKeyCellProps) => ( + + + {row.original.user.name} + +); + +const ApiKeyActionsCell = ({ row, table }: ApiKeyCellProps & { table: MRT_TableInstance }) => { + const meta = table.options.meta as ApiKeysTableMeta; + return ( + + meta.handleDelete(row.original.id)} + confirmLabel={meta.confirmLabel} + confirmationAriaLabel={meta.confirmLabel} + loading={meta.isPendingDelete} + color="red" + variant="subtle" + aria-label={meta.deleteTitle} + > + + + + ); +}; + +export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps) => { + const [newApiKey, setNewApiKey] = useState(null); const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({ - async onSuccess(data) { - openModal({ - apiKey: data.apiKey, - }); - await revalidatePathActionAsync("/manage/tools/api"); + onSuccess(data) { + setNewApiKey(data.apiKey); + onCreated(); }, }); const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({ @@ -38,21 +71,19 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { }); const t = useI18n("management.page.tool.api.tab.apiKey"); + const tCommon = useI18n("common"); const handleDelete = useCallback( - (id: string) => { - openConfirmModal({ - title: t("modal.delete.title"), - children: t("modal.delete.text"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateDeleteAsync({ apiKeyId: id }); - }, - }); + async (id: string) => { + await mutateDeleteAsync({ apiKeyId: id }); }, - [t, openConfirmModal, mutateDeleteAsync], + [mutateDeleteAsync], ); + const handleDismissNewApiKey = useCallback(async () => { + setNewApiKey(null); + await revalidatePathActionAsync("/manage/tools/api"); + }, []); - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { accessorKey: "id", @@ -61,36 +92,32 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { { accessorKey: "user", header: t("table.header.createdBy"), - Cell: ({ row }) => ( - - - {row.original.user.name} - - ), + Cell: ApiKeyCreatedByCell, }, { header: t("table.header.actions"), - Cell: ({ row }) => ( - - handleDelete(row.original.id)} loading={isPendingDelete} c="red"> - - - - ), + Cell: ApiKeyActionsCell, }, ], - [t, handleDelete, isPendingDelete], + [t], ); const table = useMantineReactTable({ columns, data: apiKeys, + meta: { + handleDelete, + confirmLabel: tCommon("action.confirm"), + deleteTitle: t("modal.delete.title"), + isPendingDelete, + }, renderTopToolbarCustomActions: () => ( @@ -104,6 +131,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { return ( {t("title")} + {newApiKey && } ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx index c56dfc916..442f5446f 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx @@ -5,6 +5,8 @@ import type { ReactNode } from "react"; import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core"; import { IconBrain, IconCode, IconKey } from "@tabler/icons-react"; +import type { RouterOutputs } from "@homarr/api"; +import { ApiKeysManagement } from "./api-keys"; import { McpInstructions } from "./mcp-instructions"; export interface McpToolGroup { @@ -17,9 +19,8 @@ interface ApiPageTabsProps { apiKeyLabel: string; mcpLabel: string; documentationPanel: ReactNode; - authenticationPanel: ReactNode; + apiKeys: RouterOutputs["apiKeys"]["getAll"]; baseUrl: string; - hasApiKeys: boolean; toolGroups: McpToolGroup[]; } @@ -28,12 +29,12 @@ export function ApiPageTabs({ apiKeyLabel, mcpLabel, documentationPanel, - authenticationPanel, + apiKeys, baseUrl, - hasApiKeys, toolGroups, }: ApiPageTabsProps) { const [activeTab, setActiveTab] = useState("documentation"); + const [hasApiKeys, setHasApiKeys] = useState(apiKeys.length > 0); return ( @@ -48,10 +49,17 @@ export function ApiPageTabs({ {mcpLabel} - {authenticationPanel} + + setHasApiKeys(true)} /> + {documentationPanel} - + setHasApiKeys(true)} + /> ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx deleted file mode 100644 index dbaee24ef..000000000 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; - -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => { - const t = useI18n("management.page.tool.api.modal.createApiToken"); - const [visible, { toggle }] = useDisclosure(false); - return ( - - {t("description")} - - - {({ copy }) => ( - - )} - - - ); -}).withOptions({ - defaultTitle(t) { - return t("management.page.tool.api.modal.createApiToken.title"); - }, -}); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx index 2a5251a4d..d3fd2e112 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx @@ -1,5 +1,6 @@ "use client"; +import { useCallback, useState } from "react"; import { Accordion, AccordionControl, @@ -36,12 +37,11 @@ import { import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { CopyApiKeyModal } from "./copy-api-key-modal"; import type { McpToolGroup } from "./api-page-tabs"; import classes from "./mcp-tools-accordion.module.css"; +import { NewApiKeyAlert } from "./new-api-key-alert"; const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = { query: { color: "blue", method: "GET" }, @@ -52,18 +52,23 @@ interface McpInstructionsProps { baseUrl: string; hasApiKeys: boolean; toolGroups: McpToolGroup[]; + onApiKeyCreated: () => void; } -export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) { +export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreated }: McpInstructionsProps) { const t = useI18n("management.page.tool.api.tab.mcp"); const tCommon = useI18n("common"); - const { openModal } = useModalAction(CopyApiKeyModal); + const [newApiKey, setNewApiKey] = useState(null); const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({ - async onSuccess(data) { - openModal({ apiKey: data.apiKey }); - await revalidatePathActionAsync("/manage/tools/api"); + onSuccess(data) { + setNewApiKey(data.apiKey); + onApiKeyCreated(); }, }); + const handleDismissNewApiKey = useCallback(async () => { + setNewApiKey(null); + await revalidatePathActionAsync("/manage/tools/api"); + }, []); const mcpUrl = `${baseUrl}/api/mcp`; const streamableHttpConfig = JSON.stringify( @@ -114,7 +119,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct {t("description")} - {!hasApiKeys && ( + {!hasApiKeys && !newApiKey && ( }> {t("noApiKey")} @@ -131,6 +136,8 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct )} + {newApiKey && } +
<Group gap="xs"> @@ -305,7 +312,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct <Stack gap={6}> {group.tools.map((tool) => ( <div key={tool.name} className={classes.toolRow}> - <Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}> + <Badge size="sm" w={50} radius="xs" color={toolTypeDisplay[tool.type].color} variant="light"> {toolTypeDisplay[tool.type].method} </Badge> <div> diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx new file mode 100644 index 000000000..2b2f03182 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Alert, Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +interface NewApiKeyAlertProps { + apiKey: string; + onDismiss: () => void | Promise<void>; +} + +export const NewApiKeyAlert = ({ apiKey, onDismiss }: NewApiKeyAlertProps) => { + const t = useI18n("management.page.tool.api.modal.createApiToken"); + const tCommon = useI18n("common"); + const [visible, { toggle }] = useDisclosure(false); + + return ( + <Alert color="yellow" title={t("title")}> + <Stack gap="sm"> + <Text size="sm">{t("description")}</Text> + <PasswordInput value={apiKey} visible={visible} onVisibilityChange={toggle} aria-label={t("title")} readOnly /> + <Stack gap="xs" align="stretch"> + <CopyButton value={apiKey} timeout={2_000}> + {({ copied, copy }) => { + let color: "green" | undefined; + let icon = <IconCopy size={16} />; + let label = tCommon("action.copy"); + if (copied) { + color = "green"; + icon = <IconCheck size={16} />; + label = tCommon("action.copied"); + } + + return ( + <Button onClick={copy} variant="default" color={color} leftSection={icon}> + {label} + </Button> + ); + }} + </CopyButton> + <Button onClick={onDismiss} variant="subtle"> + {tCommon("action.close")} + </Button> + </Stack> + </Stack> + </Alert> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx index eb2cd98b7..f6e85e0ff 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx @@ -11,7 +11,6 @@ import { getI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { extractMcpTools } from "~/app/api/mcp/_extract-tools"; -import { ApiKeysManagement } from "./components/api-keys"; import { ApiPageTabs } from "./components/api-page-tabs"; import { ScalarApiReference } from "./components/scalar-api-reference"; @@ -87,9 +86,8 @@ export default async function ApiPage() { apiKeyLabel={t("apiKey.label")} mcpLabel={t("mcp.label")} documentationPanel={<ScalarApiReference document={document} />} - authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />} + apiKeys={apiKeys} baseUrl={baseUrl} - hasApiKeys={apiKeys.length > 0} toolGroups={toolGroups} /> </Stack> diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx index 206dcb0a2..21731871a 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx @@ -1,23 +1,35 @@ "use client"; -import { Button } from "@mantine/core"; +import { Button, Card, Collapse, Stack } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useModalAction } from "@homarr/modals"; -import { AddCertificateModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; -export const AddCertificateButton = () => { - const { openModal } = useModalAction(AddCertificateModal); +import { CertificateUploadForm } from "./certificate-upload-form"; + +export const AddCertificateSection = () => { + const [opened, { close, toggle }] = useDisclosure(false); const t = useI18n("certificate"); - const handleClick = () => { - openModal({ - async onSuccess() { - await revalidatePathActionAsync("/manage/tools/certificates"); - }, - }); - }; - - return <Button onClick={handleClick}>{t("action.create.label")}</Button>; + return ( + <Stack gap="sm"> + <Button onClick={toggle} variant="default" aria-expanded={opened}> + {t("action.create.label")} + </Button> + <Collapse expanded={opened}> + <Card withBorder> + <CertificateUploadForm + onCancel={close} + onSuccess={async () => { + await revalidatePathActionAsync("/manage/tools/certificates"); + close(); + }} + /> + </Card> + </Collapse> + </Stack> + ); }; + +export { CertificateUploadForm }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx new file mode 100644 index 000000000..512caf068 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Button, FileInput, Group, Stack } from "@mantine/core"; +import { IconCertificate } from "@tabler/icons-react"; +import { z } from "zod/v4"; + +import { clientApi } from "@homarr/api/client"; +import type { MaybePromise } from "@homarr/common/types"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { checkCertificateFile } from "@homarr/validation/certificates"; + +interface CertificateUploadFormProps { + embedded?: boolean; + onSuccess?: () => MaybePromise<void>; + onCancel?: () => void; +} + +export const CertificateUploadForm = ({ embedded = false, onSuccess, onCancel }: CertificateUploadFormProps) => { + const t = useI18n(); + const form = useZodForm( + z.object({ + file: z.file().check(checkCertificateFile), + }), + { + initialValues: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + file: null!, + }, + }, + ); + const { mutateAsync, isPending } = clientApi.certificates.addCertificate.useMutation(); + + const handleSubmit = async () => { + const validation = form.validate(); + if (validation.hasErrors || !form.values.file) return; + + const formData = new FormData(); + formData.set("file", form.values.file); + await mutateAsync(formData, { + onSuccess: async () => { + showSuccessNotification({ + title: t("certificate.action.create.notification.success.title"), + message: t("certificate.action.create.notification.success.message"), + }); + form.reset(); + await onSuccess?.(); + }, + onError: () => { + showErrorNotification({ + title: t("certificate.action.create.notification.error.title"), + message: t("certificate.action.create.notification.error.message"), + }); + }, + }); + }; + + const handleCancel = () => { + form.reset(); + onCancel?.(); + }; + + const submit = () => { + void handleSubmit().catch(() => undefined); + }; + + let submitButtonType: "button" | "submit" = "submit"; + let handleSubmitClick: (() => void) | undefined; + if (embedded) { + submitButtonType = "button"; + handleSubmitClick = submit; + } + + const content = ( + <> + <FileInput + label={t("certificate.action.create.label")} + leftSection={<IconCertificate size={16} />} + {...form.getInputProps("file")} + /> + <Group justify="end"> + {onCancel && ( + <Button onClick={handleCancel} variant="subtle" color="gray"> + {t("common.action.cancel")} + </Button> + )} + <Button type={submitButtonType} onClick={handleSubmitClick} loading={isPending}> + {t("common.action.add")} + </Button> + </Group> + </> + ); + + if (embedded) { + return <Stack>{content}</Stack>; + } + + return ( + <Stack + component="form" + onSubmit={(event) => { + event.preventDefault(); + submit(); + }} + > + {content} + </Stack> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx index 0f252cf84..274bdf816 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx @@ -1,57 +1,56 @@ "use client"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface RemoveCertificateProps { fileName: string; } export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => { - const { openConfirmModal } = useConfirmModal(); - const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation({ + const { mutateAsync, isPending } = clientApi.certificates.removeCertificate.useMutation({ async onSuccess() { await revalidatePathActionAsync("/manage/tools/certificates"); }, }); const t = useI18n("certificate"); + const tCommon = useI18n("common"); - const handleClick = () => { - openConfirmModal({ - title: t("action.remove.label"), - children: t("action.remove.confirm"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateAsync( - { fileName }, - { - onSuccess() { - showSuccessNotification({ - title: t("action.remove.notification.success.title"), - message: t("action.remove.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: t("action.remove.notification.error.title"), - message: t("action.remove.notification.error.message"), - }); - }, - }, - ); + const handleRemove = () => + mutateAsync( + { fileName }, + { + onSuccess() { + showSuccessNotification({ + title: t("action.remove.notification.success.title"), + message: t("action.remove.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("action.remove.notification.error.title"), + message: t("action.remove.notification.error.message"), + }); + }, }, - }); - }; + ); return ( - <ActionIcon onClick={handleClick} color="red" variant="subtle"> + <InlineConfirmActionIcon + onConfirm={handleRemove} + confirmLabel={tCommon("action.confirm")} + confirmationAriaLabel={tCommon("action.confirm")} + loading={isPending} + color="red" + variant="subtle" + aria-label={t("action.remove.label")} + > <IconTrash color="red" size={16} /> - </ActionIcon> + </InlineConfirmActionIcon> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx index beacd0913..10c52aff2 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx @@ -1,13 +1,12 @@ "use client"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface RemoveHostnameActionIconProps { hostname: string; @@ -15,41 +14,41 @@ interface RemoveHostnameActionIconProps { } export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => { - const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation({ + const { mutateAsync, isPending } = clientApi.certificates.removeTrustedHostname.useMutation({ async onSuccess() { await revalidatePathActionAsync("/manage/tools/certificates/hostnames"); }, }); - const { openConfirmModal } = useConfirmModal(); const t = useI18n("certificate"); + const tCommon = useI18n("common"); - const handleRemove = () => { - openConfirmModal({ - title: t("action.removeHostname.label"), - children: t("action.removeHostname.confirm"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateAsync(input, { - onSuccess() { - showSuccessNotification({ - title: t("action.removeHostname.notification.success.title"), - message: t("action.removeHostname.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: t("action.removeHostname.notification.error.title"), - message: t("action.removeHostname.notification.error.message"), - }); - }, + const handleRemove = () => + mutateAsync(input, { + onSuccess() { + showSuccessNotification({ + title: t("action.removeHostname.notification.success.title"), + message: t("action.removeHostname.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("action.removeHostname.notification.error.title"), + message: t("action.removeHostname.notification.error.message"), }); }, }); - }; return ( - <ActionIcon color="red" variant="subtle" onClick={handleRemove}> + <InlineConfirmActionIcon + onConfirm={handleRemove} + confirmLabel={tCommon("action.confirm")} + confirmationAriaLabel={tCommon("action.confirm")} + loading={isPending} + color="red" + variant="subtle" + aria-label={t("action.removeHostname.label")} + > <IconTrash color="red" size={16} stroke={1.5} /> - </ActionIcon> + </InlineConfirmActionIcon> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx index 2f75a8be4..9be9bdb42 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx @@ -13,7 +13,7 @@ import { Link } from "@homarr/ui"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { NoResults } from "~/components/no-results"; -import { AddCertificateButton } from "./_components/add-certificate"; +import { AddCertificateSection } from "./_components/add-certificate"; import { RemoveCertificate } from "./_components/remove-certificate"; interface CertificatesPageProps { @@ -69,10 +69,11 @@ export default async function CertificatesPage({ params }: CertificatesPageProps <Button variant="default" component={Link} href="/manage/tools/certificates/hostnames"> {t("page.list.toHostnames")} </Button> - <AddCertificateButton /> </Group> </Group> + <AddCertificateSection /> + {x509Certificates.length === 0 && ( <NoResults icon={IconCertificateOff} title={t("page.list.noResults.title")} /> )} diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx index c26e7cf06..2b0417611 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx @@ -2,20 +2,18 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Button } from "@mantine/core"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; interface DeleteUserButtonProps { user: RouterOutputs["user"]["getById"]; } export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => { - const tUser = useI18n("user"); const tCommon = useI18n("common"); const router = useRouter(); const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({ @@ -23,27 +21,14 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => { await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users")); }, }); - const { openConfirmModal } = useConfirmModal(); - const handleDelete = useCallback( - () => - openConfirmModal({ - title: tUser("action.delete.label"), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - children: tUser("action.delete.confirm", { username: user.name! }), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateUserDeletionAsync({ - userId: user.id, - }); - }, - }), - [user, mutateUserDeletionAsync, openConfirmModal, tUser], + () => mutateUserDeletionAsync({ userId: user.id }), + [mutateUserDeletionAsync, user.id], ); return ( - <Button onClick={handleDelete} variant="subtle" color="red"> + <InlineConfirmButton onConfirm={handleDelete} confirmLabel={tCommon("action.confirm")} variant="subtle" color="red"> {tCommon("action.delete")} - </Button> + </InlineConfirmButton> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx index 2a0830a0b..ec45ed3e2 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx @@ -8,10 +8,9 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; -import { UserAvatar } from "@homarr/ui"; +import { InlineConfirmMenuItem, UserAvatar } from "@homarr/ui"; import { useIsMobile } from "@homarr/ui/hooks"; interface UserProfileAvatarForm { @@ -19,7 +18,7 @@ interface UserProfileAvatarForm { } export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { - const { mutate } = clientApi.user.setProfileImage.useMutation({ + const { mutate, mutateAsync, isPending } = clientApi.user.setProfileImage.useMutation({ async onSuccess() { // Revalidate all as the avatar is used in multiple places await revalidatePathActionAsync("/"); @@ -27,7 +26,6 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { }); const [opened, { toggle }] = useDisclosure(false); const isMobile = useIsMobile(); - const { openConfirmModal } = useConfirmModal(); const tCommon = useI18n("common"); const tManageAvatar = useI18n("user.action.manageAvatar"); @@ -68,32 +66,28 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { [mutate, user.id, tManageAvatar], ); - const handleRemoveAvatar = useCallback(() => { - openConfirmModal({ - title: tManageAvatar("removeImage.label"), - children: tManageAvatar("removeImage.confirm"), - onConfirm() { - mutate( - { - userId: user.id, - image: null, + const handleRemoveAvatar = useCallback( + () => + mutateAsync( + { + userId: user.id, + image: null, + }, + { + onSuccess() { + showSuccessNotification({ + message: tManageAvatar("removeImage.notification.success.message"), + }); }, - { - onSuccess() { - showSuccessNotification({ - message: tManageAvatar("removeImage.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: tManageAvatar("removeImage.notification.error.message"), - }); - }, + onError() { + showErrorNotification({ + message: tManageAvatar("removeImage.notification.error.message"), + }); }, - ); - }, - }); - }, [mutate, user.id, openConfirmModal, tManageAvatar]); + }, + ), + [mutateAsync, user.id, tManageAvatar], + ); return ( <Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}> @@ -124,9 +118,15 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { )} </FileButton> {user.image && ( - <Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}> + <InlineConfirmMenuItem + color="red" + onConfirm={handleRemoveAvatar} + confirmLabel={tCommon("action.confirm")} + disabled={isPending} + leftSection={<IconPhotoX size={16} stroke={1.5} />} + > {tManageAvatar("removeImage.label")} - </Menu.Item> + </InlineConfirmMenuItem> )} </Menu.Dropdown> </Menu> diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx index de48d9ba1..2b01ca6a6 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx @@ -12,7 +12,6 @@ import { everyoneGroup, groupPermissions } from "@homarr/definitions"; import type { IsValid } from "@homarr/form"; import { useZodForm } from "@homarr/form"; import { UserCreatePasswordFields } from "@homarr/forms-collection"; -import { useModalAction } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { UserAvatar } from "@homarr/ui"; @@ -20,7 +19,7 @@ import { createCustomErrorParams } from "@homarr/validation/form/i18n"; import { optionalEmailSchema } from "@homarr/validation/email"; import { userPasswordSchema } from "@homarr/validation/user"; -import { GroupSelectModal } from "~/components/access/group-select-modal"; +import { GroupSelect } from "~/components/access/group-select"; import { StepperNavigationComponent } from "./stepper-navigation"; interface GroupWithPermissions { @@ -224,23 +223,6 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) = const tCommon = useI18n("common"); const tPermission = useI18n("permission"); const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups); - const { openModal } = useModalAction(GroupSelectModal); - - const handleAddClick = () => { - openModal({ - presentGroupIds: groups.map((group) => group.id), - withPermissions: true, - onSelect({ id, name, permissions }) { - if (!permissions) return; - - startTransition(() => { - addGroup(id); - append({ id, name, permissions }); - }); - }, - }); - }; - const handleGroupRemove = (id: string) => { filter((group) => group.id !== id); removeGroup(id); @@ -256,14 +238,23 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) = {tCreate("step.groups.description", { everyoneGroup })} </Text> </Stack> - <Button - variant="subtle" - color="gray" - leftSection={<IconPlus size={16} stroke={1.5} />} - onClick={handleAddClick} - > - {tCommon("action.add")} - </Button> + <GroupSelect + presentGroupIds={groups.map((group) => group.id)} + withPermissions + onSelect={({ id, name, permissions }) => { + if (!permissions) return; + + startTransition(() => { + addGroup(id); + append({ id, name, permissions }); + }); + }} + triggerProps={{ + variant: "subtle", + color: "gray", + leftSection: <IconPlus size={16} stroke={1.5} />, + }} + /> </Group> <Table> <Table.Thead> diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx index 7db623644..5946062d8 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx @@ -2,13 +2,13 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Button, useMatches } from "@mantine/core"; +import { useMatches } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; interface DeleteGroupProps { group: { @@ -20,46 +20,38 @@ interface DeleteGroupProps { export const DeleteGroup = ({ group }: DeleteGroupProps) => { const router = useRouter(); const { mutateAsync } = clientApi.group.deleteGroup.useMutation(); - const { openConfirmModal } = useConfirmModal(); const tDelete = useI18n("group.action.delete"); const tCommon = useI18n("common"); - const handleDeletion = useCallback(() => { - openConfirmModal({ - title: tDelete("label"), - children: tDelete("confirm", { - name: group.name, - }), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateAsync( - { - id: group.id, + const handleDeletion = useCallback( + () => + mutateAsync( + { + id: group.id, + }, + { + onSuccess() { + void revalidatePathActionAsync("/manage/users/groups"); + router.push("/manage/users/groups"); + showSuccessNotification({ + title: tCommon("notification.delete.success"), + message: tDelete("notification.success.message", { + name: group.name, + }), + }); }, - { - onSuccess() { - void revalidatePathActionAsync("/manage/users/groups"); - router.push("/manage/users/groups"); - showSuccessNotification({ - title: tCommon("notification.delete.success"), - message: tDelete("notification.success.message", { - name: group.name, - }), - }); - }, - onError() { - showErrorNotification({ - title: tCommon("notification.delete.error"), - message: tDelete("notification.error.message", { - name: group.name, - }), - }); - }, + onError() { + showErrorNotification({ + title: tCommon("notification.delete.error"), + message: tDelete("notification.error.message", { + name: group.name, + }), + }); }, - ); - }, - }); - }, [tDelete, tCommon, openConfirmModal, group.id, group.name, mutateAsync, router]); + }, + ), + [tDelete, tCommon, group.id, group.name, mutateAsync, router], + ); const fullWidth = useMatches({ xs: true, @@ -68,8 +60,14 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => { }); return ( - <Button variant="subtle" color="red" onClick={handleDeletion} fullWidth={fullWidth}> + <InlineConfirmButton + variant="subtle" + color="red" + onConfirm={handleDeletion} + confirmLabel={tCommon("action.confirm")} + fullWidth={fullWidth} + > {tDelete("label")} - </Button> + </InlineConfirmButton> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx index 1d0b26b8c..46afda084 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx @@ -1,15 +1,14 @@ "use client"; -import { useCallback, useState } from "react"; -import { Button, useMatches } from "@mantine/core"; +import { useState } from "react"; +import { Button, Group, Stack, Text, useMatches } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; -import { UserSelectModal } from "~/components/access/user-select-modal"; +import { UserSelect } from "~/components/access/user-select"; interface TransferGroupOwnershipProps { group: { @@ -19,75 +18,87 @@ interface TransferGroupOwnershipProps { }; } +interface SelectedOwner { + id: string; + name: string; +} + export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => { const tTransfer = useI18n("group.action.transfer"); const tCommon = useI18n("common"); const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId); - const { openModal } = useModalAction(UserSelectModal); - const { openConfirmModal } = useConfirmModal(); - const { mutateAsync } = clientApi.group.transferOwnership.useMutation({ + const [selectedOwner, setSelectedOwner] = useState<SelectedOwner>(); + const { mutateAsync, isPending } = clientApi.group.transferOwnership.useMutation({ async onSuccess() { await revalidatePathActionAsync(`/manage/users/groups/${group.id}`); }, }); - const handleTransfer = useCallback(() => { - openModal( + const handleTransfer = async () => { + if (!selectedOwner) return; + + await mutateAsync( { - confirmLabel: tCommon("action.continue"), - presentUserIds: innerOwnerId ? [innerOwnerId] : [], - onSelect: ({ id, name }) => { - openConfirmModal({ - title: tTransfer("label"), - children: tTransfer("confirm", { - name: group.name, - username: name, + groupId: group.id, + userId: selectedOwner.id, + }, + { + onSuccess() { + setInnerOwnerId(selectedOwner.id); + showSuccessNotification({ + title: tCommon("notification.transfer.success"), + message: tTransfer("notification.success.message", { + group: group.name, + user: selectedOwner.name, }), - // eslint-disable-next-line no-restricted-syntax - onConfirm: async () => { - await mutateAsync( - { - groupId: group.id, - userId: id, - }, - { - onSuccess() { - setInnerOwnerId(id); - showSuccessNotification({ - title: tCommon("notification.transfer.success"), - message: tTransfer("notification.success.message", { - group: group.name, - user: name, - }), - }); - }, - onError() { - showErrorNotification({ - title: tCommon("notification.transfer.error"), - message: tTransfer("notification.error.message"), - }); - }, - }, - ); - }, + }); + setSelectedOwner(undefined); + }, + onError() { + showErrorNotification({ + title: tCommon("notification.transfer.error"), + message: tTransfer("notification.error.message"), }); }, }, - { - title: tTransfer("label"), - }, ); - }, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tCommon, tTransfer]); + }; const fullWidth = useMatches({ xs: true, sm: true, md: false, }); + const presentUserIds: string[] = []; + if (innerOwnerId) presentUserIds.push(innerOwnerId); + + if (!selectedOwner) { + return ( + <UserSelect + presentUserIds={presentUserIds} + onSelect={setSelectedOwner} + triggerLabel={tTransfer("label")} + triggerProps={{ variant: "subtle", color: "red", fullWidth }} + /> + ); + } return ( - <Button variant="subtle" color="red" onClick={handleTransfer} fullWidth={fullWidth}> - {tTransfer("label")} - </Button> + <Stack gap="xs" maw={420}> + <Text size="sm"> + {tTransfer("confirm", { + name: group.name, + username: selectedOwner.name, + })} + </Text> + <Group justify="end" wrap="wrap"> + <Button variant="default" onClick={() => setSelectedOwner(undefined)} disabled={isPending}> + {tCommon("action.cancel")} + </Button> + <Button color="red" onClick={() => void handleTransfer()} loading={isPending}> + {tCommon("action.confirm")} + </Button> + </Group> + </Stack> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx index 6585e3205..20d2aa381 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx @@ -5,11 +5,9 @@ import { useCallback } from "react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import type { SupportedAuthProvider } from "@homarr/definitions"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { UserSelectModal } from "~/components/access/user-select-modal"; -import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; +import { UserSelect } from "~/components/access/user-select"; interface AddGroupMemberProps { groupId: string; @@ -20,27 +18,23 @@ interface AddGroupMemberProps { export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => { const tMembersAdd = useI18n("group.action.addMember"); const { mutateAsync } = clientApi.group.addMember.useMutation(); - const { openModal } = useModalAction(UserSelectModal); + const handleAddMember = useCallback( + async ({ id }: { id: string }) => { + await mutateAsync({ + userId: id, + groupId, + }); + await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`); + }, + [groupId, mutateAsync], + ); - const handleAddMember = useCallback(() => { - openModal( - { - // eslint-disable-next-line no-restricted-syntax - async onSelect({ id }) { - await mutateAsync({ - userId: id, - groupId, - }); - await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`); - }, - presentUserIds, - allowedProviders, - }, - { - title: tMembersAdd("label"), - }, - ); - }, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]); - - return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>; + return ( + <UserSelect + presentUserIds={presentUserIds} + allowedProviders={allowedProviders} + onSelect={handleAddMember} + triggerLabel={tMembersAdd("label")} + /> + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx index c290bd26a..600df6e81 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx @@ -1,12 +1,11 @@ "use client"; import { useCallback } from "react"; -import { Button } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; interface RemoveGroupMemberProps { groupId: string; @@ -15,30 +14,25 @@ interface RemoveGroupMemberProps { export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => { const tCommon = useI18n("common"); - const tRemoveMember = useI18n("group.action.removeMember"); const { mutateAsync } = clientApi.group.removeMember.useMutation(); - const { openConfirmModal } = useConfirmModal(); - const handleRemove = useCallback(() => { - openConfirmModal({ - title: tRemoveMember("label"), - children: tRemoveMember("confirm", { - user: user.name ?? "", - }), - // eslint-disable-next-line no-restricted-syntax - onConfirm: async () => { - await mutateAsync({ - groupId, - userId: user.id, - }); - await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`); - }, + const handleRemove = useCallback(async () => { + await mutateAsync({ + groupId, + userId: user.id, }); - }, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]); + await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`); + }, [mutateAsync, groupId, user.id]); return ( - <Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}> + <InlineConfirmButton + variant="subtle" + color="red.9" + size="compact-sm" + confirmLabel={tCommon("action.confirm")} + onConfirm={handleRemove} + > {tCommon("action.remove")} - </Button> + </InlineConfirmButton> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx index 57d544ef3..2386d070f 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx @@ -1,15 +1,14 @@ "use client"; -import { useMemo, useState } from "react"; -import { TextInput } from "@mantine/core"; +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button, Card, Collapse, Group, Stack, TextInput } from "@mantine/core"; import { IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; -import { useModalAction } from "@homarr/modals"; -import { AddGroupModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; -import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; +import { GroupCreateForm } from "./_components/group-create-form"; import { GroupsTable } from "./_groups-table"; interface GroupsListProps { @@ -18,6 +17,10 @@ interface GroupsListProps { export const GroupsList = ({ groups }: GroupsListProps) => { const [search, setSearch] = useState(""); + const router = useRouter(); + const searchParams = useSearchParams(); + const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true"); + const [formKey, setFormKey] = useState(0); const initialGroupIds = useMemo( () => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id), [groups], @@ -31,8 +34,46 @@ export const GroupsList = ({ groups }: GroupsListProps) => { ); const tGroup = useI18n("group"); + useEffect(() => { + if (searchParams.get("create") !== "true") return; + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }, [searchParams]); + + const closeCreate = () => { + setIsCreateOpen(false); + setFormKey((value) => value + 1); + if (searchParams.has("create")) router.replace("/manage/users/groups", { scroll: false }); + }; + + const toggleCreate = () => { + if (isCreateOpen) { + closeCreate(); + return; + } + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }; + return ( - <> + <Stack> + <Group justify="end"> + <Button onClick={toggleCreate}>{tGroup("action.create.label")}</Button> + </Group> + <Collapse expanded={isCreateOpen}> + <Card withBorder> + <GroupCreateForm + key={formKey} + onCancel={closeCreate} + onCreated={() => { + closeCreate(); + router.refresh(); + }} + /> + </Card> + </Collapse> <TextInput leftSection={<IconSearch size={20} stroke={1.5} />} value={search} @@ -40,13 +81,6 @@ export const GroupsList = ({ groups }: GroupsListProps) => { placeholder={`${tGroup("search")}...`} /> <GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} /> - </> + </Stack> ); }; - -export const AddGroupButton = () => { - const tGroup = useI18n("group"); - const { openModal } = useModalAction(AddGroupModal); - - return <MobileAffixButton onClick={() => openModal()}>{tGroup("action.create.label")}</MobileAffixButton>; -}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx new file mode 100644 index 000000000..35f2457cc --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Button, Group, Stack, TextInput } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { groupCreateSchema } from "@homarr/validation/group"; + +interface GroupCreateFormProps { + onCancel: () => void; + onCreated: () => void; +} + +export const GroupCreateForm = ({ onCancel, onCreated }: GroupCreateFormProps) => { + const tGroup = useI18n("group"); + const tCommon = useI18n("common"); + const form = useZodForm(groupCreateSchema, { + initialValues: { + name: "", + }, + }); + const { mutateAsync, isPending } = clientApi.group.createGroup.useMutation(); + + const handleSubmit = form.onSubmit(async (values) => { + await mutateAsync(values, { + async onSuccess() { + await revalidatePathActionAsync("/manage/users/groups"); + showSuccessNotification({ + title: tCommon("notification.create.success"), + message: tGroup("action.create.notification.success.message"), + }); + onCreated(); + }, + onError() { + showErrorNotification({ + title: tCommon("notification.create.error"), + message: tGroup("action.create.notification.error.message"), + }); + }, + }); + }); + + return ( + <form onSubmit={handleSubmit}> + <Stack> + <TextInput label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} /> + <Group justify="end"> + <Button variant="default" onClick={onCancel}> + {tCommon("action.cancel")} + </Button> + <Button loading={isPending} type="submit"> + {tCommon("action.create")} + </Button> + </Group> + </Stack> + </form> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx index ba9df38e3..eced4a16d 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx @@ -9,7 +9,7 @@ import { getI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; -import { AddGroupButton, GroupsList } from "./_client"; +import { GroupsList } from "./_client"; import classes from "./groups.module.css"; export default async function GroupsListPage() { @@ -26,7 +26,7 @@ export default async function GroupsListPage() { const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup); return ( - <ManagePageLayout title={tCommon("entity.groups")} primaryAction={<AddGroupButton />} floatingPrimaryAction> + <ManagePageLayout title={tCommon("entity.groups")}> <Stack> {dbEveryoneGroup && ( <UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}> diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx index f84865ffb..42ca121fd 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx @@ -2,18 +2,15 @@ import { Button } from "@mantine/core"; -import { useModalAction } from "@homarr/modals"; -import { InviteCreateModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants"; -export const InviteCreateButton = () => { +export const InviteCreateButton = ({ onClick }: { onClick: () => void }) => { const t = useI18n("management.page.user.invite"); - const { openModal } = useModalAction(InviteCreateModal); return ( - <Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}> + <Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={onClick}> {t("action.new.title")} </Button> ); diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx new file mode 100644 index 000000000..c56aecf0b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import type { ReactNode } from "react"; +import { Anchor, Button, CopyButton, Group, Mark, Stack, Text } from "@mantine/core"; +import { DateTimePicker } from "@mantine/dates"; +import dayjs from "dayjs"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { invariantTechnicalLabels } from "@homarr/definitions"; +import { useForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; + +type FormType = { + expirationDate: string; +}; + +type CreatedInvite = RouterOutputs["invite"]["createInvite"]; + +interface InviteCreateFormProps { + onClose: () => void; +} + +export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => { + const tInvite = useI18n("management.page.user.invite"); + const tCommon = useI18n("common"); + const [createdInvite, setCreatedInvite] = useState<CreatedInvite>(); + const utils = clientApi.useUtils(); + const { mutate, isPending } = clientApi.invite.createInvite.useMutation(); + const minDate = dayjs().add(1, "hour").toDate(); + const maxDate = dayjs().add(6, "months").toDate(); + const form = useForm<FormType>({ + initialValues: { + expirationDate: dayjs().add(4, "hours").toDate().toISOString(), + }, + }); + + if (createdInvite) { + return <CreatedInviteDetails invite={createdInvite} onClose={onClose} />; + } + + return ( + <form + onSubmit={form.onSubmit((values) => { + mutate( + { + expirationDate: new Date(values.expirationDate), + }, + { + onSuccess: (result) => { + void utils.invite.getAll.invalidate(); + setCreatedInvite(result); + }, + }, + ); + })} + > + <Stack> + <Text>{tInvite("action.new.description")}</Text> + <DateTimePicker + popoverProps={{ withinPortal: true }} + minDate={minDate} + maxDate={maxDate} + withAsterisk + valueFormat="DD MMM YYYY HH:mm" + label={tInvite("field.expirationDate.label")} + variant="filled" + {...form.getInputProps("expirationDate")} + /> + <Group justify="end" wrap="wrap"> + <Button variant="default" onClick={onClose}> + {tCommon("action.cancel")} + </Button> + <Button type="submit" loading={isPending}> + {tCommon("action.create")} + </Button> + </Group> + </Stack> + </form> + ); +}; + +const CreatedInviteDetails = ({ invite, onClose }: { invite: CreatedInvite; onClose: () => void }) => { + const tInvite = useI18n("management.page.user.invite"); + const tCommon = useI18n("common"); + const inviteUrl = createInviteUrl(invite); + + return ( + <Stack component="output" aria-live="polite"> + <Text> + {tInvite.rich("action.copy.description", { + b: bold, + })} + </Text> + <Stack gap="xs"> + <Text fw="bold">{tInvite("action.copy.link")}:</Text> + <Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}> + <Anchor href={inviteUrl} target="_blank" rel="noreferrer"> + {inviteUrl} + </Anchor> + </Mark> + <Text fw="bold">{invariantTechnicalLabels.id}:</Text> + <Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}> + {invite.id} + </Mark> + <Text fw="bold">{tInvite("field.token.label")}:</Text> + <Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}> + {invite.token} + </Mark> + </Stack> + <Group justify="end" wrap="wrap"> + <Button variant="default" onClick={onClose}> + {tCommon("action.close")} + </Button> + <CopyButton value={inviteUrl}> + {({ copy }) => ( + <Button + onClick={() => { + copy(); + onClose(); + }} + > + {tInvite("action.copy.button")} + </Button> + )} + </CopyButton> + </Group> + </Stack> + ); +}; + +const bold = (children: ReactNode) => <b>{children}</b>; + +const createInviteUrl = ({ id, token }: CreatedInvite) => { + const path = `/auth/invite/${id}?token=${token}`; + return new URL(path, window.location.origin).toString(); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx index 09ca354d2..7502fa6c2 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useMemo } from "react"; -import { ActionIcon } from "@mantine/core"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Card, Collapse, Group, Stack } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; @@ -11,9 +12,11 @@ import { MantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { invariantTechnicalLabels } from "@homarr/definitions"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { InviteCreateButton } from "./invite-create-button"; +import { InviteCreateForm } from "./invite-create-form"; dayjs.extend(relativeTime); @@ -23,6 +26,10 @@ interface InviteListComponentProps { export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => { const t = useI18n("management.page.user.invite"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true"); + const [formKey, setFormKey] = useState(0); const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, { initialData: initialInvites, refetchOnMount: false, @@ -73,28 +80,65 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps }, }); - return <MantineReactTable table={table} />; + useEffect(() => { + if (searchParams.get("create") !== "true") return; + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }, [searchParams]); + + const closeCreate = () => { + setIsCreateOpen(false); + setFormKey((value) => value + 1); + if (searchParams.has("create")) router.replace("/manage/users/invites", { scroll: false }); + }; + + const toggleCreate = () => { + if (isCreateOpen) { + closeCreate(); + return; + } + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }; + + return ( + <Stack> + <Group justify="end"> + <InviteCreateButton onClick={toggleCreate} /> + </Group> + <Collapse expanded={isCreateOpen}> + <Card withBorder> + <InviteCreateForm key={formKey} onClose={closeCreate} /> + </Card> + </Collapse> + <MantineReactTable table={table} /> + </Stack> + ); }; const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => { const t = useI18n("management.page.user.invite"); + const tCommon = useI18n("common"); const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation(); const utils = clientApi.useUtils(); - const { openConfirmModal } = useConfirmModal(); const handleDelete = useCallback(() => { - openConfirmModal({ - title: t("action.delete.title"), - children: t("action.delete.description"), - onConfirm: () => { - mutate({ id: row.original.id }); - void utils.invite.getAll.invalidate(); - }, - }); - }, [openConfirmModal, row.original.id, mutate, utils, t]); + mutate({ id: row.original.id }); + void utils.invite.getAll.invalidate(); + }, [row.original.id, mutate, utils]); return ( - <ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}> + <InlineConfirmActionIcon + variant="subtle" + color="red" + onConfirm={handleDelete} + confirmLabel={tCommon("action.confirm")} + confirmationAriaLabel={tCommon("action.confirm")} + loading={isPending} + aria-label={t("action.delete.title")} + > <IconTrash color="red" size={20} stroke={1.5} /> - </ActionIcon> + </InlineConfirmActionIcon> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx index 85dea15a8..5f6656e64 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx @@ -5,9 +5,7 @@ import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { getI18n } from "@homarr/translation/server"; -import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; -import { InviteCreateButton } from "./_components/invite-create-button"; import { InviteListComponent } from "./_components/invite-list"; export default async function InvitesOverviewPage() { @@ -24,15 +22,7 @@ export default async function InvitesOverviewPage() { const initialInvites = await api.invite.getAll(); return ( - <ManagePageLayout - title={t("title")} - primaryAction={ - <ManageMobilePrimaryAction> - <InviteCreateButton /> - </ManageMobilePrimaryAction> - } - floatingPrimaryAction - > + <ManagePageLayout title={t("title")}> <InviteListComponent initialInvites={initialInvites} /> </ManagePageLayout> ); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 551f2d656..b19b71919 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -20,8 +20,8 @@ import { WidgetError } from "@homarr/widgets/errors"; import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal"; -import type { Dimensions } from "./_dimension-modal"; -import { PreviewDimensionsModal } from "./_dimension-modal"; +import type { Dimensions } from "./_dimension-popover"; +import { PreviewDimensionsPopover } from "./_dimension-popover"; interface WidgetPreviewPageContentProps { kind: WidgetKind; @@ -32,7 +32,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps const t = useI18n(); const utils = clientApi.useUtils(); const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal); - const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const [editMode, setEditMode] = useState(false); const [isEditorLoading, setIsEditorLoading] = useState(false); @@ -98,13 +97,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps }); }, [editMode, t]); - const openDimensionsModal = useCallback(() => { - openPreviewDimensionsModal({ - dimensions, - setDimensions, - }); - }, [dimensions, openPreviewDimensionsModal]); - const updateOptions = useCallback( ({ newOptions }: { newOptions: Record<string, unknown> }) => setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })), @@ -162,15 +154,21 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps </ActionIcon> </Affix> <Affix bottom={12} right={72 + 120}> - <ActionIcon - size={48} - variant="default" - radius="xl" - onClick={openDimensionsModal} - aria-label={t("widgetPreview.dimensions.title")} - > - <IconDimensions size={24} /> - </ActionIcon> + <PreviewDimensionsPopover + dimensions={dimensions} + setDimensions={setDimensions} + target={(onClick) => ( + <ActionIcon + size={48} + variant="default" + radius="xl" + aria-label={t("widgetPreview.dimensions.title")} + onClick={onClick} + > + <IconDimensions size={24} /> + </ActionIcon> + )} + /> </Affix> </> ); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx deleted file mode 100644 index e8466f116..000000000 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Button, Group, InputWrapper, Slider, Stack } from "@mantine/core"; - -import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -interface InnerProps { - dimensions: Dimensions; - setDimensions: (dimensions: Dimensions) => void; -} - -export const PreviewDimensionsModal = createModal<InnerProps>(({ actions, innerProps }) => { - const t = useI18n(); - const form = useForm({ - initialValues: innerProps.dimensions, - }); - - const handleSubmit = (values: Dimensions) => { - innerProps.setDimensions(values); - actions.closeModal(); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <InputWrapper label={t("item.moveResize.field.width.label")}> - <Slider min={64} max={1024} step={64} {...form.getInputProps("width")} /> - </InputWrapper> - <InputWrapper label={t("item.moveResize.field.height.label")}> - <Slider min={64} max={1024} step={64} {...form.getInputProps("height")} /> - </InputWrapper> - <Group justify="end"> - <Button variant="subtle" color="gray" onClick={actions.closeModal}> - {t("common.action.cancel")} - </Button> - <Button type="submit">{t("common.action.confirm")}</Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("widgetPreview.dimensions.title"), -}); - -export interface Dimensions { - width: number; - height: number; -} diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx new file mode 100644 index 000000000..46f3e8f8d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx @@ -0,0 +1,78 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Button, Group, InputWrapper, Popover, Slider, Stack } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; + +import { useForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; + +interface PreviewDimensionsPopoverProps { + target: (onClick: () => void) => ReactNode; + dimensions: Dimensions; + setDimensions: (dimensions: Dimensions) => void; +} + +export const PreviewDimensionsPopover = ({ target, dimensions, setDimensions }: PreviewDimensionsPopoverProps) => { + const t = useI18n(); + const [opened, { close, open }] = useDisclosure(false); + const form = useForm({ + initialValues: dimensions, + }); + + const handleSubmit = (values: Dimensions) => { + setDimensions(values); + close(); + }; + + const handleClose = () => { + form.setValues(dimensions); + close(); + }; + + const handleToggle = () => { + if (opened) { + handleClose(); + return; + } + open(); + }; + + return ( + <Popover + opened={opened} + onChange={(nextOpened) => { + if (nextOpened) open(); + if (!nextOpened) handleClose(); + }} + position="top-end" + width="min(320px, calc(100vw - 2rem))" + withArrow + > + <Popover.Target>{target(handleToggle)}</Popover.Target> + <Popover.Dropdown> + <form onSubmit={form.onSubmit(handleSubmit)}> + <Stack> + <InputWrapper label={t("item.moveResize.field.width.label")}> + <Slider min={64} max={1024} step={64} {...form.getInputProps("width")} /> + </InputWrapper> + <InputWrapper label={t("item.moveResize.field.height.label")}> + <Slider min={64} max={1024} step={64} {...form.getInputProps("height")} /> + </InputWrapper> + <Group justify="end"> + <Button variant="subtle" color="gray" onClick={handleClose}> + {t("common.action.cancel")} + </Button> + <Button type="submit">{t("common.action.confirm")}</Button> + </Group> + </Stack> + </form> + </Popover.Dropdown> + </Popover> + ); +}; + +export interface Dimensions { + width: number; + height: number; +} diff --git a/apps/nextjs/src/components/access/access-picker.tsx b/apps/nextjs/src/components/access/access-picker.tsx new file mode 100644 index 000000000..98e8b8f04 --- /dev/null +++ b/apps/nextjs/src/components/access/access-picker.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState } from "react"; +import type { ReactNode } from "react"; +import { Button, Center, Combobox, Group, Loader, ScrollArea, useCombobox } from "@mantine/core"; +import type { ButtonProps } from "@mantine/core"; + +export interface AccessPickerOption<T> { + value: string; + label: string; + keywords?: readonly string[]; + content: ReactNode; + item: T; +} + +export type AccessPickerTriggerProps = Omit<ButtonProps, "children" | "loading" | "onClick" | "type">; + +interface AccessPickerProps<T> { + label: string; + searchPlaceholder: string; + emptyMessage: string; + triggerLabel: string; + options: AccessPickerOption<T>[]; + isPending: boolean; + onSelect: (option: AccessPickerOption<T>) => void | Promise<void>; + triggerProps?: AccessPickerTriggerProps; +} + +export const AccessPicker = <T,>({ + label, + searchPlaceholder, + emptyMessage, + triggerLabel, + options, + isPending, + onSelect, + triggerProps, +}: AccessPickerProps<T>) => { + const [search, setSearch] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const combobox = useCombobox({ + onDropdownClose: () => { + combobox.resetSelectedOption(); + setSearch(""); + requestAnimationFrame(() => combobox.focusTarget()); + }, + onDropdownOpen: () => { + combobox.selectFirstOption(); + requestAnimationFrame(() => combobox.focusSearchInput()); + }, + }); + + const normalizedSearch = search.trim().toLowerCase(); + const filteredOptions = options.filter((option) => { + if (!normalizedSearch) return true; + + const searchableText = [option.label, ...(option.keywords ?? [])].join(" ").toLowerCase(); + return searchableText.includes(normalizedSearch); + }); + const visibleOptions = filteredOptions.slice(0, 5); + + const handleSelect = (value: string) => { + const option = options.find((item) => item.value === value); + if (!option || isSubmitting) return; + + combobox.closeDropdown(); + setIsSubmitting(true); + + const submit = async () => { + try { + await onSelect(option); + } finally { + setIsSubmitting(false); + } + }; + + void submit().catch(() => undefined); + }; + + return ( + <Combobox store={combobox} onOptionSubmit={handleSelect} width="target" withinPortal> + <Combobox.Target targetType="button"> + <Button + {...triggerProps} + onClick={() => combobox.toggleDropdown()} + rightSection={ + <Group gap={4} wrap="nowrap"> + {triggerProps?.rightSection} + <Combobox.Chevron /> + </Group> + } + loading={isSubmitting} + disabled={isSubmitting || triggerProps?.disabled} + > + {triggerLabel} + </Button> + </Combobox.Target> + + <Combobox.Dropdown> + <Combobox.Search + value={search} + onChange={(event) => { + setSearch(event.currentTarget.value); + combobox.updateSelectedOptionIndex(); + }} + placeholder={searchPlaceholder} + aria-label={label} + /> + <ScrollArea.Autosize mah={300} type="auto"> + <Combobox.Options> + {isPending && ( + <Center p="md"> + <LoaderPlaceholder /> + </Center> + )} + {!isPending && filteredOptions.length === 0 && <Combobox.Empty>{emptyMessage}</Combobox.Empty>} + {!isPending && + visibleOptions.map((option) => ( + <Combobox.Option value={option.value} key={option.value}> + {option.content} + </Combobox.Option> + ))} + </Combobox.Options> + </ScrollArea.Autosize> + </Combobox.Dropdown> + </Combobox> + ); +}; + +const LoaderPlaceholder = () => <Loader size="sm" />; diff --git a/apps/nextjs/src/components/access/group-access-form.tsx b/apps/nextjs/src/components/access/group-access-form.tsx index 4963887f6..d71495e56 100644 --- a/apps/nextjs/src/components/access/group-access-form.tsx +++ b/apps/nextjs/src/components/access/group-access-form.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import { IconPlus } from "@tabler/icons-react"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; @@ -11,7 +10,7 @@ import { AccessSelectRow } from "./access-table-rows"; import { useAccessContext } from "./context"; import type { AccessFormType } from "./form"; import { FormProvider, useForm } from "./form"; -import { GroupSelectModal } from "./group-select-modal"; +import { GroupSelect } from "./group-select"; import type { FormProps } from "./user-access-form"; export const GroupAccessForm = <TPermission extends string>({ @@ -24,8 +23,6 @@ export const GroupAccessForm = <TPermission extends string>({ const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>( new Map(accessQueryData.groups.map(({ group }) => [group.id, group])), ); - const { openModal } = useModalAction(GroupSelectModal); - const tCommon = useI18n("common"); const tPermissions = useI18n("permission"); const form = useForm({ initialValues: { @@ -36,23 +33,6 @@ export const GroupAccessForm = <TPermission extends string>({ }, }); - const handleAddUser = () => { - openModal({ - presentGroupIds: form.values.items.map(({ principalId: id }) => id), - onSelect: (group) => { - setGroups((prev) => new Map(prev).set(group.id, group)); - form.setFieldValue("items", [ - { - principalId: group.id, - permission: defaultPermission, - }, - ...form.values.items, - ]); - handleCountChange((prev) => prev + 1); - }, - }); - }; - return ( <form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}> <FormProvider form={form}> @@ -79,9 +59,21 @@ export const GroupAccessForm = <TPermission extends string>({ </Table> <Group justify="space-between"> - <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}> - {tCommon("action.add")} - </Button> + <GroupSelect + presentGroupIds={form.values.items.map(({ principalId: id }) => id)} + onSelect={(group) => { + setGroups((prev) => new Map(prev).set(group.id, group)); + form.setFieldValue("items", [ + { + principalId: group.id, + permission: defaultPermission, + }, + ...form.values.items, + ]); + handleCountChange((prev) => prev + 1); + }} + triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }} + /> <Button type="submit" loading={isPending}> {tPermissions("action.saveGroup")} </Button> diff --git a/apps/nextjs/src/components/access/group-select-modal.tsx b/apps/nextjs/src/components/access/group-select-modal.tsx deleted file mode 100644 index d66f9f733..000000000 --- a/apps/nextjs/src/components/access/group-select-modal.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useState } from "react"; -import { Button, Group, Loader, Select, Stack } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import type { GroupPermissionKey } from "@homarr/definitions"; -import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -interface InnerProps { - withPermissions?: boolean; - presentGroupIds: string[]; - onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>; - confirmLabel?: string; -} - -interface GroupSelectFormType { - groupId: string; -} - -export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { - const tGroup = useI18n("group.action.select"); - const tCommon = useI18n("common"); - const { data: groups, isPending } = clientApi.group.selectable.useQuery({ - withPermissions: innerProps.withPermissions, - }); - const [loading, setLoading] = useState(false); - const form = useForm<GroupSelectFormType>(); - const handleSubmitAsync = async (values: GroupSelectFormType) => { - const currentGroup = groups?.find((group) => group.id === values.groupId); - if (!currentGroup) return; - setLoading(true); - await innerProps.onSelect({ - id: currentGroup.id, - name: currentGroup.name, - permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined, - }); - - setLoading(false); - actions.closeModal(); - }; - - const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add"); - - return ( - <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> - <Stack> - <Select - {...form.getInputProps("groupId")} - label={tGroup("label")} - clearable - searchable - leftSection={isPending ? <Loader size="xs" /> : undefined} - nothingFoundMessage={tGroup("notFound")} - limit={5} - data={groups - ?.filter((group) => !innerProps.presentGroupIds.includes(group.id)) - .map((group) => ({ value: group.id, label: group.name }))} - /> - <Group justify="end"> - <Button variant="default" onClick={actions.closeModal}> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={loading}> - {confirmLabel} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("permission.groupSelect.title"), -}); diff --git a/apps/nextjs/src/components/access/group-select.tsx b/apps/nextjs/src/components/access/group-select.tsx new file mode 100644 index 000000000..476c96224 --- /dev/null +++ b/apps/nextjs/src/components/access/group-select.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Text } from "@mantine/core"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import type { GroupPermissionKey } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; + +import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker"; +import { AccessPicker } from "./access-picker"; + +type SelectableGroup = RouterOutputs["group"]["selectable"][number]; + +interface GroupSelectProps { + withPermissions?: boolean; + presentGroupIds: string[]; + onSelect: (group: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>; + triggerLabel?: string; + triggerProps?: AccessPickerTriggerProps; +} + +export const GroupSelect = ({ + withPermissions, + presentGroupIds, + onSelect, + triggerLabel, + triggerProps, +}: GroupSelectProps) => { + const tGroup = useI18n("group.action.select"); + const tCommon = useI18n("common"); + const { data: groups, isPending } = clientApi.group.selectable.useQuery({ withPermissions }); + const availableGroups = (groups ?? []).filter((group) => !presentGroupIds.includes(group.id)); + const options: AccessPickerOption<SelectableGroup>[] = availableGroups.map((group) => ({ + value: group.id, + label: group.name, + keywords: getGroupPermissions(group) ?? [], + item: group, + content: <Text size="sm">{group.name}</Text>, + })); + + const handleSelect = ({ item: group }: AccessPickerOption<SelectableGroup>) => + onSelect({ + id: group.id, + name: group.name, + permissions: getGroupPermissions(group), + }); + + return ( + <AccessPicker + label={tGroup("label")} + searchPlaceholder={tGroup("label")} + emptyMessage={tGroup("notFound")} + triggerLabel={triggerLabel ?? tCommon("action.add")} + triggerProps={triggerProps} + options={options} + isPending={isPending} + onSelect={handleSelect} + /> + ); +}; + +const getGroupPermissions = (group: SelectableGroup) => { + if ("permissions" in group) return group.permissions as GroupPermissionKey[]; + return undefined; +}; diff --git a/apps/nextjs/src/components/access/user-access-form.tsx b/apps/nextjs/src/components/access/user-access-form.tsx index af85d1e61..8f7126992 100644 --- a/apps/nextjs/src/components/access/user-access-form.tsx +++ b/apps/nextjs/src/components/access/user-access-form.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import { IconPlus } from "@tabler/icons-react"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { Link, UserAvatar } from "@homarr/ui"; @@ -11,7 +10,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows"; import { useAccessContext } from "./context"; import type { AccessFormType, HandleCountChange } from "./form"; import { FormProvider, useForm } from "./form"; -import { UserSelectModal } from "./user-select-modal"; +import { UserSelect } from "./user-select"; export interface FormProps<TPermission extends string> { entity: { @@ -41,8 +40,6 @@ export const UsersAccessForm = <TPermission extends string>({ const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>( new Map(accessQueryData.users.map(({ user }) => [user.id, user])), ); - const { openModal } = useModalAction(UserSelectModal); - const tCommon = useI18n("common"); const tPermissions = useI18n("permission"); const form = useForm({ initialValues: { @@ -52,25 +49,8 @@ export const UsersAccessForm = <TPermission extends string>({ })), }, }); - - const handleAddUser = () => { - const presentUserIds = form.values.items.map(({ principalId: id }) => id); - - openModal({ - presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds, - onSelect: (user) => { - setUsers((prev) => new Map(prev).set(user.id, user)); - form.setFieldValue("items", [ - { - principalId: user.id, - permission: defaultPermission, - }, - ...form.values.items, - ]); - handleCountChange((prev) => prev + 1); - }, - }); - }; + const presentUserIds = form.values.items.map(({ principalId: id }) => id); + if (entity.ownerId) presentUserIds.push(entity.ownerId); return ( <form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}> @@ -101,9 +81,21 @@ export const UsersAccessForm = <TPermission extends string>({ </Table> <Group justify="space-between"> - <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}> - {tCommon("action.add")} - </Button> + <UserSelect + presentUserIds={presentUserIds} + onSelect={(user) => { + setUsers((prev) => new Map(prev).set(user.id, user)); + form.setFieldValue("items", [ + { + principalId: user.id, + permission: defaultPermission, + }, + ...form.values.items, + ]); + handleCountChange((prev) => prev + 1); + }} + triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }} + /> <Button type="submit" loading={isPending}> {tPermissions("action.saveUser")} </Button> diff --git a/apps/nextjs/src/components/access/user-select-modal.tsx b/apps/nextjs/src/components/access/user-select-modal.tsx deleted file mode 100644 index 8ba1be5ec..000000000 --- a/apps/nextjs/src/components/access/user-select-modal.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useState } from "react"; -import type { SelectProps } from "@mantine/core"; -import { Button, Group, Loader, Select, Stack } from "@mantine/core"; -import { IconCheck } from "@tabler/icons-react"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import type { SupportedAuthProvider } from "@homarr/definitions"; -import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { UserAvatar } from "@homarr/ui"; - -interface InnerProps { - presentUserIds: string[]; - allowedProviders?: SupportedAuthProvider[]; - onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>; - confirmLabel?: string; -} - -interface UserSelectFormType { - userId: string; -} - -export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { - const tUser = useI18n("user.action.select"); - const tCommon = useI18n("common"); - const { data: users, isPending } = clientApi.user.selectable.useQuery({ - providers: innerProps.allowedProviders, - }); - const [loading, setLoading] = useState(false); - const form = useForm<UserSelectFormType>(); - const handleSubmitAsync = async (values: UserSelectFormType) => { - const currentUser = users?.find((user) => user.id === values.userId); - if (!currentUser) return; - setLoading(true); - await innerProps.onSelect({ - id: currentUser.id, - name: currentUser.name ?? "", - image: currentUser.image ?? "", - email: currentUser.email ?? null, - }); - - setLoading(false); - actions.closeModal(); - }; - - const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add"); - const currentUser = users?.find((user) => user.id === form.values.userId); - - return ( - <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> - <Stack> - <Select - {...form.getInputProps("userId")} - label={tUser("label")} - searchable - clearable - leftSection={ - isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined - } - nothingFoundMessage={tUser("notFound")} - renderOption={createRenderOption(users ?? [])} - limit={5} - data={users - ?.filter((user) => !innerProps.presentUserIds.includes(user.id)) - .map((user) => ({ value: user.id, label: user.name ?? "" }))} - /> - <Group justify="end"> - <Button variant="default" onClick={actions.closeModal}> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={loading}> - {confirmLabel} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("permission.userSelect.title"), -}); - -const iconProps = { - stroke: 1.5, - color: "currentColor", - opacity: 0.6, - size: "1rem", -}; - -const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] => - function InnerRenderRoot({ option, checked }) { - const user = users.find((user) => user.id === option.value); - if (!user) return null; - - return ( - <Group flex="1" gap="xs"> - <UserAvatar user={user} size="xs" /> - {option.label} - {checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />} - </Group> - ); - }; diff --git a/apps/nextjs/src/components/access/user-select.tsx b/apps/nextjs/src/components/access/user-select.tsx new file mode 100644 index 000000000..c38bca1ce --- /dev/null +++ b/apps/nextjs/src/components/access/user-select.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Group, Stack, Text } from "@mantine/core"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import type { SupportedAuthProvider } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { UserAvatar } from "@homarr/ui"; + +import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker"; +import { AccessPicker } from "./access-picker"; + +type SelectableUser = RouterOutputs["user"]["selectable"][number]; + +interface UserSelectProps { + presentUserIds: string[]; + allowedProviders?: SupportedAuthProvider[]; + onSelect: (user: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>; + triggerLabel?: string; + triggerProps?: AccessPickerTriggerProps; +} + +export const UserSelect = ({ + presentUserIds, + allowedProviders, + onSelect, + triggerLabel, + triggerProps, +}: UserSelectProps) => { + const tUser = useI18n("user.action.select"); + const tCommon = useI18n("common"); + const { data: users, isPending } = clientApi.user.selectable.useQuery({ providers: allowedProviders }); + const availableUsers = (users ?? []).filter((user) => !presentUserIds.includes(user.id)); + const options: AccessPickerOption<SelectableUser>[] = availableUsers.map((user) => ({ + value: user.id, + label: user.name ?? "", + keywords: [user.email ?? ""], + item: user, + content: ( + <Group wrap="nowrap" gap="xs"> + <UserAvatar user={user} size="xs" /> + <Stack gap={0}> + <Text size="sm">{user.name}</Text> + {user.email && ( + <Text size="xs" c="dimmed"> + {user.email} + </Text> + )} + </Stack> + </Group> + ), + })); + + const handleSelect = ({ item: user }: AccessPickerOption<SelectableUser>) => + onSelect({ + id: user.id, + name: user.name ?? "", + image: user.image ?? "", + email: user.email ?? null, + }); + + return ( + <AccessPicker + label={tUser("label")} + searchPlaceholder={tUser("label")} + emptyMessage={tUser("notFound")} + triggerLabel={triggerLabel ?? tCommon("action.add")} + triggerProps={triggerProps} + options={options} + isPending={isPending} + onSelect={handleSelect} + /> + ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index a8b8eea9b..df27b7856 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -130,8 +130,8 @@ import { useTimeAgo } from "@homarr/common"; import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions"; import type { AssistantProvider } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useConfirmModal } from "@homarr/modals"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; import classes from "./assistant-panel.module.css"; import { getAssistantActivityState } from "./assistant-activity-state"; @@ -2001,7 +2001,6 @@ const ThreadListItem = () => { const t = useI18n("assistant"); const actionT = useI18n("common.action"); const aui = useAui(); - const { openConfirmModal } = useConfirmModal(); const onSelect = useContext(HistorySelectContext); const remoteId = useAuiState((state) => state.threadListItem.remoteId); const title = useAuiState((state) => state.threadListItem.title); @@ -2054,23 +2053,15 @@ const ThreadListItem = () => { } }; - const deleteConversation = () => { - openConfirmModal({ - title: t("deleteConversation.title"), - children: t("deleteConversation.description", { title: title ?? t("newConversation") }), - confirmProps: { color: "red" }, - labels: { confirm: actionT("delete"), cancel: actionT("cancel") }, - onConfirm: async () => { - try { - await aui.threadListItem().delete(); - } catch { - showErrorNotification({ - title: t("deleteConversation.failedTitle"), - message: t("deleteConversation.failedDescription"), - }); - } - }, - }); + const deleteConversation = async () => { + try { + await aui.threadListItem().delete(); + } catch { + showErrorNotification({ + title: t("deleteConversation.failedTitle"), + message: t("deleteConversation.failedDescription"), + }); + } }; if (renameOpened) { @@ -2152,16 +2143,19 @@ const ThreadListItem = () => { > <IconFileExport size={14} /> </ActionIcon> - <ActionIcon + <InlineConfirmActionIcon variant="subtle" color="red" size="sm" title={actionT("delete")} aria-label={actionT("delete")} - onClick={deleteConversation} + confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })} + confirmationAriaLabel={t("deleteConversation.title")} + confirmationChildren={<IconCheck size={14} />} + onConfirm={deleteConversation} > <IconTrash size={14} /> - </ActionIcon> + </InlineConfirmActionIcon> </Group> )} <ActionIcon diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 1b18dd5a9..8db0532f8 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { ActionIcon, Menu } from "@mantine/core"; -import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; +import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; import { useSession } from "@homarr/auth/client"; import { useEditMode } from "@homarr/boards/edit-mode"; @@ -8,14 +8,14 @@ import { getWidgetName } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmMenuItem } from "@homarr/ui"; import type { WidgetDefinition } from "@homarr/widgets/definition"; import type { SectionItem } from "~/app/[locale]/boards/_types"; -import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; -import { useOpenItemMoveModal } from "./item-move-modal"; import itemContentClasses from "./item-content.module.css"; +import { useOpenItemMoveModal } from "./item-move-modal"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; interface BoardItemMenuProps { @@ -142,11 +142,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou </Menu.Item> )} <Menu.Divider /> - <BoardRemoveConfirmationMenuItem - label={tItem("action.remove")} - confirmationLabel={tItem("remove.message")} + <InlineConfirmMenuItem + color="red" + confirmLabel={tItem("remove.message")} + leftSection={<IconTrash size={16} />} onConfirm={() => removeItem({ itemId: item.id })} - /> + > + {tItem("action.remove")} + </InlineConfirmMenuItem> </Menu.Dropdown> </Menu> ); diff --git a/apps/nextjs/src/components/board/modals/board-rename-modal.tsx b/apps/nextjs/src/components/board/modals/board-rename-modal.tsx deleted file mode 100644 index 4e91146fb..000000000 --- a/apps/nextjs/src/components/board/modals/board-rename-modal.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { Button, Group, Stack, TextInput } from "@mantine/core"; -import type { z } from "zod/v4"; - -import { clientApi } from "@homarr/api/client"; -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { boardRenameSchema } from "@homarr/validation/board"; - -interface InnerProps { - id: string; - previousName: string; - onSuccess?: (name: string) => void; -} - -export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }) => { - const utils = clientApi.useUtils(); - const tCommon = useI18n("common"); - const { mutate, isPending } = clientApi.board.renameBoard.useMutation({ - onSettled() { - void utils.board.getBoardByName.invalidate({ - name: innerProps.previousName, - }); - void utils.board.getHomeBoard.invalidate(); - void utils.board.getAllBoards.invalidate(); - }, - }); - const form = useZodForm(boardRenameSchema.omit({ id: true }), { - initialValues: { - name: innerProps.previousName, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate( - { - id: innerProps.id, - name: values.name, - }, - { - onSuccess: () => { - actions.closeModal(); - innerProps.onSuccess?.(values.name); - }, - }, - ); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <TextInput label={tCommon("field.name")} {...form.getInputProps("name")} data-autofocus /> - <Group justify="end"> - <Button variant="subtle" color="gray" onClick={actions.closeModal}> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={isPending}> - {tCommon("action.confirm")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("board.setting.section.dangerZone.action.rename.label"), -}); - -type FormType = Omit<z.infer<typeof boardRenameSchema>, "id">; diff --git a/apps/nextjs/src/components/board/sections/container/container-menu.tsx b/apps/nextjs/src/components/board/sections/container/container-menu.tsx index dfbc36f94..0e415b1ae 100644 --- a/apps/nextjs/src/components/board/sections/container/container-menu.tsx +++ b/apps/nextjs/src/components/board/sections/container/container-menu.tsx @@ -1,12 +1,12 @@ import { ActionIcon, Menu } from "@mantine/core"; -import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; +import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmMenuItem } from "@homarr/ui"; import type { ContainerSectionItem } from "~/app/[locale]/boards/_types"; -import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item"; import { useOpenItemMoveModal } from "../../items/item-move-modal"; import { useSectionContext } from "../section-context"; import { useContainerActions } from "./container-actions"; @@ -59,11 +59,14 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem {tItem("action.moveResize")} </Menu.Item> <Menu.Divider /> - <BoardRemoveConfirmationMenuItem - label={tContainer("action.remove")} - confirmationLabel={tContainer("remove.message")} + <InlineConfirmMenuItem + color="red" + confirmLabel={tContainer("remove.message")} + leftSection={<IconTrash size={16} />} onConfirm={() => removeContainer({ id: section.id })} - /> + > + {tContainer("action.remove")} + </InlineConfirmMenuItem> </Menu.Dropdown> </Menu> ); diff --git a/apps/nextjs/src/components/workshop/workshop-report-form.tsx b/apps/nextjs/src/components/workshop/workshop-report-form.tsx new file mode 100644 index 000000000..e770d0b7c --- /dev/null +++ b/apps/nextjs/src/components/workshop/workshop-report-form.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { Alert, Box, Button, CloseButton, Collapse, Group, Paper, Select, Stack, Text, Textarea } from "@mantine/core"; + +import { showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import type { WorkshopBackend } from "@homarr/workshop/backend"; +import { useWorkshopReportMutation } from "@homarr/workshop/backend"; +import type { WorkshopReport } from "@homarr/workshop/schema"; + +const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const; + +interface WorkshopReportFormProps { + client: WorkshopBackend; + submissionId: string; + opened: boolean; + onClose(): void; +} + +export function WorkshopReportForm({ client, submissionId, opened, onClose }: WorkshopReportFormProps) { + const t = useI18n("workshop"); + const tCommon = useI18n("common"); + const [category, setCategory] = useState<WorkshopReport["category"]>("other"); + const [explanation, setExplanation] = useState(""); + const report = useWorkshopReportMutation(client); + + const close = () => { + setCategory("other"); + setExplanation(""); + report.reset(); + onClose(); + }; + + const submit = () => + report.mutate( + { submission: submissionId, category, explanation }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("reportSent"), + message: t("reportSentDescription"), + }); + close(); + }, + }, + ); + + return ( + <Collapse id="workshop-report-form" expanded={opened}> + <Paper p="md"> + <form + onSubmit={(event) => { + event.preventDefault(); + submit(); + }} + > + <Stack> + <Group justify="space-between" align="flex-start" wrap="nowrap"> + <Box> + <Text fw={600}>{t("reportTitle")}</Text> + <Text size="sm" c="dimmed"> + {t("reportExplanation")} + </Text> + </Box> + <CloseButton aria-label={tCommon("action.close")} onClick={close} /> + </Group> + <Select + label={t("reportReason")} + value={category} + onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")} + data={categories.map((value) => ({ + value, + label: t(`reportCategory.${value}`), + }))} + allowDeselect={false} + /> + <Textarea + label={t("reportExplanation")} + minRows={4} + autosize + value={explanation} + onChange={(event) => setExplanation(event.currentTarget.value)} + /> + {report.error && <Alert color="red">{report.error.message}</Alert>} + <Group justify="flex-end" gap="sm"> + <Button variant="default" onClick={close}> + {tCommon("action.cancel")} + </Button> + <Button type="submit" color="red" loading={report.isPending} disabled={explanation.trim().length < 3}> + {t("reportSend")} + </Button> + </Group> + </Stack> + </form> + </Paper> + </Collapse> + ); +} diff --git a/apps/nextjs/src/components/workshop/workshop-report-modal.tsx b/apps/nextjs/src/components/workshop/workshop-report-modal.tsx deleted file mode 100644 index 89d447571..000000000 --- a/apps/nextjs/src/components/workshop/workshop-report-modal.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core"; - -import { showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import type { WorkshopBackend } from "@homarr/workshop/backend"; -import { useWorkshopReportMutation } from "@homarr/workshop/backend"; -import type { WorkshopReport } from "@homarr/workshop/schema"; - -const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const; - -interface WorkshopReportModalProps { - client: WorkshopBackend; - submissionId: string; - opened: boolean; - onClose(): void; -} - -export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) { - const t = useI18n("workshop"); - const tCommon = useI18n("common"); - const [category, setCategory] = useState<WorkshopReport["category"]>("other"); - const [explanation, setExplanation] = useState(""); - const report = useWorkshopReportMutation(client); - - const close = () => { - setExplanation(""); - report.reset(); - onClose(); - }; - - const submit = () => - report.mutate( - { submission: submissionId, category, explanation }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("reportSent"), - message: t("reportSentDescription"), - }); - close(); - }, - }, - ); - - return ( - <Modal opened={opened} onClose={close} title={t("reportTitle")} centered radius="md"> - <Stack> - <Select - label={t("reportReason")} - value={category} - onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")} - data={categories.map((value) => ({ - value, - label: t(`reportCategory.${value}`), - }))} - allowDeselect={false} - /> - <Textarea - label={t("reportExplanation")} - minRows={4} - autosize - value={explanation} - onChange={(event) => setExplanation(event.currentTarget.value)} - /> - {report.error && <Alert color="red">{report.error.message}</Alert>} - <Group justify="flex-end" gap="sm"> - <Button variant="default" onClick={close}> - {tCommon("action.cancel")} - </Button> - <Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}> - {t("reportSend")} - </Button> - </Group> - </Stack> - </Modal> - ); -} diff --git a/package.json b/package.json index 347e76aa0..477724057 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lint": "turbo lint --continue", "lint:fix": "turbo lint --continue -- --fix", "lint:ws": "pnpm dlx sherif@latest", + "qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh", "release": "semantic-release", "scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts", "scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts", diff --git a/packages/modals-collection/src/boards/add-board-modal.tsx b/packages/forms-collection/src/board-create/board-create-form.tsx similarity index 61% rename from packages/modals-collection/src/boards/add-board-modal.tsx rename to packages/forms-collection/src/board-create/board-create-form.tsx index cf2a4848a..e96a13597 100644 --- a/packages/modals-collection/src/boards/add-board-modal.tsx +++ b/packages/forms-collection/src/board-create/board-create-form.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useRouter } from "next/navigation"; import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; @@ -6,12 +8,16 @@ import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board"; -export const AddBoardModal = createModal(({ actions }) => { +interface BoardCreateFormProps { + onCancel?: () => void; +} + +export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => { const tBoard = useI18n("board"); const tCommon = useI18n("common"); const router = useRouter(); @@ -38,7 +44,6 @@ export const AddBoardModal = createModal(({ actions }) => { if (!boardNameStatus.canSubmit) return; mutate(values, { onSuccess: (result) => { - actions.closeModal(); showSuccessNotification({ title: tBoard("action.create.notification.success.title"), message: tBoard("action.create.notification.success.message", { name: values.name }), @@ -56,24 +61,17 @@ export const AddBoardModal = createModal(({ actions }) => { }); })} > - <Stack> + <Stack maw={720}> <TextInput label={tCommon("field.name")} - data-autofocus + withAsterisk + autoFocus {...form.getInputProps("name")} - description={ - boardNameStatus.description ? ( - <span> - <Group c={boardNameStatus.description.color} gap="xs" align="center"> - {boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null} - <span>{boardNameStatus.description.label}</span> - </Group> - </span> - ) : null - } + description={<BoardNameAvailability status={boardNameStatus} />} /> <InputWrapper label={tBoard("field.columnCount.label")} {...form.getInputProps("columnCount")}> <Slider + aria-label={tBoard("field.columnCount.label")} min={boardColumnCountSchema.minValue ?? undefined} max={boardColumnCountSchema.maxValue ?? undefined} step={1} @@ -87,50 +85,71 @@ export const AddBoardModal = createModal(({ actions }) => { {...form.getInputProps("isPublic")} /> - <Group justify="right"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={isPending}> + <Group justify="end" wrap="wrap"> + {onCancel && ( + <Button variant="default" onClick={onCancel}> + {tCommon("action.cancel")} + </Button> + )} + <Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}> {tCommon("action.create")} </Button> </Group> </Stack> </form> ); -}).withOptions({ - defaultTitle: (t) => t("management.page.board.action.new.label"), -}); +}; export const useBoardNameStatus = (name: string) => { const tBoard = useI18n("board"); const tCommon = useI18n("common"); const [debouncedName] = useDebouncedValue(name, 250); + const isValidName = boardNameSchema.safeParse(name).success; + const isDebouncing = name !== debouncedName; const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, { enabled: boardNameSchema.safeParse(debouncedName).success, }); + let description: { label: string; icon?: TablerIcon; color?: string } | undefined; + + if (debouncedName.trim() !== "") { + if (isDebouncing || isLoading) { + description = { + label: tBoard("action.create.availability.checking"), + }; + } else if (boardExists !== undefined) { + if (boardExists) { + description = { + icon: IconAlertTriangle, + label: tCommon("zod.errors.custom.boardAlreadyExists"), + color: "red", + }; + } else { + description = { + icon: IconCircleCheck, + label: tBoard("action.create.availability.available", { name: debouncedName }), + color: "green", + }; + } + } + } return { - canSubmit: !boardExists && !isLoading, - description: - debouncedName.trim() === "" - ? undefined - : isLoading - ? { - label: tBoard("action.create.availability.checking"), - } - : boardExists === undefined - ? undefined - : boardExists - ? { - icon: IconAlertTriangle, - label: tCommon("zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists - color: "red", - } - : { - icon: IconCircleCheck, - label: tBoard("action.create.availability.available", { name: debouncedName }), - color: "green", - }, + canSubmit: isValidName && !isDebouncing && boardExists === false && !isLoading, + description, }; }; + +export type BoardNameStatus = ReturnType<typeof useBoardNameStatus>; + +export const BoardNameAvailability = ({ status }: { status: BoardNameStatus }) => { + if (!status.description) return null; + + const Icon = status.description.icon; + + return ( + <Group component="output" c={status.description.color} gap="xs" align="center" aria-live="polite"> + {Icon && <Icon size={16} />} + <span>{status.description.label}</span> + </Group> + ); +}; diff --git a/packages/forms-collection/src/index.tsx b/packages/forms-collection/src/index.tsx index 0dfaf90ca..6253a1d2c 100644 --- a/packages/forms-collection/src/index.tsx +++ b/packages/forms-collection/src/index.tsx @@ -1,6 +1,8 @@ export * from "./new-app/_app-new-form"; export * from "./new-app/_form"; +export * from "./board-create/board-create-form"; + export * from "./icon-picker/icon-picker"; export * from "./upload-media/upload-media"; diff --git a/packages/modals-collection/src/boards/index.ts b/packages/modals-collection/src/boards/index.ts deleted file mode 100644 index ff591c8ac..000000000 --- a/packages/modals-collection/src/boards/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AddBoardModal } from "./add-board-modal"; -export { DuplicateBoardModal } from "./duplicate-board-modal"; diff --git a/packages/modals-collection/src/certificates/add-certificate-modal.tsx b/packages/modals-collection/src/certificates/add-certificate-modal.tsx deleted file mode 100644 index b414d0c6a..000000000 --- a/packages/modals-collection/src/certificates/add-certificate-modal.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Button, FileInput, Group, Stack } from "@mantine/core"; -import { IconCertificate } from "@tabler/icons-react"; -import { z } from "zod/v4"; - -import { clientApi } from "@homarr/api/client"; -import type { MaybePromise } from "@homarr/common/types"; -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { checkCertificateFile } from "@homarr/validation/certificates"; - -interface InnerProps { - onSuccess?: () => MaybePromise<void>; -} - -export const AddCertificateModal = createModal<InnerProps>(({ actions, innerProps }) => { - const t = useI18n(); - const form = useZodForm( - z.object({ - file: z.file().check(checkCertificateFile), - }), - { - initialValues: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - file: null!, - }, - }, - ); - const { mutateAsync } = clientApi.certificates.addCertificate.useMutation({ - async onSuccess() { - await innerProps.onSuccess?.(); - }, - }); - - return ( - <form - onSubmit={form.onSubmit(async (values) => { - const formData = new FormData(); - formData.set("file", values.file); - await mutateAsync(formData, { - onSuccess() { - showSuccessNotification({ - title: t("certificate.action.create.notification.success.title"), - message: t("certificate.action.create.notification.success.message"), - }); - actions.closeModal(); - }, - onError() { - showErrorNotification({ - title: t("certificate.action.create.notification.error.title"), - message: t("certificate.action.create.notification.error.message"), - }); - }, - }); - })} - > - <Stack> - <FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} /> - <Group justify="end"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {t("common.action.cancel")} - </Button> - <Button type="submit" loading={form.submitting}> - {t("common.action.add")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle(t) { - return t("certificate.action.create.label"); - }, -}); diff --git a/packages/modals-collection/src/certificates/index.ts b/packages/modals-collection/src/certificates/index.ts deleted file mode 100644 index 0bdf423ea..000000000 --- a/packages/modals-collection/src/certificates/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./add-certificate-modal"; diff --git a/packages/modals-collection/src/groups/add-group-modal.tsx b/packages/modals-collection/src/groups/add-group-modal.tsx deleted file mode 100644 index 817ce0072..000000000 --- a/packages/modals-collection/src/groups/add-group-modal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Button, Group, Stack, TextInput } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { groupCreateSchema } from "@homarr/validation/group"; - -export const AddGroupModal = createModal<void>(({ actions }) => { - const tGroup = useI18n("group"); - const tCommon = useI18n("common"); - const { mutate, isPending } = clientApi.group.createGroup.useMutation(); - const form = useZodForm(groupCreateSchema, { - initialValues: { - name: "", - }, - }); - - return ( - <form - onSubmit={form.onSubmit((values) => { - mutate(values, { - onSuccess() { - actions.closeModal(); - void revalidatePathActionAsync("/manage/users/groups"); - showSuccessNotification({ - title: tCommon("notification.create.success"), - message: tGroup("action.create.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: tCommon("notification.create.error"), - message: tGroup("action.create.notification.error.message"), - }); - }, - }); - })} - > - <Stack> - <TextInput label={tCommon("field.name")} data-autofocus {...form.getInputProps("name")} /> - <Group justify="right"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {tCommon("action.cancel")} - </Button> - <Button loading={isPending} type="submit"> - {tCommon("action.create")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("group.action.create.label"), -}); diff --git a/packages/modals-collection/src/groups/index.ts b/packages/modals-collection/src/groups/index.ts deleted file mode 100644 index 428bea2ec..000000000 --- a/packages/modals-collection/src/groups/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AddGroupModal } from "./add-group-modal"; diff --git a/packages/modals-collection/src/index.ts b/packages/modals-collection/src/index.ts index 02f9f3bee..6403ccd40 100644 --- a/packages/modals-collection/src/index.ts +++ b/packages/modals-collection/src/index.ts @@ -1,7 +1,3 @@ -export * from "./boards"; -export * from "./invites"; -export * from "./groups"; export * from "./search-engines"; export * from "./docker"; export * from "./apps"; -export * from "./certificates"; diff --git a/packages/modals-collection/src/invites/index.ts b/packages/modals-collection/src/invites/index.ts deleted file mode 100644 index f55df7d2b..000000000 --- a/packages/modals-collection/src/invites/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { InviteCopyModal } from "./invite-copy-modal"; -export { InviteCreateModal } from "./invite-create-modal"; diff --git a/packages/modals-collection/src/invites/invite-copy-modal.tsx b/packages/modals-collection/src/invites/invite-copy-modal.tsx deleted file mode 100644 index 199c0d74d..000000000 --- a/packages/modals-collection/src/invites/invite-copy-modal.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { usePathname } from "next/navigation"; -import { Button, CopyButton, Mark, Stack, Text } from "@mantine/core"; - -import type { RouterOutputs } from "@homarr/api"; -import { invariantTechnicalLabels } from "@homarr/definitions"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { Link } from "@homarr/ui"; - -export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite"]>(({ actions, innerProps }) => { - const t = useI18n("management.page.user.invite"); - const inviteUrl = useInviteUrl(innerProps); - - return ( - <Stack> - <Text> - {t.rich("action.copy.description", { - b: (children) => <b>{children}</b>, - })} - </Text> - <Link href={createPath(innerProps)}>{t("action.copy.link")}</Link> - <Stack gap="xs"> - <Text fw="bold">{invariantTechnicalLabels.id}:</Text> - <Mark style={{ borderRadius: 4 }} color="gray" px={5}> - {innerProps.id} - </Mark> - - <Text fw="bold">{t("field.token.label")}:</Text> - <Mark style={{ borderRadius: 4 }} color="gray" px={5}> - {innerProps.token} - </Mark> - </Stack> - <CopyButton value={inviteUrl}> - {({ copy }) => ( - <Button - onClick={() => { - copy(); - actions.closeModal(); - }} - variant="default" - fullWidth - > - {t("action.copy.button")} - </Button> - )} - </CopyButton> - </Stack> - ); -}).withOptions({ - defaultTitle(t) { - return t("management.page.user.invite.action.copy.title"); - }, -}); - -const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => `/auth/invite/${id}?token=${token}`; - -const useInviteUrl = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => { - const pathname = usePathname(); - - return window.location.href.replace(pathname, createPath({ id, token })); -}; diff --git a/packages/modals-collection/src/invites/invite-create-modal.tsx b/packages/modals-collection/src/invites/invite-create-modal.tsx deleted file mode 100644 index 23958f017..000000000 --- a/packages/modals-collection/src/invites/invite-create-modal.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Button, Group, Stack, Text } from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; - -import { clientApi } from "@homarr/api/client"; -import { useForm } from "@homarr/form"; -import { createModal, useModalAction } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -import { InviteCopyModal } from "./invite-copy-modal"; - -dayjs.extend(relativeTime); - -interface FormType { - expirationDate: string; -} - -export const InviteCreateModal = createModal<void>(({ actions }) => { - const tInvite = useI18n("management.page.user.invite"); - const t = useI18n(); - const { openModal } = useModalAction(InviteCopyModal); - - const utils = clientApi.useUtils(); - const { mutate, isPending } = clientApi.invite.createInvite.useMutation(); - const minDate = dayjs().add(1, "hour").toDate(); - const maxDate = dayjs().add(6, "months").toDate(); - - const form = useForm<FormType>({ - initialValues: { - expirationDate: dayjs().add(4, "hours").toDate().toISOString(), - }, - }); - - const handleSubmit = (values: FormType) => { - mutate( - { - expirationDate: new Date(values.expirationDate), - }, - { - onSuccess: (result) => { - void utils.invite.getAll.invalidate(); - actions.closeModal(); - openModal(result); - }, - }, - ); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <Text>{tInvite("action.new.description")}</Text> - - <DateTimePicker - popoverProps={{ withinPortal: true }} - minDate={minDate} - maxDate={maxDate} - withAsterisk - valueFormat="DD MMM YYYY HH:mm" - label={tInvite("field.expirationDate.label")} - variant="filled" - {...form.getInputProps("expirationDate")} - /> - - <Group justify="end"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {t("common.action.cancel")} - </Button> - <Button type="submit" loading={isPending}> - {t("common.action.create")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle(t) { - return t("management.page.user.invite.action.new.title"); - }, -}); diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index 58622ef47..eb17047d7 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -12,8 +12,6 @@ import { import { useSession } from "@homarr/auth/client"; import type { GroupPermissionKey } from "@homarr/definitions"; -import { useModalAction } from "@homarr/modals"; -import { AddBoardModal, AddGroupModal, InviteCreateModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; @@ -78,16 +76,7 @@ export const globalCommandGroup = createGroup<Command>({ commandKey: "newBoard", icon: IconCategoryPlus, name: tOption("newBoard.label"), - useInteraction() { - const { openModal } = useModalAction(AddBoardModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, + useInteraction: interaction.link(() => ({ href: "/manage/boards?create=true" })), hidden: !session?.user.permissions.includes("board-create"), }, { @@ -115,32 +104,14 @@ export const globalCommandGroup = createGroup<Command>({ commandKey: "newInvite", icon: IconMailForward, name: tOption("newInvite.label"), - useInteraction() { - const { openModal } = useModalAction(InviteCreateModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, + useInteraction: interaction.link(() => ({ href: "/manage/users/invites?create=true" })), hidden: !session?.user.permissions.includes("admin"), }, { commandKey: "newGroup", icon: IconUsersGroup, name: tOption("newGroup.label"), - useInteraction() { - const { openModal } = useModalAction(AddGroupModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, + useInteraction: interaction.link(() => ({ href: "/manage/users/groups?create=true" })), hidden: !session?.user.permissions.includes("admin"), }, ]; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a9dfff623..8ebdd3511 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1783,6 +1783,7 @@ "deleteNamed": "Delete {name}", "clearSearch": "Clear search", "copy": "Copy", + "copied": "Copied", "refresh": "Refresh" }, "here": "here", diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 776f12837..99fc5378e 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -1,5 +1,6 @@ export * from "./count-badge"; export { OverflowBadge } from "./overflow-badge"; +export { InlineConfirmActionIcon, InlineConfirmButton, InlineConfirmMenuItem } from "./inline-confirm-action"; export { SearchInput } from "./search-input"; export * from "./select-with-description"; export * from "./select-with-description-and-badge"; diff --git a/packages/ui/src/components/inline-confirm-action.tsx b/packages/ui/src/components/inline-confirm-action.tsx new file mode 100644 index 000000000..ad355e4ad --- /dev/null +++ b/packages/ui/src/components/inline-confirm-action.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { ActionIcon, Button, Menu } from "@mantine/core"; +import type { ActionIconProps, ButtonProps, ElementProps, MenuItemProps } from "@mantine/core"; +import { IconCheck } from "@tabler/icons-react"; + +const confirmationTimeout = 4_000; + +const visuallyHidden = { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute" as const, + whiteSpace: "nowrap" as const, + width: 1, +}; + +interface InlineConfirmActionOptions { + confirmLabel: ReactNode; + onConfirm: () => void | Promise<void>; + disabled?: boolean; + pending?: boolean; + timeout?: number; +} + +const useInlineConfirmAction = ({ + confirmLabel, + onConfirm, + disabled = false, + pending = false, + timeout = confirmationTimeout, +}: InlineConfirmActionOptions) => { + const [confirming, setConfirming] = useState(false); + const [submitting, setSubmitting] = useState(false); + const actionRef = useRef<HTMLButtonElement>(null); + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + const reset = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = null; + setConfirming(false); + }, []); + + useEffect(() => { + if (!confirming) return; + + const handlePointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && !actionRef.current?.contains(event.target)) reset(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") reset(); + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown, true); + timeoutRef.current = setTimeout(reset, timeout); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown, true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = null; + }; + }, [confirming, reset, timeout]); + + useEffect(() => { + if (disabled || pending) reset(); + }, [disabled, pending, reset]); + + const handleClick = useCallback(async () => { + if (disabled || pending || submitting) return; + if (!confirming) { + setConfirming(true); + return; + } + + setSubmitting(true); + try { + await onConfirm(); + } finally { + setSubmitting(false); + reset(); + } + }, [confirming, disabled, onConfirm, pending, reset, submitting]); + + const handleBlur = useCallback(() => reset(), [reset]); + + return { + actionRef, + confirming, + confirmLabel, + handleBlur, + handleClick, + reset, + submitting, + }; +}; + +type InlineConfirmButtonProps = Omit<ButtonProps, "children" | "disabled" | "loading" | "onClick"> & + ElementProps<"button", "color" | "disabled" | "onClick"> & + InlineConfirmActionOptions & { + children: ReactNode; + loading?: boolean; + }; + +export const InlineConfirmButton = ({ + children, + confirmLabel, + onConfirm, + disabled, + pending, + loading, + timeout, + variant, + ...props +}: InlineConfirmButtonProps) => { + const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending: pending || loading, timeout }); + const label = action.confirming ? confirmLabel : children; + let nextVariant = variant; + if (action.confirming) nextVariant = "filled"; + + return ( + <> + <Button + {...props} + ref={action.actionRef} + variant={nextVariant} + disabled={disabled || pending || action.submitting} + loading={loading || action.submitting} + onClick={() => void action.handleClick().catch(() => undefined)} + onBlur={action.handleBlur} + aria-label={typeof label === "string" ? label : undefined} + data-confirming={action.confirming || undefined} + > + {label} + </Button> + <span aria-live="polite" style={visuallyHidden}> + {action.confirming ? confirmLabel : null} + </span> + </> + ); +}; + +type InlineConfirmActionIconProps = Omit<ActionIconProps, "children" | "disabled" | "loading" | "onClick"> & + InlineConfirmActionOptions & { + "aria-label"?: string; + children: ReactNode; + confirmationAriaLabel?: string; + confirmationChildren?: ReactNode; + loading?: boolean; + title?: string; + }; + +export const InlineConfirmActionIcon = ({ + children, + confirmLabel, + confirmationAriaLabel, + confirmationChildren, + onConfirm, + disabled, + pending, + loading, + timeout, + variant, + "aria-label": ariaLabel, + ...props +}: InlineConfirmActionIconProps) => { + const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending: pending || loading, timeout }); + const nextAriaLabel = action.confirming + ? (confirmationAriaLabel ?? (typeof confirmLabel === "string" ? confirmLabel : ariaLabel)) + : ariaLabel; + let nextVariant = variant; + if (action.confirming) nextVariant = "filled"; + let nextChildren = children; + if (action.confirming) nextChildren = confirmationChildren ?? <IconCheck size="1rem" />; + + return ( + <> + <ActionIcon + {...props} + ref={action.actionRef} + variant={nextVariant} + disabled={disabled || pending || action.submitting} + loading={loading || action.submitting} + onClick={() => void action.handleClick().catch(() => undefined)} + onBlur={action.handleBlur} + aria-label={nextAriaLabel} + data-confirming={action.confirming || undefined} + > + {nextChildren} + </ActionIcon> + <span aria-live="polite" style={visuallyHidden}> + {action.confirming ? confirmLabel : null} + </span> + </> + ); +}; + +type InlineConfirmMenuItemProps = Omit<MenuItemProps, "children" | "closeMenuOnClick" | "disabled" | "onClick"> & + InlineConfirmActionOptions & { + children: ReactNode; + }; + +export const InlineConfirmMenuItem = ({ + children, + confirmLabel, + onConfirm, + disabled, + pending, + timeout, + ...props +}: InlineConfirmMenuItemProps) => { + const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending, timeout }); + const label = action.confirming ? confirmLabel : children; + + return ( + <> + <Menu.Item + {...props} + ref={action.actionRef} + disabled={disabled || pending || action.submitting} + closeMenuOnClick={action.confirming} + onClick={() => void action.handleClick().catch(() => undefined)} + aria-label={typeof label === "string" ? label : undefined} + data-confirming={action.confirming || undefined} + > + {label} + </Menu.Item> + <span aria-live="polite" style={visuallyHidden}> + {action.confirming ? confirmLabel : null} + </span> + </> + ); +}; diff --git a/packages/widgets/src/_inputs/form.ts b/packages/widgets/src/_inputs/form.ts index 207eee02d..a0199b55f 100644 --- a/packages/widgets/src/_inputs/form.ts +++ b/packages/widgets/src/_inputs/form.ts @@ -4,5 +4,4 @@ import { createFormContext } from "@homarr/form"; import type { WidgetEditModalState } from "../modals/widget-edit-modal"; -export const [FormProvider, useFormContext, useForm] = - createFormContext<Omit<WidgetEditModalState, "advancedOptions">>(); +export const [FormProvider, useFormContext, useForm] = createFormContext<WidgetEditModalState>(); diff --git a/packages/widgets/src/_inputs/widget-location-input.spec.tsx b/packages/widgets/src/_inputs/widget-location-input.spec.tsx index 5595f8884..9eeb9b194 100644 --- a/packages/widgets/src/_inputs/widget-location-input.spec.tsx +++ b/packages/widgets/src/_inputs/widget-location-input.spec.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom +/* eslint-disable unicorn/consistent-function-scoping -- Component mocks are intentionally colocated with their module factory. */ + import { act } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; @@ -30,6 +32,7 @@ vi.mock("@mantine/core", () => { return { Button: Container, + Collapse: Container, Fieldset: Container, Group: Container, NumberInput: Input, @@ -39,11 +42,6 @@ vi.mock("@mantine/core", () => { }; }); -vi.mock("@homarr/modals", () => ({ - createModal: () => ({ withOptions: () => null }), - useModalAction: () => ({ openModal: vi.fn() }), -})); - vi.mock("@homarr/translation/client", () => ({ useCurrentIntlLocale: () => "en", useI18n: () => (key: string) => key, diff --git a/packages/widgets/src/_inputs/widget-location-input.tsx b/packages/widgets/src/_inputs/widget-location-input.tsx index 9f1a26b1a..eb874b427 100644 --- a/packages/widgets/src/_inputs/widget-location-input.tsx +++ b/packages/widgets/src/_inputs/widget-location-input.tsx @@ -1,17 +1,18 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ActionIcon, Alert, Anchor, Button, + Collapse, Fieldset, Group, Loader, NumberInput, + Paper, Stack, - Table, Text, TextInput, Tooltip, @@ -20,7 +21,6 @@ import { IconClick, IconListSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { createModal, useModalAction } from "@homarr/modals"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { formatLocalizedCompactNumber } from "../common/locale"; @@ -33,7 +33,7 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp const t = useWidgetInputTranslation(kind, property); const tLocation = useI18n("widget.common.location"); const form = useFormContext(); - const { openModal } = useModalAction(LocationSearchModal); + const [searchQuery, setSearchQuery] = useState<string | null>(null); const fieldPath = `options.${property}`; const inputProps = form.getInputProps(fieldPath); const value = isOptionLocation(inputProps.value) ? inputProps.value : options.defaultValue; @@ -58,26 +58,23 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp handleChange(location); form.clearFieldError(`options.${property}.latitude`); form.clearFieldError(`options.${property}.longitude`); + setSearchQuery(null); }, [form, handleChange, property], ); const onSearch = useCallback(() => { if (!selectionEnabled) return; + setSearchQuery(value.name); + }, [selectionEnabled, value.name]); - openModal({ - query: value.name, - onLocationSelect, - }); - }, [selectionEnabled, value.name, onLocationSelect, openModal]); - - form.watch(`options.${property}.latitude`, ({ value }) => { - if (typeof value !== "number") return; + form.watch(`options.${property}.latitude`, ({ value: nextValue }) => { + if (typeof nextValue !== "number") return; form.setFieldValue(`options.${property}.name`, unknownLocation); }); - form.watch(`options.${property}.longitude`, ({ value }) => { - if (typeof value !== "number") return; + form.watch(`options.${property}.longitude`, ({ value: nextValue }) => { + if (typeof nextValue !== "number") return; form.setFieldValue(`options.${property}.name`, unknownLocation); }); @@ -121,6 +118,16 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp value={longitudeInputProps.value ?? value.longitude} /> </Group> + + <Collapse expanded={searchQuery !== null}> + {searchQuery && ( + <LocationSearchResults + query={searchQuery} + onLocationSelect={onLocationSelect} + onClose={() => setSearchQuery(null)} + /> + )} + </Collapse> </Stack> </Fieldset> ); @@ -143,124 +150,123 @@ type LocationOnChange = ( }, ) => void; -interface LocationSearchInnerProps { +interface LocationSearchResultsProps { query: string; onLocationSelect: (location: OptionLocation) => void; + onClose: () => void; } -const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, innerProps }) => { - const t = useI18n("widget.common.location.table"); +const LocationSearchResults = ({ query, onLocationSelect, onClose }: LocationSearchResultsProps) => { + const tLocation = useI18n("widget.common.location"); const tCommon = useI18n("common"); - const { data, isPending, error } = clientApi.location.searchCity.useQuery({ - query: innerProps.query, - }); + const { data, isPending, error, refetch } = clientApi.location.searchCity.useQuery({ query }); if (error) { return ( - <Alert title={tCommon("error")} color="red"> - {tCommon("action.tryAgain")} - </Alert> + <Stack gap="xs"> + <Alert title={tCommon("error")} color="red"> + <Button variant="light" color="red" size="compact-sm" onClick={() => void refetch()}> + {tCommon("action.tryAgain")} + </Button> + </Alert> + <Group justify="right"> + <Button variant="default" size="compact-sm" onClick={onClose}> + {tCommon("action.close")} + </Button> + </Group> + </Stack> ); } return ( - <Stack> - <Table striped> - <Table.Thead> - <Table.Tr> - <Table.Th style={{ width: "70%" }}>{t("header.city")}</Table.Th> - <Table.Th style={{ width: "50%" }}>{t("header.country")}</Table.Th> - <Table.Th>{t("header.coordinates")}</Table.Th> - <Table.Th>{t("header.population")}</Table.Th> - <Table.Th style={{ width: 40 }} /> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {isPending && ( - <Table.Tr> - <Table.Td colSpan={5}> - <Group justify="center"> - <Loader /> - </Group> - </Table.Td> - </Table.Tr> - )} - {data?.results.map((city) => ( - <LocationSelectTableRow - key={city.id} - city={city} - onLocationSelect={innerProps.onLocationSelect} - closeModal={actions.closeModal} - /> - ))} - </Table.Tbody> - </Table> + <Stack component="section" aria-label={tLocation("search")} gap="xs"> + <Stack gap={4} mah={320} style={{ overflow: "hidden auto" }}> + {isPending && ( + <Group justify="center" py="md"> + <Loader /> + </Group> + )} + {!isPending && data?.results.length === 0 && ( + <Text ta="center" c="dimmed" py="md"> + {tCommon("noResults")} + </Text> + )} + {data?.results.map((city) => ( + <LocationSelectResult key={city.id} city={city} onLocationSelect={onLocationSelect} /> + ))} + </Stack> <Group justify="right"> - <Button variant="light" onClick={actions.closeModal}> - {tCommon("action.cancel")} + <Button variant="default" size="compact-sm" onClick={onClose}> + {tCommon("action.close")} </Button> </Group> </Stack> ); -}).withOptions({ - defaultTitle(t) { - return t("widget.common.location.search"); - }, - size: "xl", -}); +}; -interface LocationSearchTableRowProps { +interface LocationSelectResultProps { city: RouterOutputs["location"]["searchCity"]["results"][number]; onLocationSelect: (location: OptionLocation) => void; - closeModal: () => void; } -const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => { +const LocationSelectResult = ({ city, onLocationSelect }: LocationSelectResultProps) => { const t = useI18n("widget.common.location.table"); const locale = useCurrentIntlLocale(); + let populationLabel = t("population.fallback"); + if (city.population) { + populationLabel = `${t("header.population")}: ${formatLocalizedCompactNumber(city.population, locale)}`; + } const onSelect = useCallback(() => { onLocationSelect({ name: city.name, latitude: city.latitude, longitude: city.longitude, }); - closeModal(); - }, [city, onLocationSelect, closeModal]); + }, [city, onLocationSelect]); return ( - <Table.Tr> - <Table.Td> - <Text style={{ whiteSpace: "nowrap" }}>{city.name}</Text> - </Table.Td> - <Table.Td> - <Text style={{ whiteSpace: "nowrap" }}>{city.country}</Text> - </Table.Td> - <Table.Td> - <Anchor target="_blank" href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}> - <Text style={{ whiteSpace: "nowrap" }}> - {city.latitude}, {city.longitude} + <Paper withBorder p="xs"> + <Group justify="space-between" align="flex-start" wrap="nowrap"> + <Stack gap={2} miw={0}> + <Text fw={500} style={{ overflowWrap: "anywhere" }}> + {city.name} </Text> - </Anchor> - </Table.Td> - <Table.Td> - {city.population ? ( - <Text style={{ whiteSpace: "nowrap" }}>{formatLocalizedCompactNumber(city.population, locale)}</Text> - ) : ( - <Text c="gray"> {t("population.fallback")}</Text> - )} - </Table.Td> - <Table.Td> + <Text size="xs" c="dimmed" style={{ overflowWrap: "anywhere" }}> + {city.country} + </Text> + <Group gap="xs" wrap="wrap"> + <Anchor + size="xs" + target="_blank" + href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`} + > + {city.latitude}, {city.longitude} + </Anchor> + <Text size="xs" c="dimmed"> + {populationLabel} + </Text> + </Group> + </Stack> <Tooltip label={t("action.select", { city: city.name, countryCode: city.country_code ?? "??", })} > - <ActionIcon color="red" variant="subtle" onClick={onSelect}> + <ActionIcon + flex="0 0 auto" + color="red" + variant="subtle" + onClick={onSelect} + aria-label={t("action.select", { + city: city.name, + countryCode: city.country_code ?? "??", + })} + > <IconClick size="var(--mantine-font-size-md)" /> </ActionIcon> </Tooltip> - </Table.Td> - </Table.Tr> + </Group> + </Paper> ); }; diff --git a/packages/widgets/src/dns-hole/controls/TimerModal.tsx b/packages/widgets/src/dns-hole/controls/TimerModal.tsx deleted file mode 100644 index 02de22c0c..000000000 --- a/packages/widgets/src/dns-hole/controls/TimerModal.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useRef, useState } from "react"; -import type { NumberInputHandlers } from "@mantine/core"; -import { ActionIcon, Button, Flex, Group, Modal, NumberInput, rem, Stack, Text } from "@mantine/core"; -import { IconClockPause } from "@tabler/icons-react"; - -import { useI18n } from "@homarr/translation/client"; - -interface TimerModalProps { - opened: boolean; - close: () => void; - selectedIntegrationIds: string[]; - disableDns: (data: { duration: number; integrationId: string }) => void; -} - -const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: TimerModalProps) => { - const t = useI18n("widget.dnsHoleControls"); - const tCommon = useI18n("common"); - const [hours, setHours] = useState(0); - const [minutes, setMinutes] = useState(0); - const hoursHandlers = useRef<NumberInputHandlers>(null); - const minutesHandlers = useRef<NumberInputHandlers>(null); - - const handleSetTimer = () => { - const duration = hours * 3600 + minutes * 60; - selectedIntegrationIds.forEach((integrationId) => { - disableDns({ duration, integrationId }); - }); - setHours(0); - setMinutes(0); - close(); - }; - - return ( - <Modal - withinPortal - radius="lg" - shadow="sm" - size="sm" - opened={opened} - onClose={() => { - close(); - setHours(0); - setMinutes(0); - }} - title={t("controls.setTimer")} - > - <Flex direction="column" align="center" justify="center"> - <Stack align="flex-end"> - <Group> - <Text>{tCommon("information.hours")}</Text> - <ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}> - – - </ActionIcon> - <NumberInput - hideControls - value={hours} - onChange={(val) => setHours(Number(val))} - handlersRef={hoursHandlers} - max={999} - min={0} - step={1} - styles={{ input: { width: rem(54), textAlign: "center" } }} - /> - <ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}> - + - </ActionIcon> - </Group> - <Group> - <Text>{tCommon("information.minutes")}</Text> - <ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}> - – - </ActionIcon> - <NumberInput - hideControls - value={minutes} - onChange={(val) => setMinutes(Number(val))} - handlersRef={minutesHandlers} - max={59} - min={0} - step={1} - styles={{ input: { width: rem(54), textAlign: "center" } }} - /> - <ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}> - + - </ActionIcon> - </Group> - </Stack> - <Text ta="center" c="dimmed" my={5}> - {t("controls.unlimited")} - </Text> - <Button - variant="light" - color="red" - leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />} - h="2rem" - w="12rem" - onClick={handleSetTimer} - > - {t("controls.set")} - </Button> - </Flex> - </Modal> - ); -}; - -export default TimerModal; diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index 8ec849d2a..dec4e56aa 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -1,5 +1,8 @@ "use client"; +/* eslint-disable react/no-unstable-nested-components -- TimerPopover uses render props to wire controlled targets. */ +/* eslint-disable import/no-unassigned-import -- The shared widget stylesheet is loaded for its side effects. */ + import "../../widgets-common.css"; import { useState } from "react"; @@ -18,7 +21,6 @@ import { Tooltip, UnstyledButton, } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -38,7 +40,7 @@ import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../com import { WidgetQueryLoadingState } from "../../common/query-state-indicator"; import actionTargetClasses from "../../common/action-target.module.css"; import classes from "./component.module.css"; -import TimerModal from "./TimerModal"; +import TimerPopover from "./timer-popover"; const dnsLightStatus = (enabled: boolean | undefined) => `var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`; @@ -123,11 +125,8 @@ export default function DnsHoleControlsWidget({ const t = useI18n("widget.dnsHoleControls"); - // Timer modal setup - const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]); const [bulkPending, setBulkPending] = useState(false); const [bulkFailureCount, setBulkFailureCount] = useState(0); - const [opened, { close, open }] = useDisclosure(false); const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0; const actionsPending = bulkPending || isEnabling || isDisabling; @@ -177,26 +176,29 @@ export default function DnsHoleControlsWidget({ </Button> </Tooltip> - <Tooltip label={t("controls.setTimer")}> - <Button - aria-label={t("controls.setTimer")} - size="xs" - p={0} - className="dns-hole-controls-timer-all-button" - onClick={() => { - setSelectedIntegrationIds(integrationsSummaries.enabled); - open(); - }} - disabled={integrationsSummaries.enabled.length === 0 || actionsPending} - variant="light" - color="yellow" - bd={0} - radius={board.itemRadius} - flex={1} - > - <IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" /> - </Button> - </Tooltip> + <TimerPopover + target={(onClick) => ( + <Tooltip label={t("controls.setTimer")}> + <Button + aria-label={t("controls.setTimer")} + size="xs" + p={0} + className="dns-hole-controls-timer-all-button" + disabled={integrationsSummaries.enabled.length === 0 || actionsPending} + variant="light" + color="yellow" + bd={0} + radius={board.itemRadius} + flex={1} + onClick={onClick} + > + <IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" /> + </Button> + </Tooltip> + )} + selectedIntegrationIds={integrationsSummaries.enabled} + disableDns={(input) => void disableDns(input).catch(() => undefined)} + /> <Tooltip label={t("controls.disableAll")}> <Button @@ -230,9 +232,8 @@ export default function DnsHoleControlsWidget({ key={summary.integration.id} integrationsWithInteractions={integrationsWithInteractions} toggleDns={toggleDns} + disableDns={(input) => void disableDns(input).catch(() => undefined)} data={summary} - setSelectedIntegrationIds={setSelectedIntegrationIds} - open={open} t={t} hasIconColor={board.iconColor !== null} rootWidth={width} @@ -255,13 +256,6 @@ export default function DnsHoleControlsWidget({ </Text> </Tooltip> )} - - <TimerModal - opened={opened} - close={close} - selectedIntegrationIds={selectedIntegrationIds} - disableDns={(input) => void disableDns(input).catch(() => undefined)} - /> </Stack> ); } @@ -269,9 +263,8 @@ export default function DnsHoleControlsWidget({ interface ControlsCardProps { integrationsWithInteractions: string[]; toggleDns: (integrationId: string) => Promise<void>; + disableDns: (data: { duration: number; integrationId: string }) => void; data: AvailableDnsSummaryResult; - setSelectedIntegrationIds: (integrationId: string[]) => void; - open: () => void; t: ReturnType<typeof useI18n<"widget.dnsHoleControls">>; hasIconColor: boolean; rootWidth: number; @@ -282,9 +275,8 @@ interface ControlsCardProps { const ControlsCard: React.FC<ControlsCardProps> = ({ integrationsWithInteractions, toggleDns, + disableDns, data, - setSelectedIntegrationIds, - open, t, hasIconColor, rootWidth, @@ -382,21 +374,24 @@ const ControlsCard: React.FC<ControlsCardProps> = ({ <IconPlayerStop size="var(--mantine-font-size-xs)" /> </ActionIcon> )} - <ActionIcon - className={actionTargetClasses.root} - aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} - onClick={() => { - setSelectedIntegrationIds([data.integration.id]); - open(); - }} - size="sm" - color="yellow" - variant="light" - display={isInteractPermitted ? undefined : "none"} - disabled={!controlEnabled || !isEnabled} - > - <IconClockPause size="var(--mantine-font-size-xs)" /> - </ActionIcon> + <TimerPopover + target={(onClick) => ( + <ActionIcon + className={actionTargetClasses.root} + aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} + size="sm" + color="yellow" + variant="light" + display={isInteractPermitted ? undefined : "none"} + disabled={!controlEnabled || !isEnabled} + onClick={onClick} + > + <IconClockPause size="var(--mantine-font-size-xs)" /> + </ActionIcon> + )} + selectedIntegrationIds={[data.integration.id]} + disableDns={disableDns} + /> </Group> )} {layout === "md" && ( @@ -445,24 +440,27 @@ const ControlsCard: React.FC<ControlsCardProps> = ({ </Flex> </Flex> {layout === "md" && ( - <ActionIcon - aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} - className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)} - display={isInteractPermitted ? undefined : "none"} - disabled={!controlEnabled || !isEnabled} - color="yellow" - size={30} - radius={board.itemRadius} - bd={0} - ms={"auto"} - variant="subtle" - onClick={() => { - setSelectedIntegrationIds([data.integration.id]); - open(); - }} - > - <IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" /> - </ActionIcon> + <TimerPopover + target={(onClick) => ( + <ActionIcon + aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} + className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)} + display={isInteractPermitted ? undefined : "none"} + disabled={!controlEnabled || !isEnabled} + color="yellow" + size={30} + radius={board.itemRadius} + bd={0} + ms={"auto"} + variant="subtle" + onClick={onClick} + > + <IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" /> + </ActionIcon> + )} + selectedIntegrationIds={[data.integration.id]} + disableDns={disableDns} + /> )} </Flex> </Card> diff --git a/packages/widgets/src/dns-hole/controls/timer-popover.tsx b/packages/widgets/src/dns-hole/controls/timer-popover.tsx new file mode 100644 index 000000000..68ef981e0 --- /dev/null +++ b/packages/widgets/src/dns-hole/controls/timer-popover.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; +import type { NumberInputHandlers } from "@mantine/core"; +import { ActionIcon, Button, Flex, Group, NumberInput, Popover, rem, Stack, Text } from "@mantine/core"; +import { IconClockPause } from "@tabler/icons-react"; +import { useDisclosure } from "@mantine/hooks"; + +import { useI18n } from "@homarr/translation/client"; + +interface TimerPopoverProps { + target: (onClick: () => void) => ReactNode; + selectedIntegrationIds: string[]; + disableDns: (data: { duration: number; integrationId: string }) => void; +} + +const TimerPopover = ({ target, selectedIntegrationIds, disableDns }: TimerPopoverProps) => { + const t = useI18n("widget.dnsHoleControls"); + const tCommon = useI18n("common"); + const [opened, { close, open }] = useDisclosure(false); + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(0); + const hoursHandlers = useRef<NumberInputHandlers>(null); + const minutesHandlers = useRef<NumberInputHandlers>(null); + + const handleSetTimer = () => { + const duration = hours * 3600 + minutes * 60; + selectedIntegrationIds.forEach((integrationId) => { + disableDns({ duration, integrationId }); + }); + setHours(0); + setMinutes(0); + close(); + }; + + const reset = () => { + close(); + setHours(0); + setMinutes(0); + }; + + const handleToggle = () => { + if (opened) { + reset(); + return; + } + open(); + }; + + return ( + <Popover + opened={opened} + onChange={(nextOpened) => { + if (nextOpened) open(); + if (!nextOpened) reset(); + }} + position="bottom" + shadow="sm" + > + <Popover.Target>{target(handleToggle)}</Popover.Target> + <Popover.Dropdown> + <Stack gap="sm" maw={280}> + <Text fw={500}>{t("controls.setTimer")}</Text> + <Flex direction="column" align="center" justify="center"> + <Stack align="flex-end"> + <Group> + <Text>{tCommon("information.hours")}</Text> + <ActionIcon + size={35} + variant="default" + onClick={() => hoursHandlers.current?.decrement()} + aria-label={`− ${tCommon("information.hours")}`} + > + – + </ActionIcon> + <NumberInput + hideControls + value={hours} + onChange={(val) => setHours(Number(val))} + handlersRef={hoursHandlers} + aria-label={tCommon("information.hours")} + max={999} + min={0} + step={1} + styles={{ input: { width: rem(54), textAlign: "center" } }} + /> + <ActionIcon + size={35} + variant="default" + onClick={() => hoursHandlers.current?.increment()} + aria-label={`+ ${tCommon("information.hours")}`} + > + + + </ActionIcon> + </Group> + <Group> + <Text>{tCommon("information.minutes")}</Text> + <ActionIcon + size={35} + variant="default" + onClick={() => minutesHandlers.current?.decrement()} + aria-label={`− ${tCommon("information.minutes")}`} + > + – + </ActionIcon> + <NumberInput + hideControls + value={minutes} + onChange={(val) => setMinutes(Number(val))} + handlersRef={minutesHandlers} + aria-label={tCommon("information.minutes")} + max={59} + min={0} + step={1} + styles={{ input: { width: rem(54), textAlign: "center" } }} + /> + <ActionIcon + size={35} + variant="default" + onClick={() => minutesHandlers.current?.increment()} + aria-label={`+ ${tCommon("information.minutes")}`} + > + + + </ActionIcon> + </Group> + </Stack> + <Text ta="center" c="dimmed" my={5}> + {t("controls.unlimited")} + </Text> + <Button + variant="light" + color="red" + leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />} + h="2rem" + w="12rem" + onClick={handleSetTimer} + > + {t("controls.set")} + </Button> + </Flex> + </Stack> + </Popover.Dropdown> + </Popover> + ); +}; + +export default TimerPopover; diff --git a/packages/widgets/src/modals/widget-advanced-options-modal.tsx b/packages/widgets/src/modals/widget-advanced-options-modal.tsx deleted file mode 100644 index 184fd4678..000000000 --- a/packages/widgets/src/modals/widget-advanced-options-modal.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { CloseButton, ColorInput, Input, Stack, TextInput, useMantineTheme } from "@mantine/core"; - -import { useForm } from "@homarr/form"; -import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { TextMultiSelect } from "@homarr/ui"; -import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; - -interface InnerProps { - advancedOptions: BoardItemAdvancedOptions; - onSuccess: (options: BoardItemAdvancedOptions) => void; -} - -export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, innerProps }) => { - const t = useI18n("item.edit"); - const theme = useMantineTheme(); - const form = useForm({ - initialValues: innerProps.advancedOptions, - }); - const handleSubmit = (values: BoardItemAdvancedOptions) => { - innerProps.onSuccess({ - ...values, - // we want to fallback to null if the title is empty - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - title: values.title?.trim() || null, - }); - actions.closeModal(); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <TextInput - label={t("field.title.label")} - data-autofocus - {...form.getInputProps("title")} - rightSection={<Input.ClearButton onClick={() => form.setFieldValue("title", "")} />} - /> - <TextMultiSelect label={t("field.customCssClasses.label")} {...form.getInputProps("customCssClasses")} /> - <ColorInput - label={t("field.borderColor.label")} - format="hex" - swatches={Object.values(theme.colors).map((color) => color[6])} - rightSection={ - <CloseButton - onClick={() => form.setFieldValue("borderColor", "")} - style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }} - /> - } - {...form.getInputProps("borderColor")} - /> - <ModalFormFooter onCancel={actions.closeModal} /> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle(t) { - return t("item.edit.advancedOptions.title"); - }, - size: modalSizeForm, - transitionProps: { - duration: 0, - }, -}); diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 5cb24cd9b..80582e325 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -5,19 +5,25 @@ import { useEffect, useId, useRef, useState } from "react"; import type { ComponentType, FormEvent, PropsWithChildren } from "react"; import { + Accordion, Alert, Badge, Box, Button, Center, + CloseButton, + ColorInput, Divider, Group, + Input as MantineInput, NumberInput, SimpleGrid, Stack, Tabs, Text, + TextInput, Tooltip, + useMantineTheme, } from "@mantine/core"; import { schemaResolver } from "@mantine/form"; import { useElementSize } from "@mantine/hooks"; @@ -30,11 +36,11 @@ import { objectEntries } from "@homarr/common"; import { IntegrationProvider, 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, modalSizeForm } from "@homarr/modals"; import type { SettingsContextProps } from "@homarr/settings/creator"; import { SpotlightProvider } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; -import { IntegrationAvatar } from "@homarr/ui"; +import { IntegrationAvatar, TextMultiSelect } from "@homarr/ui"; import { zodErrorMap } from "@homarr/validation/form/i18n"; import { getInputForType } from "../_inputs"; @@ -47,7 +53,6 @@ import { OPTIONS_SUPER_REFINE } from "../options"; import type { IntegrationSelectOption } from "../widget-integration-select"; import { WidgetIntegrationSelect } from "../widget-integration-select"; import { WidgetCardShell, WidgetTitleBadge } from "../widget-card-shell"; -import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal"; import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form"; import { EmbeddedAppEditForm } from "./embedded-app-edit-form"; import classes from "./widget-edit-modal.module.css"; @@ -314,7 +319,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ const tCommon = useI18n("common"); const board = useOptionalBoard(); const { data: session } = useSession(); - const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions); + const theme = useMantineTheme(); const appEditRef = useRef<EmbeddedAppEditFormHandle>(null); const integrationEditHandles = useRef(new Map<string, EmbeddedIntegrationEditFormHandle>()); const integrationEditRefCallbacks = useRef( @@ -368,6 +373,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ options: optionsSchema, integrationIds: integrationIdsSchema, advancedOptions: z.object({ + title: z.string().max(64).nullable(), customCssClasses: z.array(z.string()), borderColor: z.string(), }), @@ -377,7 +383,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ validateInputOnBlur: true, validateInputOnChange: true, }); - const { openModal } = useModalAction(WidgetAdvancedOptionsModal); const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false; const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); @@ -457,7 +462,10 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ innerProps.onSuccessfulEdit( { ...values, - advancedOptions, + advancedOptions: { + ...values.advancedOptions, + title: values.advancedOptions.title?.trim() || null, + }, }, previewSize ?? undefined, ); @@ -519,14 +527,14 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ <Box className={classes.workspace} data-with-preview={innerProps.previewComponent ? true : undefined} - data-with-settings={hasWidgetSettings ? true : undefined} + data-with-settings > {innerProps.previewComponent && ( <WidgetEditPreview kind={innerProps.kind} Component={innerProps.previewComponent} definition={definition} - state={{ ...form.values, advancedOptions }} + state={form.values} itemId={innerProps.itemId} boardId={innerProps.boardId ?? board?.id} dimensions={previewDimensions} @@ -536,107 +544,131 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ PreviewWrapper={innerProps.previewWrapper} /> )} - {innerProps.previewComponent && hasWidgetSettings && ( - <Divider orientation="vertical" className={classes.previewDivider} /> - )} - <form id={widgetFormId} className={classes.settingsForm} onSubmit={onFormSubmit} hidden={!hasWidgetSettings}> - {hasWidgetSettings && ( - <Stack className={classes.settingsPanel} gap="sm"> - <Group gap="xs" wrap="nowrap"> - <IconSettings size={16} /> - <Text fw={600} size="sm"> - {tItem("settings.label")} - </Text> - </Group> - {canConfigureWidget && innerProps.integrationSupport && ( - <Box className={classes.fullWidthField}> - <WidgetIntegrationSelect - label={tItem("field.integrations.label")} - data={innerProps.integrationData} - canSelectMultiple={maxIntegrations > 1} - withAsterisk={integrationsRequired} - onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} - {...form.getInputProps("integrationIds")} - /> - </Box> - )} - {canConfigureWidget && ( - <Stack gap="sm"> - {switchOptions.length > 0 && ( - <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm"> - {switchOptions.map(([key, value]) => { - const Input = getInputForType(value.type); - if (!Input) { - return null; - } + {innerProps.previewComponent && <Divider orientation="vertical" className={classes.previewDivider} />} + <form id={widgetFormId} className={classes.settingsForm} onSubmit={onFormSubmit}> + <Stack className={classes.settingsPanel} gap="sm"> + {hasWidgetSettings && ( + <> + <Group gap="xs" wrap="nowrap"> + <IconSettings size={16} /> + <Text fw={600} size="sm"> + {tItem("settings.label")} + </Text> + </Group> + {canConfigureWidget && innerProps.integrationSupport && ( + <Box className={classes.fullWidthField}> + <WidgetIntegrationSelect + label={tItem("field.integrations.label")} + data={innerProps.integrationData} + canSelectMultiple={maxIntegrations > 1} + withAsterisk={integrationsRequired} + onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} + {...form.getInputProps("integrationIds")} + /> + </Box> + )} + {canConfigureWidget && ( + <Stack gap="sm"> + {switchOptions.length > 0 && ( + <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm"> + {switchOptions.map(([key, value]) => { + const Input = getInputForType(value.type); + if (!Input) { + return null; + } - return ( - <Box - key={key} - className={classes.switchOption} - data-option-type={value.type} - onClick={(event) => { - const target = event.target; - if (target instanceof Element && target.closest("label, input")) return; - const input = event.currentTarget.querySelector<HTMLInputElement>("input[type=checkbox]"); - if (!input?.disabled) input?.click(); - }} - > - <Input - kind={innerProps.kind} - property={key} - options={value as never} - initialOptions={innerProps.value.options} - itemId={innerProps.itemId} - boardId={innerProps.boardId ?? board?.id} - /> - </Box> - ); - })} - </SimpleGrid> - )} - {otherOptions.map(([key, value]) => { - const Input = getInputForType(value.type); - if (!Input) { - return null; - } + return ( + <Box + key={key} + className={classes.switchOption} + data-option-type={value.type} + onClick={(event) => { + const target = event.target; + if (target instanceof Element && target.closest("label, input")) return; + const input = event.currentTarget.querySelector<HTMLInputElement>("input[type=checkbox]"); + if (!input?.disabled) input?.click(); + }} + > + <Input + kind={innerProps.kind} + property={key} + options={value as never} + initialOptions={innerProps.value.options} + itemId={innerProps.itemId} + boardId={innerProps.boardId ?? board?.id} + /> + </Box> + ); + })} + </SimpleGrid> + )} + {otherOptions.map(([key, value]) => { + const Input = getInputForType(value.type); + if (!Input) { + return null; + } - return ( - <Box key={key} data-option-type={value.type}> - <Input - kind={innerProps.kind} - property={key} - options={value as never} - initialOptions={innerProps.value.options} - itemId={innerProps.itemId} - boardId={innerProps.boardId ?? board?.id} + return ( + <Box key={key} data-option-type={value.type}> + <Input + kind={innerProps.kind} + property={key} + options={value as never} + initialOptions={innerProps.value.options} + itemId={innerProps.itemId} + boardId={innerProps.boardId ?? board?.id} + /> + </Box> + ); + })} + </Stack> + )} + </> + )} + <Accordion variant="contained"> + <Accordion.Item value="advanced-options"> + <Accordion.Control>{tItem("advancedOptions.label")}</Accordion.Control> + <Accordion.Panel> + <Stack gap="sm"> + <TextInput + label={tItem("field.title.label")} + rightSection={ + <MantineInput.ClearButton + onClick={() => form.setFieldValue("advancedOptions.title", "")} + disabled={!form.values.advancedOptions.title} /> - </Box> - ); - })} - </Stack> - )} - </Stack> - )} + } + {...form.getInputProps("advancedOptions.title")} + /> + <TextMultiSelect + label={tItem("field.customCssClasses.label")} + {...form.getInputProps("advancedOptions.customCssClasses")} + /> + <ColorInput + label={tItem("field.borderColor.label")} + format="hex" + swatches={Object.values(theme.colors).map((color) => color[6])} + rightSection={ + <CloseButton + aria-label={tCommon("action.remove")} + onClick={() => form.setFieldValue("advancedOptions.borderColor", "")} + style={{ display: form.values.advancedOptions.borderColor ? undefined : "none" }} + /> + } + {...form.getInputProps("advancedOptions.borderColor")} + /> + </Stack> + </Accordion.Panel> + </Accordion.Item> + </Accordion> + </Stack> </form> </Box> ); const footer = ( <Box className={classes.footer}> - <Group justify="space-between" wrap="nowrap"> - <Button - variant="subtle" - type="button" - onClick={() => - openModal({ - advancedOptions, - onSuccess: setAdvancedOptions, - }) - } - > - {tItem("advancedOptions.label")} - </Button> + <Group justify="end" wrap="nowrap"> <Group gap="xs" wrap="nowrap"> <Button onClick={actions.closeModal} variant="subtle" color="gray"> {tCommon("action.cancel")} diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx index cff8d40c3..073715298 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -66,6 +66,7 @@ import { useForm } from "@homarr/form"; import type { TranslationObject } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; +import { InlineConfirmActionIcon } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import actionTargetClasses from "../common/action-target.module.css"; @@ -80,7 +81,6 @@ import { useSession } from "@homarr/auth/client"; import { constructBoardPermissions } from "@homarr/auth/shared"; import { useRequiredBoard } from "@homarr/boards/context"; import { hotkeys } from "@homarr/definitions"; -import { useConfirmModal } from "@homarr/modals"; const iconProps = { size: 30, @@ -285,21 +285,10 @@ export function Notebook({ return false; }, [editor]); - const { openConfirmModal } = useConfirmModal(); const handleEditCancel = useCallback(() => { if (savingRef.current) return; - openConfirmModal({ - title: t("dismiss.title"), - children: t("dismiss.message"), - labels: { - confirm: t("dismiss.action.discard"), - cancel: t("dismiss.action.keepEditing"), - }, - onConfirm: () => { - setIsEditing(handleEditCancelCallback); - }, - }); - }, [setIsEditing, handleEditCancelCallback, openConfirmModal, t]); + setIsEditing(handleEditCancelCallback); + }, [setIsEditing, handleEditCancelCallback]); const handleEditToggle = useCallback(async () => { if (!editor || savingRef.current) return; @@ -369,8 +358,7 @@ export function Notebook({ }, toolbar: { backgroundColor: "transparent", - borderColor: - "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))", + borderColor: "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))", padding: "0.5rem", }, content: { @@ -554,19 +542,22 @@ export function Notebook({ {isEditing ? <IconDeviceFloppy {...iconProps} /> : <IconEdit {...iconProps} />} </ActionIcon> {isEditing && ( - <ActionIcon + <InlineConfirmActionIcon className={`homarr-notebook-action ${actionTargetClasses.root}`} data-visible title={tCommon("action.cancel")} aria-label={tCommon("action.cancel")} + confirmLabel={t("dismiss.message")} + confirmationAriaLabel={t("dismiss.action.discard")} + confirmationChildren={<IconCheck {...iconProps} />} + onConfirm={handleEditCancel} color={primaryColor} variant="light" size={30} disabled={isSaving} - onClick={handleEditCancel} > <IconX {...iconProps} /> - </ActionIcon> + </InlineConfirmActionIcon> )} </Stack> )} diff --git a/scripts/browser-agent/ux-coherence-happy-paths.sh b/scripts/browser-agent/ux-coherence-happy-paths.sh new file mode 100755 index 000000000..2a172deab --- /dev/null +++ b/scripts/browser-agent/ux-coherence-happy-paths.sh @@ -0,0 +1,496 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-${BASE_URL:-http://100.111.30.70:3201}}" +SCREENSHOT_DIR="${2:-${SCREENSHOT_DIR:-.screenshots/ux-coherence-browser-agent}}" +BASE_URL="${BASE_URL%/}" + +mkdir -p "$SCREENSHOT_DIR" +SCREENSHOT_DIR="$(cd "$SCREENSHOT_DIR" && pwd)" + +SESSION="$(mise exec -- agent-browser session id --scope worktree --prefix ux-coherence)" +BOARD_NAME="" +BOARD_CREATE_REQUESTED=0 +BOARD_CREATED=0 +BOARD_CLEANUP_PENDING=0 +API_KEY_CLEANUP_PENDING=0 +API_KEY_CREATED_ID="" +CLEANUP_RAN=0 +CLEANUP_DEADLINE=0 +declare -a API_KEY_IDS_BEFORE=() +declare -A API_KEY_IDS_BEFORE_SET=() +API_KEY_IDS_BEFORE_JSON="[]" + +ab() { + mise exec -- agent-browser --session "$SESSION" "$@" +} + +fail() { + echo "ux-coherence browser assertion failed: $*" >&2 + exit 1 +} + +assert_count() { + local selector="$1" + local expected="$2" + local message="$3" + local actual + + actual="$(ab get count "$selector")" + if [[ "$actual" != "$expected" ]]; then + fail "$message (expected $expected, got $actual)" + fi +} + +assert_no_dialog() { + local description="$1" + + assert_eval "(() => { const dialog = Array.from(document.querySelectorAll('[role=\"dialog\"]')).find((element) => element.offsetParent !== null); if (dialog) throw new Error('$description opened a visible dialog'); return true; })()" +} + +assert_no_modal_dialog() { + local description="$1" + + assert_eval "(() => { const modal = Array.from(document.querySelectorAll('[role=\"dialog\"][aria-modal=\"true\"]')).find((element) => element.offsetParent !== null); if (modal) throw new Error('$description opened a visible modal dialog'); return true; })()" +} + +assert_eval() { + local expression="$1" + + ab eval "$expression" >/dev/null +} + +parse_api_key_ids() { + local -n target="$1" + local remaining="$2" + local marker + local id + + target=() + while [[ "$remaining" =~ UX_COHERENCE_API_ID=([[:xdigit:]]{8}) ]]; do + marker="${BASH_REMATCH[0]}" + id="${BASH_REMATCH[1],,}" + target+=("$id") + remaining="${remaining#*"$marker"}" + done +} + +capture_api_key_ids() { + local target_name="$1" + local -n target="$target_name" + local output + local count + + output="$(ab eval "Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => 'UX_COHERENCE_API_ID=' + button.closest('tr')?.querySelector('td')?.textContent?.trim()).join('\\n')")" || return + count="$(ab get count 'button[aria-label="Delete API token"]')" || return + if [[ ! "$count" =~ ^[0-9]+$ ]]; then + return 1 + fi + + parse_api_key_ids "$target_name" "$output" + [[ "${#target[@]}" -eq "$count" ]] +} + +capture_cleanup_api_key_ids() { + local target_name="$1" + local -n target="$target_name" + local output + local count + + output="$(cleanup_ab eval "Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => 'UX_COHERENCE_API_ID=' + button.closest('tr')?.querySelector('td')?.textContent?.trim()).join('\\n')")" || return + count="$(cleanup_ab get count 'button[aria-label="Delete API token"]')" || return + if [[ ! "$count" =~ ^[0-9]+$ ]]; then + return 1 + fi + + parse_api_key_ids "$target_name" "$output" + [[ "${#target[@]}" -eq "$count" ]] +} + +api_key_ids_match_baseline() { + local -n current="$1" + local id + + if [[ "${#current[@]}" -ne "${#API_KEY_IDS_BEFORE[@]}" ]]; then + return 1 + fi + + for id in "${current[@]}"; do + if [[ -z "${API_KEY_IDS_BEFORE_SET[$id]+present}" ]]; then + return 1 + fi + done +} + +api_key_ids_to_json() { + local -n ids="$1" + local separator="" + local id + + printf '[' + for id in "${ids[@]}"; do + printf '%s"%s"' "$separator" "$id" + separator="," + done + printf ']' +} + +capture() { + local path="$1" + + # Development tooling is not part of Homarr and obscures the reviewed UI. + ab eval "document.querySelectorAll('nextjs-portal, button[aria-label=\"Open TanStack Devtools\"]').forEach((element) => element.style.setProperty('display', 'none', 'important'))" >/dev/null + ab screenshot --full "$path" +} + +retry_action_until() { + local action="$1" + local condition="$2" + local description="$3" + local result + + for _ in {1..240}; do + result="$(ab eval "$condition" 2>/dev/null || true)" + if [[ "$result" == "true" ]]; then + return + fi + + ab eval "$action" >/dev/null 2>&1 || true + ab wait 250 >/dev/null + done + + fail "$description" +} + +open_expandable() { + local selector="$1" + local description="$2" + + retry_action_until \ + "document.querySelector('$selector')?.click()" \ + "document.querySelector('$selector')?.getAttribute('aria-expanded') === 'true'" \ + "$description did not expand" +} + +cleanup_ab() { + local remaining=$((CLEANUP_DEADLINE - SECONDS)) + local command_timeout + + if ((remaining <= 0)); then + return 124 + fi + + command_timeout="$remaining" + if ((command_timeout > 10)); then + command_timeout=10 + fi + + AGENT_BROWSER_DEFAULT_TIMEOUT="$((command_timeout * 1000))" \ + timeout --signal=TERM "${command_timeout}s" mise exec -- agent-browser --session "$SESSION" "$@" +} + +cleanup_board_resource() { + local opened + + if ((BOARD_CREATED)); then + echo "Best-effort cleanup: confirmed board $BOARD_NAME" >&2 + else + echo "Best-effort cleanup: board creation request for $BOARD_NAME" >&2 + fi + cleanup_ab open "$BASE_URL/manage/boards" >/dev/null 2>&1 || return + cleanup_ab wait --load domcontentloaded >/dev/null 2>&1 || return + cleanup_ab wait --fn "document.querySelector('main') !== null" >/dev/null 2>&1 || return + opened="$(cleanup_ab eval "(() => { const card = Array.from(document.querySelectorAll('main .mantine-Card-root')).find((item) => item.querySelector('a[href=\"/boards/$BOARD_NAME\"]')); if (!card) return false; const settings = card.querySelector('button[aria-label=\"Settings\"]'); if (!settings) throw new Error('Board settings button was not found'); settings.click(); return true; })()" 2>/dev/null)" || return + if [[ "$opened" != "true" ]]; then + BOARD_CLEANUP_PENDING=0 + BOARD_CREATED=0 + return + fi + + cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((item) => item.offsetParent !== null && item.textContent?.trim() === 'Delete')" >/dev/null 2>&1 || return + cleanup_ab eval "(() => { const item = Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete'); if (!item) throw new Error('Board Delete action was not found'); item.click(); return true; })()" >/dev/null 2>&1 || return + cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((item) => item.offsetParent !== null && item.textContent?.trim() === 'Confirm')" >/dev/null 2>&1 || return + cleanup_ab eval "(() => { const item = Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm'); if (!item) throw new Error('Board Confirm action was not found'); item.click(); return true; })()" >/dev/null 2>&1 || return + cleanup_ab wait --fn "!document.body.innerText.includes('$BOARD_NAME')" >/dev/null 2>&1 || return + BOARD_CLEANUP_PENDING=0 + BOARD_CREATED=0 +} + +cleanup_api_key_resources() { + local id + local -a current_ids=() + local -a created_ids=() + local -a remaining_ids=() + + echo "Best-effort cleanup: API keys created after the captured baseline" >&2 + cleanup_ab open "$BASE_URL/manage/tools/api" >/dev/null 2>&1 || return + cleanup_ab wait --load domcontentloaded >/dev/null 2>&1 || return + cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"tab\"]')).some((item) => item.textContent?.trim() === 'Authentication')" >/dev/null 2>&1 || return + cleanup_ab eval "Array.from(document.querySelectorAll('[role=\"tab\"]')).find((item) => item.textContent?.trim() === 'Authentication')?.click()" >/dev/null 2>&1 || return + cleanup_ab wait --fn "Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" >/dev/null 2>&1 || return + capture_cleanup_api_key_ids current_ids || return + + if [[ -n "$API_KEY_CREATED_ID" ]]; then + for id in "${current_ids[@]}"; do + if [[ "$id" == "$API_KEY_CREATED_ID" ]]; then + created_ids+=("$id") + fi + done + else + for id in "${current_ids[@]}"; do + if [[ -z "${API_KEY_IDS_BEFORE_SET[$id]+present}" ]]; then + created_ids+=("$id") + fi + done + fi + + for id in "${created_ids[@]}"; do + cleanup_ab eval "(() => { const id = '$id'; const button = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).find((item) => item.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase() === id); if (!button) throw new Error('Created API key row was not found'); button.click(); return true; })()" >/dev/null 2>&1 || return + cleanup_ab wait --fn "(() => { const id = '$id'; const row = Array.from(document.querySelectorAll('tr')).find((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === id); return row?.querySelector('button[aria-label=\"Confirm\"]')?.offsetParent !== null; })()" >/dev/null 2>&1 || return + cleanup_ab eval "(() => { const id = '$id'; const row = Array.from(document.querySelectorAll('tr')).find((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === id); const button = row?.querySelector('button[aria-label=\"Confirm\"]'); if (!button) throw new Error('Created API key Confirm action was not found'); button.click(); return true; })()" >/dev/null 2>&1 || return + cleanup_ab wait --fn "!Array.from(document.querySelectorAll('tr')).some((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === '$id')" >/dev/null 2>&1 || return + done + + capture_cleanup_api_key_ids remaining_ids || return + if api_key_ids_match_baseline remaining_ids; then + API_KEY_CLEANUP_PENDING=0 + API_KEY_CREATED_ID="" + fi +} + +cleanup() { + local exit_code=$? + + if ((CLEANUP_RAN)); then + exit "$exit_code" + fi + + CLEANUP_RAN=1 + trap - EXIT INT TERM + set +e + CLEANUP_DEADLINE=$((SECONDS + 45)) + + if ((API_KEY_CLEANUP_PENDING)); then + cleanup_api_key_resources + fi + if ((BOARD_CLEANUP_PENDING && BOARD_CREATE_REQUESTED)); then + cleanup_board_resource + fi + + # Closing the session is always the final browser operation. + timeout --signal=TERM 5s mise exec -- agent-browser --session "$SESSION" close >/dev/null 2>&1 + exit "$exit_code" +} + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +echo "Browser session: $SESSION" +echo "Base URL: $BASE_URL" +echo "Screenshots: $SCREENSHOT_DIR" + +# The first command launches Chromium. This host requires the sandbox override. +ab --args "--no-sandbox" open "$BASE_URL" +ab wait --load domcontentloaded +ab set viewport 1440 1000 +ab set media light reduced-motion +ab wait --fn "window.location.pathname.includes('/auth/login') || document.querySelector('button[aria-label=\"Edit board\"]') !== null" --timeout 120000 + +current_url="$(ab get url)" +if [[ "$current_url" == *"/auth/login"* ]]; then + # These credentials are visibly documented by the writable demo login page. + ab wait --text "Demo mode is enabled" + ab wait --fn "(() => { const input = document.querySelector('input[name=\"username\"]'); return input !== null && Object.keys(input).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000 + ab snapshot -i + ab find label "Username" fill "demo" --exact + ab find label "Password" fill "demo" --exact + ab find role button click --name "Login" --exact + ab wait --fn "!window.location.pathname.includes('/auth/login')" --timeout 120000 + ab snapshot -i +fi + +current_url="$(ab get url)" +if [[ "$current_url" == *"/auth/login"* ]]; then + fail "demo login did not leave the login page" +fi + +navigate() { + local path="$1" + + ab open "$BASE_URL$path" + ab wait --load domcontentloaded + ab snapshot -i +} + +echo "[1/5] Weather dimensions popover" +navigate "/widgets/weather" +ab wait 'button[aria-label="Change dimensions"]' --timeout 120000 +open_expandable 'button[aria-label="Change dimensions"]' "Weather dimensions popover" +ab wait --text "Width" +ab snapshot -i +# Mantine gives non-modal popovers a dialog role; verify that no modal shell opened. +assert_no_modal_dialog "Weather dimensions popover" +capture "$SCREENSHOT_DIR/01-weather-dimensions-popover.png" +assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Cancel'); if (!button) throw new Error('Weather dimensions Cancel button was not found'); button.click(); return true; })()" +ab snapshot -i + +echo "[2/5] Board creation and inline cleanup" +BOARD_NAME="ux-$(date +%Y%m%d%H%M%S)-$BASHPID" +navigate "/manage/boards" +ab wait --fn "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'New board'); return button !== undefined && Object.keys(button).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000 +ab find role button click --name "New board" --exact +ab wait --fn "Array.from(document.querySelectorAll('input')).some((input) => input.getAttribute('aria-label') === 'Name' || input.labels?.[0]?.textContent?.trim().startsWith('Name'))" --timeout 120000 +assert_no_modal_dialog "Board creation form" +ab snapshot -i +ab find role textbox fill "$BOARD_NAME" --name "Name" --exact +ab wait --text "$BOARD_NAME is available" +ab snapshot -i +assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create'); if (!button || button.disabled) throw new Error('Create board button is not enabled'); return true; })()" +capture "$SCREENSHOT_DIR/02-board-inline-create.png" +BOARD_CREATE_REQUESTED=1 +BOARD_CLEANUP_PENDING=1 +assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create'); if (!button || button.disabled) throw new Error('Create board button is not enabled'); button.click(); return true; })()" +ab wait --url "**/boards/$BOARD_NAME*" --timeout 120000 +BOARD_CREATED=1 +ab snapshot -i + +navigate "/manage/boards" +ab wait --text "$BOARD_NAME" +retry_action_until \ + "(() => { const card = Array.from(document.querySelectorAll('main .mantine-Card-root')).find((item) => item.querySelector('a[href=\"/boards/$BOARD_NAME\"]')); const settings = card?.querySelector('button[aria-label=\"Settings\"]'); settings?.scrollIntoView({ block: 'center' }); if (settings?.getAttribute('aria-expanded') !== 'true') settings?.click(); })()" \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete')" \ + "Created board actions did not open" +ab snapshot -i +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete')?.click()" \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm')" \ + "Board Delete action did not enter confirmation state" +ab snapshot -i +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm')?.click()" \ + "!document.body.innerText.includes('$BOARD_NAME')" \ + "Created board still exists after cleanup" +ab snapshot -i +assert_eval "(() => { if (document.body.innerText.includes('$BOARD_NAME')) throw new Error('Created board still exists after cleanup'); return true; })()" +BOARD_CLEANUP_PENDING=0 +BOARD_CREATED=0 + +echo "[3/5] Calendar inline removal and reload restore" +navigate "/" +ab wait '[data-kind="calendar"]' --timeout 120000 +if [[ "$(ab get count 'button[aria-label="Edit board"]')" == "1" ]]; then + retry_action_until \ + "document.querySelector('button[aria-label=\"Edit board\"]')?.click()" \ + "document.querySelector('button[aria-label=\"Settings for Calendar\"]') !== null" \ + "Board did not enter edit mode" + ab snapshot -i +fi +ab hover '[data-kind="calendar"]' +ab wait --fn "(() => { const button = document.querySelector('button[aria-label=\"Settings for Calendar\"]'); return button !== null && button.offsetParent !== null; })()" --timeout 120000 +ab snapshot -i +retry_action_until \ + "document.querySelector('button[aria-label=\"Settings for Calendar\"]')?.click()" \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Remove item')" \ + "Calendar actions did not open" +ab snapshot -i +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Remove item')?.click()" \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Are you sure?')" \ + "Calendar removal did not enter confirmation state" +ab snapshot -i +assert_no_dialog "Calendar removal confirmation" +capture "$SCREENSHOT_DIR/03-calendar-inline-remove-confirmation.png" +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Are you sure?')?.click()" \ + "document.querySelector('[data-kind=\"calendar\"]') === null" \ + "Calendar item was not removed" +ab snapshot -i +ab reload +ab wait --load domcontentloaded +ab wait '[data-kind="calendar"]' +ab snapshot -i +assert_count '[data-kind="calendar"]' 1 "Calendar item was not restored by reloading without Save" + +echo "[4/5] API token inline creation and exact-row cleanup" +navigate "/manage/tools/api" +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"tab\"]')).find((element) => element.textContent?.trim() === 'Authentication')?.click()" \ + "Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" \ + "Authentication tab did not open" +ab snapshot -i +capture_api_key_ids API_KEY_IDS_BEFORE || fail "Could not capture the pre-create API key IDs" +API_KEY_IDS_BEFORE_SET=() +for api_key_id in "${API_KEY_IDS_BEFORE[@]}"; do + API_KEY_IDS_BEFORE_SET["$api_key_id"]=1 +done +API_KEY_IDS_BEFORE_JSON="$(api_key_ids_to_json API_KEY_IDS_BEFORE)" +API_KEY_CLEANUP_PENDING=1 +ab wait --fn "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create API token'); return button !== undefined && !button.disabled && Object.keys(button).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000 +ab find role button click --name "Create API token" --exact +ab wait --text "API token created" --timeout 120000 +ab snapshot -i -s "table" +assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create API token'); if (!button?.disabled) throw new Error('Create API token is not disabled while the new token is visible'); return true; })()" +assert_no_dialog "API token creation result" +# The token stays masked; do not snapshot, reveal, copy, or read the secret alert. +capture "$SCREENSHOT_DIR/04-api-token-created-inline.png" +retry_action_until \ + "Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Close' && item.offsetParent !== null)?.click()" \ + "!document.body.innerText.includes('API token created')" \ + "API token result did not close" +ab snapshot -i +ab reload +ab wait --load domcontentloaded +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"tab\"]')).find((element) => element.textContent?.trim() === 'Authentication')?.click()" \ + "Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" \ + "Authentication tab did not reopen after refreshing API keys" +ab wait --fn "(() => { const previous = new Set($API_KEY_IDS_BEFORE_JSON); const created = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => button.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase()).filter(Boolean).filter((id) => !previous.has(id)); return created.length === 1; })()" --timeout 120000 +ab snapshot -i +declare -a API_KEY_IDS_AFTER_CREATE=() +capture_api_key_ids API_KEY_IDS_AFTER_CREATE || fail "Could not capture the post-create API key IDs" +for api_key_id in "${API_KEY_IDS_AFTER_CREATE[@]}"; do + if [[ -z "${API_KEY_IDS_BEFORE_SET[$api_key_id]+present}" ]]; then + if [[ -n "$API_KEY_CREATED_ID" ]]; then + fail "More than one API key appeared after creation" + fi + API_KEY_CREATED_ID="$api_key_id" + fi +done +if [[ -z "$API_KEY_CREATED_ID" ]]; then + fail "Could not identify the newly created API token row" +fi +assert_eval "(() => { const id = '$API_KEY_CREATED_ID'; const button = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).find((item) => item.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase() === id); if (!button) throw new Error('Created API key row was not found'); button.setAttribute('aria-label', 'Delete created API token'); return true; })()" +ab find role button click --name "Delete created API token" --exact +ab wait --fn "document.querySelector('button[aria-label=\"Confirm\"]')?.offsetParent !== null" +ab snapshot -i +assert_no_dialog "API token deletion confirmation" +capture "$SCREENSHOT_DIR/05-api-token-inline-delete-confirmation.png" +ab find role button click --name "Confirm" --exact +ab wait --fn "!Array.from(document.querySelectorAll('tr')).some((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === '$API_KEY_CREATED_ID')" --timeout 120000 +ab snapshot -i +assert_eval "(() => { const previous = new Set($API_KEY_IDS_BEFORE_JSON); const current = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => button.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase()).filter(Boolean); if (current.length !== previous.size || current.some((id) => !previous.has(id))) throw new Error('New API token row still exists after cleanup'); return true; })()" +capture "$SCREENSHOT_DIR/06-api-token-cleaned-up.png" +declare -a API_KEY_IDS_AFTER_CLEANUP=() +capture_api_key_ids API_KEY_IDS_AFTER_CLEANUP || fail "Could not verify API key cleanup" +api_key_ids_match_baseline API_KEY_IDS_AFTER_CLEANUP || fail "API key cleanup did not restore the pre-create IDs" +API_KEY_CLEANUP_PENDING=0 +API_KEY_CREATED_ID="" + +echo "[5/5] Certificate upload inline expansion" +navigate "/manage/tools/certificates" +retry_action_until \ + "Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'false')?.click()" \ + "Array.from(document.querySelectorAll('button')).some((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'true')" \ + "Add certificate section did not expand" +ab snapshot -i +assert_no_dialog "Add certificate section" +capture "$SCREENSHOT_DIR/07-certificate-upload-inline.png" +retry_action_until \ + "Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Cancel' && item.offsetParent !== null)?.click()" \ + "Array.from(document.querySelectorAll('button')).some((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'false')" \ + "Add certificate section did not close" +ab snapshot -i + +echo "UX coherence happy paths passed. Screenshots remain in $SCREENSHOT_DIR" From 7da2e10064afcab38e4a6fd656dbcc06a052e948 Mon Sep 17 00:00:00 2001 From: Thomas <thomas@ajnart.fr> Date: Thu, 27 Aug 2026 13:59:42 +0200 Subject: [PATCH 10/11] fix: address release v2 review findings (#6708) --- .github/workflows/ci.yml | 20 +- apps/docs/docs/management/api/index.mdx | 2 + apps/docs/docs/management/settings/index.mdx | 20 + apps/docs/docs/widgets/iframe/index.mdx | 2 + apps/nextjs/package.json | 1 + .../query-persistence.spec.ts | 347 ++++-------------- .../_client-providers/query-persistence.ts | 247 +++---------- .../app/[locale]/_client-providers/trpc.tsx | 51 +-- .../custom-widgets/_copy-ai-prompt-button.tsx | 30 +- .../configmaps/configmaps-table.tsx | 10 +- .../kubernetes/ingresses/ingresses-table.tsx | 9 +- .../kubernetes/kubernetes-query-options.ts | 18 + .../namespaces/namespaces-table.tsx | 10 +- .../tools/kubernetes/nodes/nodes-table.tsx | 9 +- .../tools/kubernetes/pods/pods-table.tsx | 10 +- .../kubernetes/secrets/secrets-table.tsx | 10 +- .../kubernetes/services/services-table.tsx | 10 +- .../kubernetes/volumes/volumes-table.tsx | 10 +- .../board/board-layout-thumbnail.tsx | 35 +- .../src/components/board/board-switcher.tsx | 21 +- .../src/components/board/items/item-menu.tsx | 3 +- .../board/items/widget-context-menu.tsx | 3 +- .../layout/header/lazy-spotlight.tsx | 8 +- packages/api/src/query-cache.ts | 20 + packages/api/src/router/board.ts | 17 +- .../router/docker/docker-reconciliation.ts | 224 ++++++----- .../router/test/docker/docker-router.spec.ts | 4 +- packages/api/src/router/widgets/immich.ts | 5 +- packages/cli/src/index.ts | 3 +- .../widgets/src/common/query-state.spec.ts | 5 + packages/widgets/src/common/query-state.ts | 3 + packages/widgets/src/iframe/component.spec.ts | 8 +- packages/widgets/src/iframe/component.tsx | 32 +- packages/widgets/src/iframe/index.ts | 23 +- .../widgets/src/modals/widget-edit-modal.tsx | 14 +- pnpm-lock.yaml | 14 + pnpm-workspace.yaml | 1 + tools/homarr-dev/internal/tui/actions.go | 6 +- tools/homarr-dev/internal/tui/manage.go | 4 + 39 files changed, 500 insertions(+), 769 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-query-options.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89aa72bba..39d2c4904 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,8 +320,12 @@ jobs: publish-preview: name: Publish multi-platform preview image - needs: [preview-amd64, preview-arm64] + needs: [container, preview-amd64, preview-arm64] if: | + !cancelled() && + needs.container.result == 'success' && + needs.preview-amd64.result == 'success' && + needs.preview-arm64.result == 'success' && ( (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || @@ -375,9 +379,6 @@ jobs: ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }} run: | tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}") - if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then - tags+=(-t "${PREVIEW_IMAGE}:pr-6545") - fi docker buildx imagetools create "${tags[@]}" \ "${PREVIEW_IMAGE}@${AMD64_DIGEST}" \ "${PREVIEW_IMAGE}@${ARM64_DIGEST}" @@ -386,9 +387,6 @@ jobs: if: "!env.ACT" run: | tags=("${PREVIEW_TAG}") - if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then - tags+=("pr-6545") - fi for tag in "${tags[@]}"; do image="${PREVIEW_IMAGE}:${tag}" manifest="$(docker buildx imagetools inspect "$image" --raw)" @@ -435,6 +433,14 @@ jobs: core.info(`No package version found with tag "${tag}"`); return; } + const targetTags = target.metadata?.container?.tags ?? []; + const extraTags = targetTags.filter((targetTag) => targetTag !== tag); + if (extraTags.length > 0) { + core.setFailed( + `Package version ${target.id} also carries tag(s) ${extraTags.map((targetTag) => `"${targetTag}"`).join(", ")}; refusing cleanup.`, + ); + return; + } await github.rest.packages.deletePackageVersionForOrg({ package_type: "container", package_name: packageName, diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index fab706520..67fa8119f 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -115,6 +115,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. +The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum. + Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. ## Docker action payload migration diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index 215ea1bd5..f45fabdc5 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -128,6 +128,26 @@ You can decide if app status is disabled on all boards. You can set the default color scheme for users without their own color scheme. +## Branding + +Branding settings let administrators adapt Homarr to the identity and visual style of their installation. Changes apply across the application and authentication pages after you save the settings. + +### Identity + +- **Application name** replaces the default Homarr name in page titles and branded surfaces. +- **Logo** is used in the header and other application branding. +- **Favicon** controls the browser tab icon. When it is empty, Homarr falls back to the configured logo. + +### Colors and corners + +Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements. + +### Sign-in page + +You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting. + +Use an image URL that every user can load before signing in. A URL that requires an authenticated Homarr session will not work on the sign-in page. + ## Culture ### Default language diff --git a/apps/docs/docs/widgets/iframe/index.mdx b/apps/docs/docs/widgets/iframe/index.mdx index 5f410a7f3..040e08c31 100644 --- a/apps/docs/docs/widgets/iframe/index.mdx +++ b/apps/docs/docs/widgets/iframe/index.mdx @@ -67,6 +67,8 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai - **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page - **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni +Homarr passes the configured URL directly to the browser. The iframe sandbox and configured permissions still apply. + ### Configuration <WidgetConfig configuration={iframeWidget.configuration} /> diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 16392ff8d..f5c4794b7 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -72,6 +72,7 @@ "@rexa-developer/tiks": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", + "@tanstack/query-sync-storage-persister": "catalog:", "@tanstack/react-devtools": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index 5c18c31f6..d7324e679 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -1,20 +1,17 @@ import { dehydrate, QueryClient } from "@tanstack/react-query"; import type { PersistedClient } from "@tanstack/react-query-persist-client"; -import { persistQueryClientRestore } from "@tanstack/react-query-persist-client"; -import { parse, stringify } from "superjson"; import { afterEach, describe, expect, test, vi } from "vitest"; import { - createSessionQueryPersister, + createSessionQueryPersistence, getQueryPersistenceStorageKey, queryPersistenceBuster, - queryPersistenceMaxDataBytes, shouldPersistDashboardQuery, } from "./query-persistence"; -const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => { +const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => { const queryClient = new QueryClient(); - queryClient.setQueryData(queryKey, data, { updatedAt }); + queryClient.setQueryData(queryKey, data); const query = queryClient.getQueryCache().find({ queryKey }); if (!query) throw new Error("Expected query to be present"); return { query, queryClient }; @@ -37,314 +34,98 @@ const createMemoryStorage = (): Storage => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - window.sessionStorage.clear(); }); -describe("widget query persistence", () => { - test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => { - const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 }); +describe("dashboard query persistence", () => { + test("keeps usable dashboard data and skips volatile or unrelated queries", () => { + const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 }); const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]); - const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" }); const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" }); - const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], { - value: "x".repeat(queryPersistenceMaxDataBytes + 1), + const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []); + const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], { + apiKey: "secret", }); + const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" }); const pendingClient = new QueryClient(); const pending = pendingClient.getQueryCache().build(pendingClient, { queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }], queryFn: () => Promise.resolve([]), }); + const failed = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], []); + failed.query.setState({ ...failed.query.state, error: new Error("refresh failed"), status: "error" }); expect(shouldPersistDashboardQuery(widget.query)).toBe(true); expect(shouldPersistDashboardQuery(supporting.query)).toBe(true); - expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false); expect(shouldPersistDashboardQuery(ping.query)).toBe(false); - expect(shouldPersistDashboardQuery(oversized.query)).toBe(false); + expect(shouldPersistDashboardQuery(beszel.query)).toBe(false); + expect(shouldPersistDashboardQuery(customApi.query)).toBe(false); + expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false); expect(shouldPersistDashboardQuery(pending)).toBe(false); + expect(shouldPersistDashboardQuery(failed.query)).toBe(true); }); - test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => { - expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a")); - expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b")); - expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a")); - - const userAKey = getQueryPersistenceStorageKey("user-a"); - window.sessionStorage.setItem(userAKey, "a"); - createSessionQueryPersister("user-a").removeClient(); - expect(window.sessionStorage.getItem(userAKey)).toBeNull(); - }); - - test("evicts every other scope so signing out cannot leave data behind", () => { - const userAKey = getQueryPersistenceStorageKey("user-a"); - const anonymousKey = getQueryPersistenceStorageKey(null); - window.sessionStorage.setItem(userAKey, "a"); - window.sessionStorage.setItem(anonymousKey, "anonymous"); - window.sessionStorage.setItem("unrelated", "kept"); - - createSessionQueryPersister("user-b"); - - expect(window.sessionStorage.getItem(userAKey)).toBeNull(); - expect(window.sessionStorage.getItem(anonymousKey)).toBeNull(); - expect(window.sessionStorage.getItem("unrelated")).toBe("kept"); - }); - - test("never restores another session scope", async () => { - const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const; - const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 }); - const userAPersister = createSessionQueryPersister("user-a"); - userAPersister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - userAPersister.flush(); - expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1); - - expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined(); - }); - - test("stops persisting once the provider tears the cache down", async () => { - const storage = createMemoryStorage(); - const persister = createSessionQueryPersister("user-a", storage); - const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { - temp: 21, - }); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - persister.flush(); - persister.stop(); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { mutations: [], queries: [] }, - }); - persister.flush(); - - expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1); - }); - - test("removes the cache instead of storing an empty one", () => { - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const persister = createSessionQueryPersister(scope); - window.sessionStorage.setItem(key, "stale"); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { mutations: [], queries: [] }, - }); - persister.flush(); - - expect(window.sessionStorage.getItem(key)).toBeNull(); - }); - - test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => { + test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => { vi.useFakeTimers(); - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const persister = createSessionQueryPersister(scope); - const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], { + const storage = createMemoryStorage(); + const userA = createSessionQueryPersistence("user-a", storage); + const userB = createSessionQueryPersistence("user-b", storage); + const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], { startsAt: new Date("2026-08-22T12:00:00.000Z"), }); - query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" }); - expect(shouldPersistDashboardQuery(query)).toBe(true); + const cachedQuery = queryClient.getQueryCache().find({ + queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }], + }); + if (!cachedQuery) throw new Error("Expected cached query to be present"); + cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" }); + queryClient.getMutationCache().build( + queryClient, + { mutationKey: ["integration", "create"], mutationFn: async () => undefined }, + { + context: undefined, + data: undefined, + error: null, + failureCount: 0, + failureReason: null, + isPaused: true, + status: "pending", + submittedAt: Date.now(), + variables: { apiKey: "secret" }, + }, + ); const persistedClient: PersistedClient = { timestamp: Date.now(), buster: queryPersistenceBuster, - clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }), + clientState: dehydrate(queryClient, userA.dehydrateOptions), }; - persister.persistClient(persistedClient); - persister.flush(); - const restored = await persister.restoreClient(); - const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined; - expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z")); + expect(persistedClient.clientState.mutations).toHaveLength(0); + + await userA.persister.persistClient(persistedClient); + await vi.advanceTimersByTimeAsync(1_000); + + const restored = await userA.persister.restoreClient(); + expect(restored?.clientState.queries[0]?.state.data).toEqual({ + startsAt: new Date("2026-08-22T12:00:00.000Z"), + }); expect(restored?.clientState.queries[0]?.state.status).toBe("success"); expect(restored?.clientState.queries[0]?.state.error).toBeNull(); - expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true); - expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error"); + expect(await userB.persister.restoreClient()).toBeUndefined(); - window.sessionStorage.setItem(key, "not-valid-superjson"); - expect(persister.restoreClient()).toBeUndefined(); - expect(window.sessionStorage.getItem(key)).toBeNull(); + await userA.persister.removeClient(); + expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull(); }); - test("sanitizes structurally valid but unsafe stored state before hydration", async () => { - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const { queryClient } = createSuccessfulQuery( - [["widget", "calendar", "findAllEvents"], { type: "query" }], - [{ title: "safe" }], - ); - const validQuery = dehydrate(queryClient).queries[0]; - if (!validQuery) throw new Error("Expected a dehydrated query"); - - window.sessionStorage.setItem( - key, - stringify({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { - mutations: [{ state: { status: "pending" } }], - queries: [ - { - ...validQuery, - state: { - ...validQuery.state, - error: new Error("private refresh error"), - fetchStatus: "fetching", - status: "error", - }, - }, - { - ...validQuery, - queryHash: "unrelated", - queryKey: [["board", "getBoardByName"], { type: "query" }], - }, - { queryHash: "malformed", queryKey: [["widget", "weather"]], state: null }, - { - ...validQuery, - queryHash: "missing-timestamp", - state: { ...validQuery.state, dataUpdatedAt: undefined }, - }, - ], - }, - }), - ); - - const restored = await createSessionQueryPersister(scope).restoreClient(); - expect(restored?.clientState.mutations).toEqual([]); - expect(restored?.clientState.queries).toHaveLength(1); - expect(restored?.clientState.queries[0]?.state.status).toBe("success"); - expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle"); - expect(restored?.clientState.queries[0]?.state.error).toBeNull(); - }); - - test.each([ - ["expired", 1, queryPersistenceBuster], - ["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"], - ["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster], - ])("discards %s cache entries", async (_case, timestamp, storedBuster) => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z")); - const scope = "user-a"; - const persister = createSessionQueryPersister(scope); - const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { - temp: 21, - }); - persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) }); - persister.flush(); - - const restoredClient = new QueryClient(); - await persistQueryClientRestore({ - queryClient: restoredClient, - persister, - maxAge: 5 * 60 * 1_000, - buster: queryPersistenceBuster, - }); - - expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined(); - expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull(); - }); - - test("quota retry writes the cache after evicting the oldest query", async () => { - const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1); - const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2); - const storage = createMemoryStorage(); - const setItem = storage.setItem.bind(storage); - const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => { - const client = parse<PersistedClient>(value); - if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError"); - setItem(key, value); - }); - const persistedClient: PersistedClient = { - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { - mutations: [], - queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries], - }, - }; - const persister = createSessionQueryPersister("user-a", storage); - - persister.persistClient(persistedClient); - persister.flush(); - - expect(setItemSpy).toHaveBeenCalledTimes(2); - const restored = await persister.restoreClient(); - expect(restored?.clientState.queries).toHaveLength(1); - expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]); - }); - - test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => { - vi.useFakeTimers(); - const storage = createMemoryStorage(); - const setItem = vi.spyOn(storage, "setItem"); - const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const; - const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1); - const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2); - const persister = createSessionQueryPersister("user-a", storage); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(first.queryClient), - }); - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(latest.queryClient), - }); - - expect(setItem).not.toHaveBeenCalled(); - persister.flush(); - expect(setItem).toHaveBeenCalledOnce(); - await vi.advanceTimersByTimeAsync(0); - expect(setItem).toHaveBeenCalledOnce(); - - const restored = await persister.restoreClient(); - expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 }); - }); - - test("treats blocked storage operations as an empty cache", async () => { - const blockedStorage = { - getItem: () => { - throw new DOMException("blocked", "SecurityError"); - }, - removeItem: () => { - throw new DOMException("blocked", "SecurityError"); - }, - setItem: () => { - throw new DOMException("blocked", "SecurityError"); - }, - } as unknown as Storage; - const persister = createSessionQueryPersister("blocked", blockedStorage); - const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 }); - - expect(persister.restoreClient()).toBeUndefined(); - expect(() => { - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - persister.flush(); - }).not.toThrow(); - expect(() => persister.removeClient()).not.toThrow(); - }); - - test("treats an unavailable session storage getter as an empty cache", async () => { - vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => { + test("treats unavailable session storage as an empty cache", async () => { + const blockedStorage = createMemoryStorage(); + blockedStorage.getItem = () => { throw new DOMException("blocked", "SecurityError"); - }); + }; + blockedStorage.removeItem = () => { + throw new DOMException("blocked", "SecurityError"); + }; - expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow(); - expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined(); + const persistence = createSessionQueryPersistence("blocked", blockedStorage); + expect(await persistence.persister.restoreClient()).toBeUndefined(); + expect(() => persistence.persister.removeClient()).not.toThrow(); }); }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts index e5145ff5c..dbfd90c2f 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -1,13 +1,12 @@ import type { QueryKey } from "@tanstack/react-query"; -import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"; +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { removeOldestQuery } from "@tanstack/react-query-persist-client"; +import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client"; import { parse, stringify } from "superjson"; -import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; +import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; -export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs; -export const queryPersistenceMaxDataBytes = 256 * 1024; -export const queryPersistenceBuster = "v2-session-superjson"; +export const queryPersistenceBuster = "v6-dashboard-data"; const queryPersistenceStoragePrefix = "homarr:widget-query-cache"; @@ -16,95 +15,33 @@ export const getQueryPersistenceStorageKey = (scope: string | null) => interface PersistableQuery { queryKey: QueryKey; - state: { - data: unknown; - dataUpdatedAt: number; - }; + state: { data: unknown; status: string }; } -export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void }; - -const textEncoder = new TextEncoder(); -const budgetVerdictCache = new WeakMap<object, boolean>(); - -const measureFitsBudget = (data: unknown) => { - try { - return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes; - } catch { - return false; - } -}; - -const fitsStorageBudget = (data: unknown) => { - if (typeof data !== "object" || data === null) return measureFitsBudget(data); - - const cached = budgetVerdictCache.get(data); - if (cached !== undefined) return cached; - - const verdict = measureFitsBudget(data); - budgetVerdictCache.set(data, verdict); - return verdict; -}; - -const holdsDashboardData = (query: PersistableQuery) => - query.state.data !== undefined && - Number.isFinite(query.state.dataUpdatedAt) && - query.state.dataUpdatedAt > 0 && - isWidgetDataQueryKey(query.queryKey); - export const shouldPersistDashboardQuery = (query: PersistableQuery) => - holdsDashboardData(query) && fitsStorageBudget(query.state.data); + query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey); -type DehydratedQuery = PersistedClient["clientState"]["queries"][number]; - -const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({ - ...query, - state: { - ...query.state, - error: null, - errorUpdateCount: 0, - errorUpdatedAt: 0, - fetchFailureCount: 0, - fetchFailureReason: null, - fetchStatus: "idle", - isInvalidated: true, - status: "success", - }, -}); - -const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({ - ...client, - clientState: { - mutations: [], - queries: client.clientState.queries.map(sanitizePersistedQuery), - }, -}); - -const isRecord = (value: unknown): value is Record<string, unknown> => - typeof value === "object" && value !== null && !Array.isArray(value); - -const isRestorableQuery = (value: unknown): value is DehydratedQuery => { - if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false; - if (!isRecord(value.state) || typeof value.state.status !== "string") return false; - - return holdsDashboardData(value as unknown as PersistableQuery); -}; - -const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => { - if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined; - if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined; - - return { - timestamp: value.timestamp as number, - buster: value.buster, +const serializePersistedClient = (client: PersistedClient) => + stringify({ + ...client, clientState: { - mutations: [], - queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery), + ...client.clientState, + queries: client.clientState.queries.map((query) => ({ + ...query, + state: { + ...query.state, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + status: "success", + }, + })), }, - }; -}; + }); -const getSessionStorage = (): Storage | undefined => { +const getSessionStorage = () => { if (typeof window === "undefined") return undefined; try { @@ -114,113 +51,47 @@ const getSessionStorage = (): Storage | undefined => { } }; -const removeStorageItem = (storage: Storage | undefined, key: string) => { - try { - storage?.removeItem(key); - } catch { - return undefined; - } -}; - -const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => { - if (!storage) return; - - try { - const foreignKeys: string[] = []; - for (let index = 0; index < storage.length; index += 1) { - const storedKey = storage.key(index); - if (storedKey === null || storedKey === activeKey) continue; - if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey); - } - - for (const foreignKey of foreignKeys) storage.removeItem(foreignKey); - } catch { - return undefined; - } -}; - -export const createSessionQueryPersister = ( - scope: string | null, - storage: Storage | undefined = getSessionStorage(), -): SessionQueryPersister => { - const key = getQueryPersistenceStorageKey(scope); - removeForeignScopes(storage, key); - let pendingClient: PersistedClient | undefined; - let persistTimer: ReturnType<typeof setTimeout> | undefined; - let stopped = false; - - const persistNow = (client: PersistedClient) => { - if (!storage) return; - let candidate: PersistedClient | undefined = sanitizePersistedClient(client); - let errorCount = 0; - - if (candidate.clientState.queries.length === 0) { - removeStorageItem(storage, key); - return; - } - - while (candidate) { - try { - storage.setItem(key, stringify(candidate)); - return; - } catch (error) { - errorCount += 1; - candidate = removeOldestQuery({ - persistedClient: candidate, - error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"), - errorCount, - }); - } - } - }; - - const clearPersistTimer = () => { - if (persistTimer === undefined) return; - clearTimeout(persistTimer); - persistTimer = undefined; - }; - - const flush = () => { - clearPersistTimer(); - const client = pendingClient; - pendingClient = undefined; - if (client) persistNow(client); - }; +const protectStorage = (storage: Storage | undefined) => { + if (!storage) return undefined; return { - persistClient(client) { - if (!storage || stopped) return; - pendingClient = client; - if (persistTimer !== undefined) return; - persistTimer = setTimeout(flush, 0); - }, - restoreClient() { + getItem(key: string) { try { - const storedValue = storage?.getItem(key); - if (!storedValue) return undefined; - - const client = sanitizeRestoredClient(parse<unknown>(storedValue)); - if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) { - removeStorageItem(storage, key); - return undefined; - } - - return client; + return storage.getItem(key); + } catch { + return null; + } + }, + setItem(key: string, value: string) { + storage.setItem(key, value); + }, + removeItem(key: string) { + try { + storage.removeItem(key); } catch { - removeStorageItem(storage, key); return undefined; } }, - removeClient() { - clearPersistTimer(); - pendingClient = undefined; - removeStorageItem(storage, key); - }, - flush, - stop() { - stopped = true; - clearPersistTimer(); - pendingClient = undefined; - }, }; }; + +export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"]; + +export const createSessionQueryPersistence = ( + scope: string | null, + storage: Storage | undefined = getSessionStorage(), +): SessionQueryPersistence => ({ + persister: createSyncStoragePersister({ + storage: protectStorage(storage), + key: getQueryPersistenceStorageKey(scope), + serialize: serializePersistedClient, + deserialize: parse, + retry: removeOldestQuery, + }), + maxAge: queryCacheDefaultGcTimeMs, + buster: queryPersistenceBuster, + dehydrateOptions: { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: shouldPersistDashboardQuery, + }, +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index dc5242de4..5b6621679 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; -import { QueryClient } from "@tanstack/react-query"; +import { QueryCache, QueryClient } from "@tanstack/react-query"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { @@ -25,6 +25,8 @@ import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { dashboardSupportingQueryPolicies, + isTrpcForbiddenError, + isWidgetDataQueryKey, queryCacheDefaultGcTimeMs, queryCacheDefaultRefetchIntervalMs, queryCacheDefaultStaleTimeMs, @@ -36,13 +38,8 @@ import { showWarningNotification } from "@homarr/notifications"; import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; -import { - createSessionQueryPersister, - queryPersistenceBuster, - queryPersistenceMaxAgeMs, - shouldPersistDashboardQuery, -} from "./query-persistence"; -import type { SessionQueryPersister } from "./query-persistence"; +import { createSessionQueryPersistence } from "./query-persistence"; +import type { SessionQueryPersistence } from "./query-persistence"; import { createQueryRetry } from "./query-retry"; const DevelopmentTools = @@ -75,11 +72,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) { const { data: session } = useSession(); const sessionQueryScope = getSessionQueryScope(session); const [initialSessionQueryScope] = useState(() => sessionQueryScope); - const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope)); + const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope)); const handleScopeChange = useCallback(() => { - void queryPersister.removeClient(); + void queryPersistence.persister.removeClient(); reloadPage(); - }, [queryPersister]); + }, [queryPersistence]); return ( <SessionQueryScopeGuard @@ -87,7 +84,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) { currentScope={sessionQueryScope} onScopeChange={handleScopeChange} > - <ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider> + <ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider> </SessionQueryScopeGuard> ); } @@ -96,8 +93,8 @@ const reloadPage = () => window.location.reload(); const ScopedTRPCReactProvider = ({ children, - queryPersister, -}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => { + queryPersistence, +}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => { const wsClient = useMemo( () => createWSClient({ @@ -114,6 +111,13 @@ const ScopedTRPCReactProvider = ({ ); const [queryClient] = useState(() => { const client = new QueryClient({ + queryCache: new QueryCache({ + onError(error, query) { + if (!isTrpcForbiddenError(error) || !isWidgetDataQueryKey(query.queryKey)) return; + void queryPersistence.persister.removeClient(); + query.setState({ data: undefined, dataUpdatedAt: 0, isInvalidated: true }); + }, + }), defaultOptions: { queries: { staleTime: queryCacheDefaultStaleTimeMs, @@ -152,15 +156,6 @@ const ScopedTRPCReactProvider = ({ return client; }); - useEffect(() => { - const flush = () => queryPersister.flush(); - window.addEventListener("pagehide", flush); - return () => { - window.removeEventListener("pagehide", flush); - flush(); - queryPersister.stop(); - }; - }, [queryPersister]); useEffect(() => () => queryClient.clear(), [queryClient]); const trpcClient = useMemo(() => { @@ -211,15 +206,7 @@ const ScopedTRPCReactProvider = ({ return ( <clientApi.Provider client={trpcClient} queryClient={queryClient}> - <PersistQueryClientProvider - client={queryClient} - persistOptions={{ - persister: queryPersister, - maxAge: queryPersistenceMaxAgeMs, - buster: queryPersistenceBuster, - dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery }, - }} - > + <PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}> <ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration> {process.env.NODE_ENV === "development" && <DevelopmentTools />} </PersistQueryClientProvider> diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx index 3db1ab746..985b75d1a 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx @@ -10,24 +10,17 @@ import { useI18n } from "@homarr/translation/client"; import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; -interface CopyAiPromptButtonBaseProps { +interface CopyAiPromptButtonProps { rawResponse?: string | null; request?: string | null; documentationUrl?: string | null; + getDraft: () => CustomWidgetAiDraft; + getDiagnostics: () => readonly CustomWidgetAiDiagnostic[]; } -type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps & - ({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) & - ( - | { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never } - | { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] } - ); - export const CopyAiPromptButton = ({ rawResponse, - draft, getDraft, - diagnostics, getDiagnostics, request, documentationUrl, @@ -35,23 +28,14 @@ export const CopyAiPromptButton = ({ const t = useI18n("customWidget"); const assistant = useOptionalHomarrAssistant(); - const readDraft = () => { - if (getDraft) return getDraft(); - return draft; - }; - const readDiagnostics = () => { - if (getDiagnostics) return getDiagnostics(); - return diagnostics; - }; - const handleAssistant = () => { const prompt = buildCustomWidgetAssistantPrompt( undefined, rawResponse, - readDraft(), + getDraft(), request, documentationUrl, - readDiagnostics(), + getDiagnostics(), ); if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return; showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") }); @@ -62,10 +46,10 @@ export const CopyAiPromptButton = ({ const value = buildCustomWidgetAiPrompt( undefined, rawResponse, - readDraft(), + getDraft(), request, documentationUrl, - readDiagnostics(), + getDiagnostics(), ); await navigator.clipboard.writeText(value); showSuccessNotification({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx index 813b191cc..adefc229a 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface ConfigMapsTableComponentProps { @@ -47,13 +49,7 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery( { contextId }, - { - initialData: initialConfigMaps, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialConfigMaps), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx index dcc2a2e1d..372dd5da9 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx @@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import KubernetesErrorPage from "../cluster-dashboard/error"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; dayjs.extend(relativeTime); @@ -84,13 +85,7 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery( { contextId }, - { - initialData: initialIngresses, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialIngresses), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-query-options.ts b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-query-options.ts new file mode 100644 index 000000000..217a66a70 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-query-options.ts @@ -0,0 +1,18 @@ +interface QueryWithStatus { + state: { + status: string; + }; +} + +const getErrorRefetchInterval = (query: QueryWithStatus) => { + if (query.state.status === "error") return 30_000; + return false; +}; + +export const createKubernetesResourceQueryOptions = <TData>(initialData: TData) => ({ + initialData, + refetchOnMount: "always" as const, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: getErrorRefetchInterval, +}); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx index d70da8bc4..0c1a01c9d 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx @@ -15,6 +15,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface NamespacesTableComponentProps { @@ -66,13 +68,7 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery( { contextId }, - { - initialData: initialNamespaces, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialNamespaces), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx index 66e64059c..112ee25f3 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx @@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import KubernetesErrorPage from "../cluster-dashboard/error"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; dayjs.extend(relativeTime); @@ -97,13 +98,7 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps) const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery( { contextId }, - { - initialData: initialNodes, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialNodes), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx index 855090602..83395f2b7 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface PodsTableComponentProps { @@ -57,13 +59,7 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) { const { data } = clientApi.kubernetes.pods.getPods.useQuery( { contextId }, - { - initialData: initialPods, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialPods), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx index 2c31987a7..4e3cab8c7 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface SecretsTableComponentProps { @@ -52,13 +54,7 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery( { contextId }, - { - initialData: initialSecrets, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialSecrets), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx index aa2c87c77..3a206cda9 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx @@ -14,6 +14,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface ServicesTableComponentProps { @@ -71,13 +73,7 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo const { data } = clientApi.kubernetes.services.getServices.useQuery( { contextId }, - { - initialData: initialServices, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialServices), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx index 4b54dbe34..e9da29962 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface VolumesTableComponentProps { @@ -76,13 +78,7 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery( { contextId }, - { - initialData: initialVolumes, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialVolumes), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx index 2d959d24d..55462c3e4 100644 --- a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx @@ -11,16 +11,17 @@ import classes from "./board-layout-thumbnail.module.css"; interface BoardLayoutThumbnailProps { preview: BoardPreviewData | null; label: string; - fitFullLayout?: boolean; + previewRowLimit?: number; className?: string; } -const maxThumbnailRows = 12; +const compactBoardLayoutThumbnailRows = 12; +export const maxBoardLayoutThumbnailRows = 48; export const BoardLayoutThumbnail = ({ preview, label, - fitFullLayout = false, + previewRowLimit = compactBoardLayoutThumbnailRows, className, }: BoardLayoutThumbnailProps) => { const canvasClassName = [classes.canvas, className].filter(Boolean).join(" "); @@ -34,7 +35,7 @@ export const BoardLayoutThumbnail = ({ } const elements = projectBoardLayout(preview, layout, layout); - const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows; + const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows)); const roots = preview.sections .filter((section) => section.kind === "empty") .toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id)); @@ -94,9 +95,21 @@ export const BoardLayoutThumbnail = ({ }} > {laneElements.map((element) => { + const renderedWidth = Math.min(element.width, columnCount - element.xOffset); + const renderedHeight = Math.min(element.height, rowCount - element.yOffset); const nestedItems = element.type === "section" - ? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id) + ? elements.filter( + (candidate) => + candidate.type === "item" && + candidate.sectionId === element.id && + candidate.xOffset >= 0 && + candidate.xOffset < renderedWidth && + candidate.yOffset >= 0 && + candidate.yOffset < renderedHeight && + candidate.width > 0 && + candidate.height > 0, + ) : []; return ( <span @@ -104,16 +117,16 @@ export const BoardLayoutThumbnail = ({ aria-hidden className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`} style={{ - gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`, - gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`, + gridColumn: `${element.xOffset + 1} / span ${renderedWidth}`, + gridRow: `${element.yOffset + 1} / span ${renderedHeight}`, }} > {element.type === "section" ? ( <span className={classes.containerContents} style={{ - gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`, - gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`, + gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`, + gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`, }} > {nestedItems.map((nestedItem) => ( @@ -123,11 +136,11 @@ export const BoardLayoutThumbnail = ({ style={{ gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min( nestedItem.width, - element.width - nestedItem.xOffset, + renderedWidth - nestedItem.xOffset, )}`, gridRow: `${nestedItem.yOffset + 1} / span ${Math.min( nestedItem.height, - element.height - nestedItem.yOffset, + renderedHeight - nestedItem.yOffset, )}`, }} > diff --git a/apps/nextjs/src/components/board/board-switcher.tsx b/apps/nextjs/src/components/board/board-switcher.tsx index 786ad582a..14dfaa42c 100644 --- a/apps/nextjs/src/components/board/board-switcher.tsx +++ b/apps/nextjs/src/components/board/board-switcher.tsx @@ -30,11 +30,12 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import { Link, UserAvatar } from "@homarr/ui"; -import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; +import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail"; import classes from "./board-switcher.module.css"; export const boardSwitcherHotkey = "shift+c"; +const boardSwitcherPreview = { previewRowLimit: maxBoardLayoutThumbnailRows } as const; export interface BoardSwitcherControls { open: () => void; @@ -61,18 +62,12 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { data: boards = [], isPending, isError, - } = clientApi.board.getManageOverview.useQuery( - { fullPreview: true }, - { - enabled: isOpen, - }, - ); + } = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, { + enabled: isOpen, + }); const switcherBoards = useMemo( - () => [ - ...boards.filter((board) => board.id !== currentBoard?.id), - ...boards.filter((board) => board.id === currentBoard?.id), - ], + () => boards.filter((board) => board.id !== currentBoard?.id), [boards, currentBoard?.id], ); const filteredBoards = useMemo(() => { @@ -91,7 +86,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { setSearch(""); setActiveIndex(0); }, []); - const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true }); + const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview); const spotlightAction = useMemo( () => ({ @@ -341,7 +336,7 @@ const getBoardSwitcherResults = ({ <BoardLayoutThumbnail preview={board.preview} label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })} - fitFullLayout + previewRowLimit={boardSwitcherPreview.previewRowLimit} /> </Card.Section> <Card.Section withBorder px="sm" py={6}> diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 8db0532f8..e1ac6524b 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -10,6 +10,7 @@ import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { InlineConfirmMenuItem } from "@homarr/ui"; import type { WidgetDefinition } from "@homarr/widgets/definition"; +import type { WidgetPreviewDimensions } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useSectionContext } from "../sections/section-context"; @@ -21,7 +22,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit- interface BoardItemMenuProps { item: SectionItem; definition: WidgetDefinition; - previewDimensions: { width: number; height: number; scale?: number }; + previewDimensions: WidgetPreviewDimensions; resetErrorBoundary?: () => void; } diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index c8821aac4..5057907d2 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -25,6 +25,7 @@ import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definit import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation"; +import type { WidgetPreviewDimensions } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useAdvancedFocus } from "../advanced-focus/context"; @@ -39,7 +40,7 @@ type ToggleOption = [key: string, option: { type: string; skipContextMenu?: bool interface WidgetContextMenuProps { item: SectionItem; definition: WidgetDefinition; - previewDimensions: { width: number; height: number; scale?: number }; + previewDimensions: WidgetPreviewDimensions; widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetRuntimeRef: WidgetRuntimeRef; sourceRef: RefObject<HTMLElement | null>; diff --git a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx index 427988c74..f36f3959c 100644 --- a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx +++ b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx @@ -54,11 +54,13 @@ export const LazySpotlight = () => { }; }, []); - return isMounted || isPreloaded ? ( + if (!isMounted && !isPreloaded) return null; + + return ( <> {isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />} {isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />} - <Spotlight /> + {isMounted && <Spotlight />} </> - ) : null; + ); }; diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts index a1cb201ea..3ef88895b 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -1,5 +1,16 @@ import type { QueryKey } from "@tanstack/react-query"; +const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null; + +interface ForbiddenTrpcError { + data: { code: "FORBIDDEN" }; +} + +export const isTrpcForbiddenError = (error: unknown): error is ForbiddenTrpcError => { + if (!isRecord(error) || !isRecord(error.data)) return false; + return error.data.code === "FORBIDDEN"; +}; + // RSC-prefetched data must survive the hydration pass without an immediate // duplicate fetch. Widget-specific intervals still control live refreshes. export const queryCacheDefaultStaleTimeMs = 30_000; @@ -8,6 +19,7 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5; export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5; const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]); +const persistenceExcludedWidgetPaths = new Set(["widget.customApi.getData", "widget.customApi.queryRequest"]); // Beszel system stats are large time-series payloads and were deliberately kept out of // the persisted cache in #6289; live stats come from an SSE subscription instead. const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]); @@ -66,3 +78,11 @@ export const isWidgetDataQueryKey = (queryKey: QueryKey) => { return isWidgetDataTrpcPath(path.join(".")); }; + +export const isPersistableDashboardQueryKey = (queryKey: QueryKey) => { + const path = getTrpcPathFromQueryKey(queryKey); + if (!path) return false; + + const joinedPath = path.join("."); + return isWidgetDataTrpcPath(joinedPath) && !persistenceExcludedWidgetPaths.has(joinedPath); +}; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 7b94e814e..d2568b5f6 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -86,9 +86,14 @@ interface BoardItemPlacementRectangle { } const boardItemPlacementTails = new Map<string, Promise<void>>(); -const maxManageOverviewPreviewRows = 12; +const defaultManageOverviewPreviewRows = 12; +const maxManageOverviewPreviewRows = 48; const manageOverviewInputSchema = z - .object({ fullPreview: z.boolean().default(false), userId: z.string().optional() }) + .object({ + fullPreview: z.boolean().default(false), + previewRowLimit: z.number().int().min(1).max(maxManageOverviewPreviewRows).optional(), + userId: z.string().optional(), + }) .optional(); const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => { @@ -311,6 +316,8 @@ export const boardRouter = createTRPCRouter({ const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId); const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships); const canViewAllBoards = getCanViewAllBoards(groupMemberships); + let previewRowLimit = input?.previewRowLimit ?? defaultManageOverviewPreviewRows; + if (input?.fullPreview) previewRowLimit = maxManageOverviewPreviewRows; const dbBoards = await ctx.db.query.boards.findMany({ columns: { @@ -388,7 +395,7 @@ export const boardRouter = createTRPCRouter({ inArray(itemLayouts.sectionId, rootSectionIds), gte(itemLayouts.xOffset, 0), gte(itemLayouts.yOffset, 0), - input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), + lt(itemLayouts.yOffset, previewRowLimit), gt(itemLayouts.width, 0), gt(itemLayouts.height, 0), ), @@ -413,7 +420,7 @@ export const boardRouter = createTRPCRouter({ inArray(sectionLayouts.parentSectionId, rootSectionIds), gte(sectionLayouts.xOffset, 0), gte(sectionLayouts.yOffset, 0), - input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows), + lt(sectionLayouts.yOffset, previewRowLimit), gt(sectionLayouts.width, 0), gt(sectionLayouts.height, 0), ), @@ -448,7 +455,7 @@ export const boardRouter = createTRPCRouter({ inArray(itemLayouts.sectionId, previewContainerSectionIds), gte(itemLayouts.xOffset, 0), gte(itemLayouts.yOffset, 0), - input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), + lt(itemLayouts.yOffset, previewRowLimit), gt(itemLayouts.width, 0), gt(itemLayouts.height, 0), ), diff --git a/packages/api/src/router/docker/docker-reconciliation.ts b/packages/api/src/router/docker/docker-reconciliation.ts index de3131f3d..03ff50f38 100644 --- a/packages/api/src/router/docker/docker-reconciliation.ts +++ b/packages/api/src/router/docker/docker-reconciliation.ts @@ -84,15 +84,13 @@ export const getDockerReconciliationAsync = async (db: Database) => { const appAmbiguous = !linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1)); const ambiguous = integrationAmbiguous || appAmbiguous; - const state: DockerReconciliationState = integrationMoved - ? "moved" - : linked - ? "linked" - : integration || app - ? "represented" - : match - ? "newRecognized" - : "newApp"; + const state = getReconciliationState({ + integrationMoved, + linked, + represented: Boolean(integration || app), + recognized: Boolean(match), + }); + const nextAction = getCandidateNextAction(state, integrationAmbiguous, appAmbiguous); return { candidateKey: container.resourceId, @@ -103,11 +101,7 @@ export const getDockerReconciliationAsync = async (db: Database) => { match, urlCandidates, state, - nextAction: integrationAmbiguous - ? ("reviewIntegration" as const) - : appAmbiguous - ? ("viewRepresentation" as const) - : nextActionForState(state), + nextAction, representation: { integration: integration ? { @@ -147,87 +141,100 @@ export const getDockerServiceHealthAsync = async (db: Database) => { status: endpoint.status, nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const), })), - services: reconciliation.candidates.map((candidate) => ({ - key: candidate.candidateKey, - identity: { - endpointId: candidate.endpointId, - nativeId: candidate.nativeId, - name: candidate.container.name, - integration: candidate.representation.integration, - app: candidate.representation.app, - }, - layers: [ - { layer: "docker" as const, status: "available" as const, nextAction: "none" as const }, - { - layer: "integrationConfiguration" as const, - status: integrationHealthStatus( - candidate.state, - candidate.match?.kind, - Boolean(candidate.representation.integration), - ), - nextAction: - candidate.nextAction === "reviewIntegration" - ? ("reviewIntegration" as const) - : candidate.state === "newRecognized" - ? ("setupIntegration" as const) - : ("none" as const), + services: reconciliation.candidates.map((candidate) => { + const integration = candidate.representation.integration; + const app = candidate.representation.app; + const itemCount = integration?.itemCount; + + return { + key: candidate.candidateKey, + identity: { + endpointId: candidate.endpointId, + nativeId: candidate.nativeId, + name: candidate.container.name, + integration, + app, }, - { - layer: "authentication" as const, - status: "notObserved" as const, - nextAction: candidate.representation.integration - ? ("testConnection" as const) - : candidate.nextAction === "reviewIntegration" - ? ("reviewIntegration" as const) - : candidate.match - ? ("setupIntegration" as const) - : ("none" as const), - }, - { - layer: "apiRequest" as const, - status: "notObserved" as const, - nextAction: candidate.representation.integration - ? ("openIntegrationDiagnostics" as const) - : ("none" as const), - }, - { - layer: "appRepresentation" as const, - status: candidate.representation.app ? ("linked" as const) : ("missing" as const), - nextAction: candidate.representation.app - ? ("none" as const) - : candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation" - ? ("viewRepresentation" as const) - : ("createApp" as const), - }, - { - layer: "widgetConfiguration" as const, - status: candidate.representation.integration - ? candidate.representation.integration.itemCount > 0 - ? ("linked" as const) - : ("unused" as const) - : ("notApplicable" as const), - nextAction: - candidate.representation.integration && candidate.representation.integration.itemCount === 0 - ? ("addWidget" as const) - : ("none" as const), - }, - { - layer: "widgetQuery" as const, - status: "notObserved" as const, - nextAction: - candidate.representation.integration && candidate.representation.integration.itemCount > 0 - ? ("openBoard" as const) - : ("none" as const), - }, - ], - status: candidate.state, - nextAction: candidate.nextAction, - })), + layers: [ + { layer: "docker" as const, status: "available" as const, nextAction: "none" as const }, + { + layer: "integrationConfiguration" as const, + status: integrationHealthStatus(candidate.state, candidate.match?.kind, Boolean(integration)), + nextAction: getIntegrationConfigurationNextAction(candidate.state, candidate.nextAction), + }, + { + layer: "authentication" as const, + status: "notObserved" as const, + nextAction: getAuthenticationNextAction( + Boolean(integration), + Boolean(candidate.match), + candidate.nextAction, + ), + }, + { + layer: "apiRequest" as const, + status: "notObserved" as const, + nextAction: integration ? ("openIntegrationDiagnostics" as const) : ("none" as const), + }, + { + layer: "appRepresentation" as const, + status: app ? ("linked" as const) : ("missing" as const), + nextAction: getAppRepresentationNextAction( + Boolean(app), + candidate.representation.signals.ambiguous, + candidate.nextAction, + ), + }, + { + layer: "widgetConfiguration" as const, + status: getWidgetConfigurationStatus(itemCount), + nextAction: itemCount === 0 ? ("addWidget" as const) : ("none" as const), + }, + { + layer: "widgetQuery" as const, + status: "notObserved" as const, + nextAction: itemCount !== undefined && itemCount > 0 ? ("openBoard" as const) : ("none" as const), + }, + ], + status: candidate.state, + nextAction: candidate.nextAction, + }; + }), }; }; const normalizeName = (value: string) => value.trim().toLowerCase(); +interface ReconciliationStateInput { + integrationMoved: boolean; + linked: boolean; + represented: boolean; + recognized: boolean; +} + +const getReconciliationState = ({ + integrationMoved, + linked, + represented, + recognized, +}: ReconciliationStateInput): DockerReconciliationState => { + if (integrationMoved) return "moved"; + if (linked) return "linked"; + if (represented) return "represented"; + if (recognized) return "newRecognized"; + return "newApp"; +}; + +const getCandidateNextAction = ( + state: DockerReconciliationState, + integrationAmbiguous: boolean, + appAmbiguous: boolean, +) => { + if (integrationAmbiguous) return "reviewIntegration" as const; + if (appAmbiguous) return "viewRepresentation" as const; + return nextActionForState(state); +}; + const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => { if (state === "newRecognized") return "setupIntegration"; if (state === "newApp") return "createApp"; @@ -235,6 +242,42 @@ const nextActionForState = (state: DockerReconciliationState): DockerReconciliat return "viewRepresentation"; }; +const getIntegrationConfigurationNextAction = ( + state: DockerReconciliationState, + nextAction: DockerReconciliationNextAction, +) => { + if (nextAction === "reviewIntegration") return "reviewIntegration" as const; + if (state === "newRecognized") return "setupIntegration" as const; + return "none" as const; +}; + +const getAuthenticationNextAction = ( + hasIntegration: boolean, + hasMatch: boolean, + nextAction: DockerReconciliationNextAction, +) => { + if (hasIntegration) return "testConnection" as const; + if (nextAction === "reviewIntegration") return "reviewIntegration" as const; + if (hasMatch) return "setupIntegration" as const; + return "none" as const; +}; + +const getAppRepresentationNextAction = ( + hasApp: boolean, + ambiguous: boolean, + nextAction: DockerReconciliationNextAction, +) => { + if (hasApp) return "none" as const; + if (ambiguous && nextAction === "viewRepresentation") return "viewRepresentation" as const; + return "createApp" as const; +}; + +const getWidgetConfigurationStatus = (itemCount: number | undefined) => { + if (itemCount === undefined) return "notApplicable" as const; + if (itemCount > 0) return "linked" as const; + return "unused" as const; +}; + const integrationHealthStatus = ( state: DockerReconciliationState, kind: IntegrationKind | undefined, @@ -242,5 +285,6 @@ const integrationHealthStatus = ( ) => { if (!kind) return "notApplicable" as const; if (state === "moved") return "changed" as const; - return hasIntegration ? ("configured" as const) : ("missing" as const); + if (hasIntegration) return "configured" as const; + return "missing" as const; }; diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index d9cc04695..504a8c068 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -14,6 +14,7 @@ import { dockerRouter } from "../../docker/docker-router"; const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn()); const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true)); const invalidateDockerCacheMock = vi.hoisted(() => vi.fn()); +const resetDockerSingletonMock = vi.hoisted(() => vi.fn()); const getDockerDataAsyncMock = vi.hoisted(() => vi.fn< () => Promise<{ @@ -62,7 +63,7 @@ vi.mock("@homarr/docker", () => ({ }, }, ], - reset: () => undefined, + reset: resetDockerSingletonMock, }, })); vi.mock("@homarr/request-handler/docker", () => ({ @@ -501,6 +502,7 @@ test("invalidates the shared Docker inventory on explicit refresh", async () => await createAdminCaller().refreshInventory(); expect(invalidateDockerCacheMock).toHaveBeenCalledOnce(); + expect(resetDockerSingletonMock).toHaveBeenCalledOnce(); }); test("projects persisted service layers without inventing runtime health", async () => { diff --git a/packages/api/src/router/widgets/immich.ts b/packages/api/src/router/widgets/immich.ts index 75966c793..835f96996 100644 --- a/packages/api/src/router/widgets/immich.ts +++ b/packages/api/src/router/widgets/immich.ts @@ -50,7 +50,10 @@ export const immichRouter = createTRPCRouter({ ) .concat(createImmichIntegrationMiddleware("query")) .query(async ({ ctx, input }) => { - if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit); + if (ctx.integration.kind === "mock") { + if (input.limit === undefined) return [...mockWidgetData.immichAlbums]; + return mockWidgetData.immichAlbums.slice(0, input.limit); + } const innerHandler = immichAlbumsRequestHandler.handler( { ...ctx.integration, kind: "immich" }, { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1aa483261..717c71832 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,7 +6,8 @@ import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness const harnessCommandNames = new Set(["harness", "local-harness"]); function isHarnessInvocation(argv: string[]): boolean { - return argv.slice(2).some((argument) => harnessCommandNames.has(argument)); + const command = argv[2]; + return command !== undefined && harnessCommandNames.has(command); } async function main(): Promise<void> { diff --git a/packages/widgets/src/common/query-state.spec.ts b/packages/widgets/src/common/query-state.spec.ts index c90c0ae50..63f2fd614 100644 --- a/packages/widgets/src/common/query-state.spec.ts +++ b/packages/widgets/src/common/query-state.spec.ts @@ -12,6 +12,11 @@ describe("getUsableWidgetQueryData", () => { expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data); }); + test("throws access-denied refetches instead of rendering stale cached data", () => { + const error = { data: { code: "FORBIDDEN" } }; + expect(() => getUsableWidgetQueryData({ data: [{ id: "revoked" }], error })).toThrow(error); + }); + test("throws a terminal failure when no usable data exists", () => { const error = new Error("offline"); expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error); diff --git a/packages/widgets/src/common/query-state.ts b/packages/widgets/src/common/query-state.ts index a83066c0b..77e8ccfd0 100644 --- a/packages/widgets/src/common/query-state.ts +++ b/packages/widgets/src/common/query-state.ts @@ -1,3 +1,5 @@ +import { isTrpcForbiddenError } from "@homarr/api/query-cache"; + interface WidgetQueryState { data: unknown; error: unknown; @@ -9,6 +11,7 @@ interface PendingWidgetQueryState extends WidgetQueryState { /** Throws terminal query failures while preserving successful empty values and stale cached data. */ export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => { + if (isTrpcForbiddenError(query.error)) throw query.error; if (query.error && query.data === undefined) throw query.error; return query.data; }; diff --git a/packages/widgets/src/iframe/component.spec.ts b/packages/widgets/src/iframe/component.spec.ts index a5f3b5835..8c7dd134d 100644 --- a/packages/widgets/src/iframe/component.spec.ts +++ b/packages/widgets/src/iframe/component.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getAllowedPermissions, getFrameTitle, getSandboxFlags, isSupportedProtocol } from "./component"; +import { getAllowedPermissions, getFrameTitle, getSandboxFlags } from "./component"; const permissions = { allowFullScreen: true, @@ -18,12 +18,6 @@ describe("iframe policy", () => { expect(getSandboxFlags(permissions)).toContain("allow-modals"); }); - it("accepts only HTTP protocols", () => { - expect(isSupportedProtocol("https://example.com")).toBe(true); - expect(isSupportedProtocol("javascript:alert(1)")).toBe(false); - expect(isSupportedProtocol("not a url")).toBe(false); - }); - it("creates a display title without credentials, paths, or query parameters", () => { expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com"); }); diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx index c66343927..3c311b88e 100644 --- a/packages/widgets/src/iframe/component.tsx +++ b/packages/widgets/src/iframe/component.tsx @@ -1,26 +1,21 @@ "use client"; import { Box, Stack, Text, Title } from "@mantine/core"; -import { IconBrowserOff, IconProtocol } from "@tabler/icons-react"; +import { IconBrowserOff } from "@tabler/icons-react"; import { objectEntries } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -import { getSafeApplicationUrl } from "../common/application-url"; import classes from "./component.module.css"; export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) { const t = useI18n("widget.iframe"); const { embedUrl, allowScrolling, ...permissions } = options; - const safeEmbedUrl = getSafeApplicationUrl(embedUrl); const allowedPermissions = getAllowedPermissions(permissions); const sandboxFlags = getSandboxFlags(permissions); if (embedUrl.trim() === "") return <NoUrl />; - if (!safeEmbedUrl) { - return <UnsupportedProtocol />; - } return ( <Stack h="100%" w="100%" gap={0}> @@ -29,8 +24,8 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro loading="lazy" style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined} className={classes.iframe} - src={safeEmbedUrl} - title={getFrameTitle(safeEmbedUrl)} + src={embedUrl} + title={getFrameTitle(embedUrl)} allow={allowedPermissions} scrolling={allowScrolling ? "yes" : "no"} sandbox={sandboxFlags.join(" ")} @@ -42,12 +37,6 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro ); } -const supportedProtocols = ["http", "https"]; - -export const isSupportedProtocol = (url: string) => { - return getSafeApplicationUrl(url) !== undefined; -}; - const NoUrl = () => { const t = useI18n("widget.iframe"); @@ -59,21 +48,6 @@ const NoUrl = () => { ); }; -const UnsupportedProtocol = () => { - const t = useI18n("widget.iframe"); - - return ( - <Stack align="center" justify="center" h="100%"> - <IconProtocol /> - <Title order={4} ta="center"> - {t("error.unsupportedProtocol", { - supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "), - })} - - - ); -}; - export const getAllowedPermissions = ( permissions: Omit["options"], "embedUrl" | "allowScrolling">, ) => { diff --git a/packages/widgets/src/iframe/index.ts b/packages/widgets/src/iframe/index.ts index 981ab180c..810a0b03b 100644 --- a/packages/widgets/src/iframe/index.ts +++ b/packages/widgets/src/iframe/index.ts @@ -1,7 +1,6 @@ import { IconBrowser, IconExternalLink } from "@tabler/icons-react"; import { createWidgetDefinition } from "../definition"; -import { getSafeApplicationUrl } from "../common/application-url"; import { optionsBuilder } from "../options"; const createOptions = () => @@ -23,17 +22,17 @@ export const { definition, componentLoader } = createWidgetDefinition("iframe", supportsAdvancedFocus: false, icon: IconBrowser, contextActions: ({ options }) => { - const embedUrl = getSafeApplicationUrl(options.embedUrl); - return embedUrl - ? [ - { - key: "open-iframe", - label: "widget.common.openInNewTab.label", - icon: IconExternalLink, - onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"), - }, - ] - : []; + const embedUrl = options.embedUrl; + if (typeof embedUrl !== "string" || embedUrl.trim() === "") return []; + + return [ + { + key: "open-iframe", + label: "widget.common.openInNewTab.label", + icon: IconExternalLink, + onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"), + }, + ]; }, createOptions, }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 80582e325..b493f92ad 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -68,10 +68,14 @@ export interface WidgetEditModalSize { height: number; } +export interface WidgetPreviewDimensions extends WidgetEditModalSize { + scale?: number; +} + interface WidgetEditPreviewResizeOptions { initialSize: WidgetEditModalSize; maximumSize: WidgetEditModalSize; - getDimensions?: (size: WidgetEditModalSize) => { width: number; height: number; scale?: number }; + getDimensions?: (size: WidgetEditModalSize) => WidgetPreviewDimensions; } export interface EmbeddedIntegrationEditFormHandle { @@ -99,7 +103,7 @@ export interface WidgetEditModalProps { onIntegrationSaved?: () => void; onOpenNewIntegration?: (onCreated?: (id: string) => void) => void; previewComponent?: ComponentType>; - previewDimensions?: { width: number; height: number; scale?: number }; + previewDimensions?: WidgetPreviewDimensions; previewResize?: WidgetEditPreviewResizeOptions; previewWrapper?: ComponentType; } @@ -111,7 +115,7 @@ interface WidgetEditPreviewProps { state: WidgetEditModalState; itemId?: string; boardId?: string; - dimensions?: { width: number; height: number; scale?: number }; + dimensions?: WidgetPreviewDimensions; integrationData: IntegrationSelectOption[]; onChangeOptions: (newOptions: Record) => void; resize?: { @@ -179,7 +183,9 @@ const WidgetEditPreview = ({ }, })); let componentItemId = itemId; - if (kind === "assistant") componentItemId = `widget-preview-${generatedPreviewId}`; + if (kind === "assistant" || kind === "timer") { + componentItemId = `widget-preview-${generatedPreviewId}`; + } const isPendingCustomWidget = kind === "customApi" && !itemId; const previewOpacity = (board?.opacity ?? 100) / 100; const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0d9d55c9..31ecb25f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ catalogs: '@tabler/icons-react': specifier: ^3.44.0 version: 3.44.0 + '@tanstack/query-sync-storage-persister': + specifier: 5.101.2 + version: 5.101.2 '@tanstack/react-devtools': specifier: ^0.10.8 version: 0.10.9 @@ -1133,6 +1136,9 @@ importers: '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) + '@tanstack/query-sync-storage-persister': + specifier: 'catalog:' + version: 5.101.2 '@tanstack/react-devtools': specifier: 'catalog:' version: 0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14) @@ -8846,6 +8852,9 @@ packages: '@tanstack/query-persist-client-core@5.101.2': resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==} + '@tanstack/query-sync-storage-persister@5.101.2': + resolution: {integrity: sha512-hRyLEJvkUWtfCUA3Lf5vD1CrXbHHK4pZNM/iNd7JM5GgC4CWzGIHkVkeg8zC2LV11KceKObKgjNuMSg3b6NOjA==} + '@tanstack/react-devtools@0.10.9': resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==} engines: {node: '>=18'} @@ -23948,6 +23957,11 @@ snapshots: dependencies: '@tanstack/query-core': 5.101.2 + '@tanstack/query-sync-storage-persister@5.101.2': + dependencies: + '@tanstack/query-core': 5.101.2 + '@tanstack/query-persist-client-core': 5.101.2 + '@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)': dependencies: '@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 952d1f3a8..443103fcc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -59,6 +59,7 @@ catalog: "@semantic-release/release-notes-generator": ^14.1.1 "@t3-oss/env-nextjs": ^0.13.11 "@tabler/icons-react": ^3.44.0 + "@tanstack/query-sync-storage-persister": 5.101.2 "@tanstack/react-devtools": ^0.10.8 "@tanstack/react-query": ^5.101.2 "@tanstack/react-query-devtools": ^5.101.2 diff --git a/tools/homarr-dev/internal/tui/actions.go b/tools/homarr-dev/internal/tui/actions.go index fb675383a..9cd79e1a5 100644 --- a/tools/homarr-dev/internal/tui/actions.go +++ b/tools/homarr-dev/internal/tui/actions.go @@ -76,7 +76,7 @@ func (m Model) instanceTarget(row instanceRow) target { result := target{ label: row.container.Name, container: row.container.Name, - volume: row.container.Name + "_data", + volume: row.volume, running: row.container.Running(), port: row.container.HostPort(), } @@ -315,6 +315,10 @@ func (m Model) deleteImage(reference string) (Model, tea.Cmd) { // away either way, and failing halfway would leave the user to work out why. func (m Model) deleteData(subject target) (Model, tea.Cmd) { container, volume := subject.container, subject.volume + if volume == "" { + m.status, m.statusLevel = subject.label+" has no data volume to delete", levelWarn + return m, nil + } m.tasks.Action("delete data "+volume, volume, func(ctx context.Context) error { _ = docker.RemoveContext(ctx, container) return docker.RemoveVolume(ctx, volume) diff --git a/tools/homarr-dev/internal/tui/manage.go b/tools/homarr-dev/internal/tui/manage.go index 0cc088ae7..ff2c171f0 100644 --- a/tools/homarr-dev/internal/tui/manage.go +++ b/tools/homarr-dev/internal/tui/manage.go @@ -65,6 +65,10 @@ func (m Model) handleManageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.arm(actionDeleteImage, subject) case key.Matches(msg, m.keys.ManageData): + if subject.volume == "" { + m.status, m.statusLevel = subject.label+" has no data volume to delete", levelWarn + return m, nil + } return m.arm(actionDeleteData, subject) case key.Matches(msg, m.keys.ManageStop): From 0553d60a58f8bbbcfb5c43018a44192418798638 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 27 Aug 2026 14:40:00 +0200 Subject: [PATCH 11/11] fix: prevent widget move registry crash (#6717) --- .../src/components/board/items/item-move-modal.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx index c0beae1fd..3c42b44b5 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client"; import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; import { getLayoutRowCount } from "../layout"; import { resolvePinnedGridCollisions } from "../sections/grid/dnd"; +import type { RegisteredGridEditor } from "../sections/grid/grid-editor-registry"; import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry"; import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements"; import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions"; @@ -30,6 +31,7 @@ interface InnerProps extends OpenItemMoveModalInput { board: Board; currentLayoutId: string; commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void; + registeredGridEditors: ReadonlyMap>; } interface MoveTarget { @@ -57,8 +59,7 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = const tLandmark = useI18n("board.landmark"); const tContainer = useI18n("section.container"); const tMoveResize = useI18n("item.moveResize"); - const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps; - const registeredGridEditors = useRegisteredGridEditors(); + const { board, commitSectionGrids, currentLayoutId, entry, registeredGridEditors, sourceSectionId } = innerProps; const minimumSize = useMemo( () => getEntryMinimumSize(board, currentLayoutId, entry), [board, currentLayoutId, entry], @@ -235,6 +236,7 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = export const useOpenItemMoveModal = () => { const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); + const registeredGridEditors = useRegisteredGridEditors(); const { commitSectionGrids } = useGridLayoutActions(); const { openModal } = useModalAction(ItemMoveModal); @@ -245,9 +247,10 @@ export const useOpenItemMoveModal = () => { board, currentLayoutId, commitSectionGrids, + registeredGridEditors, }); }, - [board, commitSectionGrids, currentLayoutId, openModal], + [board, commitSectionGrids, currentLayoutId, openModal, registeredGridEditors], ); };