From 78ddc81b15e293b54e7c689b8b8cae0a94a148dc Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 7 Jun 2026 02:04:57 +0200 Subject: [PATCH] feat(board): add widget hover overlay component --- .../board/items/widget-hover-metadata.ts | 158 ++++++++++++++++++ .../items/widget-hover-overlay.module.css | 110 ++++++++++++ .../board/items/widget-hover-overlay.tsx | 86 ++++++++++ 3 files changed, 354 insertions(+) create mode 100644 apps/nextjs/src/components/board/items/widget-hover-metadata.ts create mode 100644 apps/nextjs/src/components/board/items/widget-hover-overlay.module.css create mode 100644 apps/nextjs/src/components/board/items/widget-hover-overlay.tsx diff --git a/apps/nextjs/src/components/board/items/widget-hover-metadata.ts b/apps/nextjs/src/components/board/items/widget-hover-metadata.ts new file mode 100644 index 000000000..0e913f5d4 --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-hover-metadata.ts @@ -0,0 +1,158 @@ +import { objectEntries } from "@homarr/common"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import type { SettingsContextProps } from "@homarr/settings/creator"; +import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets"; +import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; + +import type { SectionItem } from "~/app/[locale]/boards/_types"; + +export type Translator = { + (key: string): string; +}; + +export interface HoverMetadataItem { + label: string; + value: string; +} + +const maxMetadataItems = 3; + +const skippedOptionTypes: Partial> = { + app: true, + anchorNote: true, + dynamicSelect: true, + sortableItemList: true, + multiReleasesRepositories: true, + umamiEventName: true, + umamiEventNames: true, + umamiWebsite: true, +}; + +const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right); + +type OptionDefinitionWithRules = WidgetOptionDefinition & { + shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean; + options?: readonly { value: unknown; label: unknown }[]; +}; + +type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null; + +const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => { + const match = definition.options?.find((option) => option.value === value); + const label = match?.label; + const fallbackLabel = () => String(value); + const labelResolvers: Record string> = { + function: () => (label as (translator: Translator) => string)(t), + string: () => label as string, + missing: fallbackLabel, + }; + const labelType = typeof label; + const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel; + return resolveLabel(); +}; + +const optionTypeFormatters: Partial> = { + switch: (value, _, t) => { + const switchLabels: Record = { + true: t("common.action.show"), + false: t("common.action.hide"), + }; + return switchLabels[String(Boolean(value))] ?? null; + }, + text: (value) => { + const text = String(value).trim(); + return text.length > 0 ? text : null; + }, + number: (value) => String(value), + slider: (value) => String(value), + select: (value, definition, t) => resolveSelectLabel(value, definition, t), + multiSelect: (value) => { + const items = value as unknown[]; + return items.length > 0 ? String(items.length) : null; + }, + multiText: (value) => { + const items = (value as string[]).filter((entry) => entry.trim().length > 0); + return items.length > 0 ? items.join(", ") : null; + }, + location: (value) => { + const location = value as { name?: string }; + const name = location.name?.trim() ?? ""; + return name.length > 0 ? name : null; + }, +}; + +const formatOptionValue = ( + type: WidgetOptionType, + value: unknown, + definition: OptionDefinitionWithRules, + t: Translator, +) => { + const formatter = optionTypeFormatters[type]; + return formatter?.(value, definition, t) ?? null; +}; + +const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => { + const labelKey = `widget.${kind}.option.${key}.label`; + const translated = t(labelKey); + return translated === labelKey ? key : translated; +}; + +export const collectWidgetHoverMetadata = ( + item: SectionItem, + settings: Pick, + integrationKinds: IntegrationKind[], + t: Translator, +): HoverMetadataItem[] => { + const { definition } = widgetImports[item.kind]; + const optionDefinitions = definition.createOptions(settings) as Record; + const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); + + return objectEntries(optionDefinitions) + .filter(([key, optionDefinition]) => { + const skip = skippedOptionTypes[optionDefinition.type]; + const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds); + const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue); + return !skip && !hidden && !unchanged; + }) + .map(([key, optionDefinition]) => { + const formattedValue = formatOptionValue( + optionDefinition.type, + resolvedOptions[key], + optionDefinition, + t, + ); + return { + label: getOptionLabel(item.kind, key, t), + value: formattedValue ?? "", + }; + }) + .filter((entry) => entry.value.length > 0) + .slice(0, maxMetadataItems); +}; + +interface DisplayNameSource { + matches: (item: SectionItem, options: Record) => boolean; + resolve: (item: SectionItem, options: Record) => string; +} + +const displayNameSources: DisplayNameSource[] = [ + { + matches: (item) => Boolean(item.advancedOptions.title?.trim()), + resolve: (item) => item.advancedOptions.title?.trim() ?? "", + }, + { + matches: (item, options) => + item.kind === "clock" && Boolean(options.customTitleToggle) && String(options.customTitle ?? "").trim().length > 0, + resolve: (_, options) => String(options.customTitle).trim(), + }, +]; + +export const resolveWidgetDisplayName = ( + item: SectionItem, + settings: Pick, + t: Translator, +) => { + const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); + const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions)); + return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`); +}; diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css new file mode 100644 index 000000000..0be917214 --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css @@ -0,0 +1,110 @@ +.wrapper { + position: absolute; + left: 10px; + right: 10px; + bottom: calc(100% - 10px); + margin-bottom: 1px; + z-index: 200; + pointer-events: auto; + opacity: 0; + transform: translateY(6px); + transition: + opacity 150ms ease, + transform 150ms ease; +} + +:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper, +:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper { + opacity: 1; + transform: translateY(0); +} + +.panel { + @mixin dark { + --background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96); + --border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9); + } + @mixin light { + --background-color: rgb(from var(--mantine-color-white) r g b / 0.96); + --border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9); + } + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: 0 0 var(--mantine-radius-md) var(--mantine-radius-md); + background-color: var(--background-color); + box-shadow: 0 4px 16px rgb(0 0 0 / 0.12); + backdrop-filter: blur(8px); + overflow: hidden; +} + +.nameSection { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-shrink: 0; +} + +.widgetIcon { + flex-shrink: 0; + color: var(--mantine-color-dimmed); +} + +.name { + font-size: 11px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.integrations { + flex-shrink: 0; +} + +.integrationAvatar { + border: none; +} + +.metadata { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + margin-left: auto; +} + +.metadataItem { + @mixin dark { + --chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9); + } + @mixin light { + --chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95); + } + display: flex; + align-items: center; + gap: 4px; + max-width: 140px; + padding: 2px 6px; + border-radius: var(--mantine-radius-md); + background-color: var(--chip-background); + font-size: 10px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; +} + +.metadataLabel { + color: var(--mantine-color-dimmed); + flex-shrink: 0; +} + +.metadataValue { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx new file mode 100644 index 000000000..6ea3ac572 --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Avatar, Group, Text, Tooltip } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useEditMode } from "@homarr/boards/edit-mode"; +import { getIconUrl } from "@homarr/definitions"; +import { useSettings } from "@homarr/settings"; +import { useI18n } from "@homarr/translation/client"; +import { widgetImports } from "@homarr/widgets"; + +import type { SectionItem } from "~/app/[locale]/boards/_types"; +import type { Translator } from "./widget-hover-metadata"; +import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata"; +import classes from "./widget-hover-overlay.module.css"; + +interface WidgetHoverOverlayProps { + item: SectionItem; +} + +export const WidgetHoverOverlay = ({ item }: WidgetHoverOverlayProps) => { + const [isEditMode] = useEditMode(); + const settings = useSettings(); + const t = useI18n(); + const { data: integrations } = clientApi.integration.all.useQuery(); + const { definition } = widgetImports[item.kind]; + const WidgetIcon = definition.icon; + + const translate = ((key: string) => t(key as never)) as Translator; + const displayName = resolveWidgetDisplayName(item, settings, translate); + + const connectedIntegrations = (integrations ?? []).filter((integration) => + item.integrationIds.includes(integration.id), + ); + const metadataItems = collectWidgetHoverMetadata( + item, + settings, + connectedIntegrations.map((integration) => integration.kind), + translate, + ); + const hasIntegrations = connectedIntegrations.length > 0; + const hasMetadata = metadataItems.length > 0; + + if (isEditMode) { + return null; + } + + return ( +
+
+
+ + + {displayName} + +
+ + {hasIntegrations && ( + + {connectedIntegrations.map((integration) => ( + + + + ))} + + )} + + {hasMetadata && ( + + {metadataItems.map((metadataItem) => ( +
+ {metadataItem.label} + {metadataItem.value} +
+ ))} +
+ )} +
+
+ ); +};