From aecbca2b81ad1693c599e3168fd0c0723311b3b4 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 10 Jun 2026 18:58:14 +0200 Subject: [PATCH] feat: custom widgets (#5889) --- .../manage/custom-widgets/_beta-banner.tsx | 19 + .../custom-widgets/_copy-ai-prompt-button.tsx | 343 +++ .../custom-widgets/_custom-widget-actions.tsx | 213 ++ .../custom-widgets/_custom-widget-form.tsx | 1293 +++++++++ .../custom-widgets/_custom-widget-list.tsx | 101 + .../custom-widgets/_custom-widget-preview.tsx | 178 ++ .../manage/custom-widgets/edit/[id]/page.tsx | 190 ++ .../manage/custom-widgets/new/page.tsx | 36 + .../[locale]/manage/custom-widgets/page.tsx | 42 + .../nextjs/src/app/[locale]/manage/layout.tsx | 7 + .../board/items/item-select-modal.tsx | 75 +- e2e/custom-widgets-custom-jsx.spec.ts | 82 + e2e/shared/mock-api-server.ts | 35 + e2e/shared/seed-admin-user.ts | 59 + package.json | 3 + packages/api/package.json | 2 + packages/api/src/root.ts | 1 + packages/api/src/router/board.ts | 6 +- packages/api/src/router/custom-widget/auth.ts | 41 + .../custom-widget/custom-widget-router.ts | 456 +++ .../src/router/custom-widget/display-data.ts | 145 + .../custom-widget/parse-display-config.ts | 15 + .../__tests__/custom-api-extractors.spec.ts | 252 ++ .../widgets/__tests__/custom-api-fixtures.ts | 142 + packages/api/src/router/widgets/custom-api.ts | 91 + packages/api/src/router/widgets/index.ts | 1 + .../mysql/0040_wealthy_thunderbolts.sql | 29 + .../mysql/0041_merge_custom_widget_url.sql | 6 + .../migrations/mysql/meta/0038_snapshot.json | 506 +++- .../migrations/mysql/meta/0040_snapshot.json | 2582 +++++++++++++++++ .../db/migrations/mysql/meta/_journal.json | 16 +- .../postgresql/0006_last_callisto.sql | 28 + .../0007_merge_custom_widget_url.sql | 6 + .../postgresql/meta/0006_snapshot.json | 2412 +++++++++++++++ .../migrations/postgresql/meta/_journal.json | 16 +- packages/db/migrations/seed.ts | 68 +- .../sqlite/0038_crazy_hobgoblin.sql | 27 + .../sqlite/0039_merge_custom_widget_url.sql | 23 + .../migrations/sqlite/meta/0038_snapshot.json | 2454 ++++++++++++++++ .../db/migrations/sqlite/meta/_journal.json | 16 +- packages/db/package.json | 1 + packages/db/schema/index.ts | 4 + packages/db/schema/mysql.ts | 58 + packages/db/schema/postgresql.ts | 58 + packages/db/schema/sqlite.ts | 62 + .../custom-widgets/currency-exchange.json | 18 + .../db/seed/custom-widgets/dog-facts.json | 17 + packages/db/seed/custom-widgets/jellyfin.json | 23 + packages/db/seed/custom-widgets/pokedex.json | 14 + packages/definitions/src/widget.ts | 1 + packages/translation/src/lang/en.json | 238 ++ packages/validation/src/custom-widget.ts | 471 +++ packages/widgets/package.json | 5 + packages/widgets/src/_inputs/index.ts | 2 + .../src/_inputs/json-path-tree-picker.tsx | 386 +++ .../widget-custom-widget-select-input.tsx | 91 + ...widget-multiReleasesRepositories-input.tsx | 6 +- packages/widgets/src/custom-api/component.tsx | 493 ++++ .../src/custom-api/custom-jsx-display.spec.ts | 178 ++ .../src/custom-api/custom-jsx-display.tsx | 68 + .../src/custom-api/extract-display-data.ts | 1 + packages/widgets/src/custom-api/index.ts | 20 + .../custom-api/jsx-interactive-components.tsx | 224 ++ .../widgets/src/custom-api/jsx-whitelist.ts | 192 ++ .../cluster/resource-table.tsx | 23 +- packages/widgets/src/index.tsx | 2 + packages/widgets/src/options.ts | 5 + pnpm-lock.yaml | 81 +- pnpm-workspace.yaml | 3 + 69 files changed, 14620 insertions(+), 142 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_beta-banner.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx create mode 100644 e2e/custom-widgets-custom-jsx.spec.ts create mode 100644 e2e/shared/mock-api-server.ts create mode 100644 e2e/shared/seed-admin-user.ts create mode 100644 packages/api/src/router/custom-widget/auth.ts create mode 100644 packages/api/src/router/custom-widget/custom-widget-router.ts create mode 100644 packages/api/src/router/custom-widget/display-data.ts create mode 100644 packages/api/src/router/custom-widget/parse-display-config.ts create mode 100644 packages/api/src/router/widgets/__tests__/custom-api-extractors.spec.ts create mode 100644 packages/api/src/router/widgets/__tests__/custom-api-fixtures.ts create mode 100644 packages/api/src/router/widgets/custom-api.ts create mode 100644 packages/db/migrations/mysql/0040_wealthy_thunderbolts.sql create mode 100644 packages/db/migrations/mysql/0041_merge_custom_widget_url.sql create mode 100644 packages/db/migrations/mysql/meta/0040_snapshot.json create mode 100644 packages/db/migrations/postgresql/0006_last_callisto.sql create mode 100644 packages/db/migrations/postgresql/0007_merge_custom_widget_url.sql create mode 100644 packages/db/migrations/postgresql/meta/0006_snapshot.json create mode 100644 packages/db/migrations/sqlite/0038_crazy_hobgoblin.sql create mode 100644 packages/db/migrations/sqlite/0039_merge_custom_widget_url.sql create mode 100644 packages/db/migrations/sqlite/meta/0038_snapshot.json create mode 100644 packages/db/seed/custom-widgets/currency-exchange.json create mode 100644 packages/db/seed/custom-widgets/dog-facts.json create mode 100644 packages/db/seed/custom-widgets/jellyfin.json create mode 100644 packages/db/seed/custom-widgets/pokedex.json create mode 100644 packages/validation/src/custom-widget.ts create mode 100644 packages/widgets/src/_inputs/json-path-tree-picker.tsx create mode 100644 packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx create mode 100644 packages/widgets/src/custom-api/component.tsx create mode 100644 packages/widgets/src/custom-api/custom-jsx-display.spec.ts create mode 100644 packages/widgets/src/custom-api/custom-jsx-display.tsx create mode 100644 packages/widgets/src/custom-api/extract-display-data.ts create mode 100644 packages/widgets/src/custom-api/index.ts create mode 100644 packages/widgets/src/custom-api/jsx-interactive-components.tsx create mode 100644 packages/widgets/src/custom-api/jsx-whitelist.ts diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_beta-banner.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_beta-banner.tsx new file mode 100644 index 000000000..b0a3a73e6 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_beta-banner.tsx @@ -0,0 +1,19 @@ +import { Alert, Anchor, Text } from "@mantine/core"; +import { IconFlask } from "@tabler/icons-react"; + +const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/5916"; + +export function CustomWidgetBetaBanner() { + return ( + }> + + This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did + more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "} + + official discussion thread + {" "} + will be taken into account. Happy Hacking! + + + ); +} 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 new file mode 100644 index 000000000..d78aafbab --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx @@ -0,0 +1,343 @@ +"use client"; + +import { Button, Popover, Stack, Text } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconCopy, IconSparkles } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; + +const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget. + +## Your Task +Generate a valid JSON configuration for a Homarr custom widget based on the user's description and the API response below. If you need clarification, ask specific questions. + +## Output Format +- Output the JSON inside a \`\`\`json code block for syntax highlighting. If \`\`\`json is not supported, use a generic \`\`\` code block instead. +- Do NOT include any text before or after the JSON block unless you have clarifying questions. +- If your environment supports structured/JSON output mode, use it. + +## JSON Schema +The output must conform to this JSON Schema: + +`; + +const PROMPT_RULES = ` + +## Key Rules +- The \`displayConfig.type\` field MUST match the \`displayType\` field exactly +- All \`jsonPath\` fields use JSONPath syntax (e.g. \`$.data.count\`, \`$.items[*].name\`) +- The \`$schema\` field should be \`"homarr-custom-widget-v2"\` +- Do NOT include secrets/passwords in the output — those are configured separately in the UI +- \`url\` must be a full URL including protocol (e.g. \`https://...\`) +- For arrays of items, inspect the API response to find the correct paths + +## Display Type Guide +- \`singleValue\`: Show one prominent number/text (e.g. total count, status) +- \`keyValue\`: Show labeled pairs like "CPU: 45%" — use \`mappings\` array +- \`table\`: Show tabular data from an array — needs \`tablePath\` for the array and \`columns\` for headers +- \`statGrid\`: Grid of stat cards with optional colors — great for dashboards (e.g. "Movies: 38, Series: 63") +- \`progressBars\`: Visual progress bars with value/max — for storage, quotas, etc. +- \`statusIndicator\`: Green/red dots based on value matching — for service health checks +- \`countGrid\`: Simple grid of counts — like statGrid but simpler, no colors +- \`raw\`: Raw JSON display — for debugging or complex data +- \`actionButton\`: A button that triggers the API call on click — for POST/PUT actions +- \`customJsx\`: Custom JSX layout using whitelisted Mantine components — full creative control over presentation. Set \`displayConfig.template\` to a JSX string. Access API data via \`{data.fieldName}\` bindings (e.g. \`{data.name}\`, \`{data.items[0].title}\`). + +## Custom JSX (\`customJsx\`) — Available Mantine Components +When using \`customJsx\`, the template supports whitelisted Mantine UI components. These are React components from the Mantine v9 library. Use their props as documented below. + +### Layout Components +- **Stack** — Vertical flex container. Props: \`gap\` (xs|sm|md|lg|xl or number), \`align\` (stretch|center|flex-start|flex-end), \`justify\` +- **Group** — Horizontal flex container. Props: \`gap\`, \`justify\` (flex-start|center|space-between|space-around|flex-end), \`wrap\` (wrap|nowrap), \`grow\` (boolean) +- **Flex** — Generic flex container. Props: \`direction\` (row|column), \`gap\`, \`align\`, \`justify\`, \`wrap\` +- **Grid** + **Grid.Col** — CSS grid layout. Grid props: \`gutter\` (gap). Grid.Col props: \`span\` (1-12 or "auto"), \`offset\` +- **SimpleGrid** — Auto-column grid. Props: \`cols\` (number), \`spacing\`, \`verticalSpacing\` +- **Center** — Centers content. Props: \`inline\` (boolean) +- **Space** — Empty spacing. Props: \`h\` (height), \`w\` (width) +- **Container** — Max-width wrapper. Props: \`size\` (xs|sm|md|lg|xl), \`fluid\` (boolean) +- **AspectRatio** — Maintains aspect ratio. Props: \`ratio\` (number, e.g. 16/9) + +### Typography Components +- **Text** — Body text. Props: \`size\` (xs|sm|md|lg|xl), \`fw\` (font-weight number, e.g. 700), \`c\` (color: "dimmed"|"red"|"blue"|...), \`ta\` (text-align), \`tt\` (text-transform: "capitalize"|"uppercase"|"lowercase"), \`td\` (text-decoration), \`lineClamp\` (number), \`truncate\` (boolean), \`span\` (renders as span) +- **Title** — Heading. Props: \`order\` (1-6, maps to h1-h6), \`size\`, \`c\`, \`ta\`, \`tt\` +- **Code** — Inline code. Props: \`color\`, \`block\` (boolean for code block) +- **Highlight** — Text with highlighted substring. Props: \`highlight\` (string or string[]) +- **Mark** — Highlighted text background. Props: \`color\` +- **Kbd** — Keyboard key display +- **Blockquote** — Styled quote. Props: \`color\`, \`cite\`, \`icon\` +- **Anchor** — Link. Props: \`href\`, \`target\`, \`underline\` ("always"|"hover"|"never") +- **NumberFormatter** — Formatted number display. Props: \`value\` (number), \`prefix\` (e.g. "$"), \`suffix\` (e.g. "%"), \`thousandSeparator\` (boolean or string), \`decimalScale\` (number) + +### Data Display Components +- **Badge** — Label badge. Props: \`color\`, \`variant\` (filled|light|outline|dot|default), \`size\` (xs|sm|md|lg|xl), \`radius\`, \`tt\` +- **Card** + **Card.Section** — Content card. Card props: \`shadow\` (xs|sm|md|lg|xl), \`padding\`/\`p\`, \`radius\`, \`withBorder\` (boolean). Card.Section props: \`withBorder\`, \`inheritPadding\` +- **Paper** — Surface container. Props: \`shadow\`, \`p\`, \`radius\`, \`withBorder\` +- **Alert** — Notification box. Props: \`color\`, \`variant\` (filled|light|outline), \`title\`, \`icon\` +- **ThemeIcon** — Icon container. Props: \`color\`, \`variant\`, \`size\`, \`radius\` +- **ColorSwatch** — Color preview. Props: \`color\` (CSS color string) +- **Table** + **Table.Thead**, **Table.Tbody**, **Table.Tr**, **Table.Th**, **Table.Td** — HTML table. Table props: \`striped\` (boolean), \`withTableBorder\`, \`withColumnBorders\`, \`highlightOnHover\` +- **List** + **List.Item** — Ordered/unordered list. List props: \`type\` ("ordered"|"unordered"), \`icon\`, \`spacing\` +- **Timeline** + **Timeline.Item** — Vertical timeline. Timeline props: \`active\` (number), \`bulletSize\`. Item props: \`title\`, \`bullet\` +- **Accordion** + **.Item** + **.Control** + **.Panel** — Collapsible sections. Item props: \`value\` (string). Control: clickable header. Panel: content. +- **Indicator** — Dot indicator on content. Props: \`color\`, \`position\`, \`size\`, \`label\`, \`processing\` (boolean) +- **Pill** — Removable tag. Props: \`size\`, \`withRemoveButton\` +- **Spoiler** — Show more/less. Props: \`maxHeight\` (number), \`showLabel\`, \`hideLabel\` + +### Feedback Components +- **Progress** + **Progress.Section** — Progress bar. Progress props: \`value\` (0-100), \`color\`, \`size\`, \`radius\`, \`striped\`, \`animated\`. Section props: \`value\`, \`color\` +- **RingProgress** — Circular progress. Props: \`sections\` (array of {value, color}), \`size\`, \`thickness\`, \`label\` (ReactNode) +- **Skeleton** — Loading placeholder. Props: \`height\`, \`width\`, \`radius\`, \`visible\` (boolean) +- **Loader** — Spinner. Props: \`size\`, \`color\`, \`type\` (oval|bars|dots) + +### Media Components +- **Image** — Responsive image. Props: \`src\`, \`alt\`, \`w\` (width), \`h\` (height), \`radius\`, \`fit\` (cover|contain|fill) +- **Avatar** + **Avatar.Group** — User avatar. Props: \`src\`, \`alt\`, \`size\` (xs|sm|md|lg|xl or number), \`radius\` (sm|xl), \`color\` +- **BackgroundImage** — Background image container. Props: \`src\`, \`radius\` +- **Tooltip** — Hover tooltip. Props: \`label\` (string), \`position\` (top|bottom|left|right), \`withArrow\` +- **Divider** — Horizontal/vertical rule. Props: \`orientation\` (horizontal|vertical), \`label\`, \`labelPosition\`, \`size\` +- **ScrollArea** — Scrollable container. Props: \`h\` (height), \`type\` (auto|always|scroll|hover) + +### Charts (@mantine/charts) +- **AreaChart**, **BarChart**, **LineChart** — Props: \`data\` (array of objects), \`dataKey\` (x-axis key), \`series\` (array of {name, color}), \`h\` (height), \`curveType\` (for Area/Line) +- **DonutChart**, **PieChart** — Props: \`data\` (array of {name, value, color}), \`h\`, \`withLabels\`, \`withLabelsLine\` +- **RadarChart** — Props: \`data\`, \`dataKey\`, \`series\` +- **RadialBarChart** — Props: \`data\` (array of {name, value, color}), \`h\` +- **Sparkline** — Props: \`data\` (flat number array), \`h\`, \`w\`, \`color\`, \`curveType\` + +### Common Prop Patterns +- **Spacing/sizing:** Most components accept \`p\` (padding), \`m\` (margin), \`px\`/\`py\`/\`mx\`/\`my\`/\`pt\`/\`pb\`/\`pl\`/\`pr\`/\`mt\`/\`mb\`/\`ml\`/\`mr\` with values xs|sm|md|lg|xl or numbers +- **Colors:** Use Mantine color names: "red", "blue", "green", "orange", "violet", "pink", "cyan", "grape", "teal", "yellow", "lime", "indigo", "gray", "dark". Add shade suffix: "red.6", "blue.4" +- **Styles:** Use \`style={{...}}\` for inline CSS. E.g. \`style={{flex: 1, minWidth: 0}}\` +- **Conditional rendering:** Use ternaries: \`{condition ? Yes : No}\` +- **Responsive:** Most size/spacing props accept object: \`{{ base: "sm", md: "lg" }}\` + +### Interactive Components (built-in, state-managed) +These components manage their own internal state — they provide interactivity without event handlers: + +**PaginatedList** — Paginates its children with prev/next buttons. +Props: \`pageSize\` (number, default 6) +Usage: Wrap a \`.map()\` expression to paginate results. +\`\`\`jsx + + {data.results.map(item => + + {item.name} + + )} + +\`\`\` + +**TabsContainer + TabPanel** — Tabbed interface with automatic tab switching. +TabsContainer props: \`defaultTab\` (string, optional — defaults to first tab) +TabPanel props: \`value\` (string, required — unique tab ID), \`label\` (string, optional — tab display text) +\`\`\`jsx + + + {data.description} + + + Total: {data.count} + + +\`\`\` + +**Collapsible** — Expandable/collapsible section with a title. +Props: \`title\` (string, required), \`defaultOpen\` (boolean, default false) +\`\`\`jsx + + {data.details} + +\`\`\` + +**StatBar** — A horizontal stat bar with label/value (great for RPG-style stats, progress). +Props: \`value\` (number), \`max\` (number, default 100), \`label\` (string), \`color\` (Mantine color) +\`\`\`jsx + + +\`\`\` + +**TypeBadge** — A colored badge that maps common type names to colors (normal, fire, water, electric, grass, ice, fighting, poison, ground, flying, psychic, bug, rock, ghost, dragon, dark, steel, fairy). +Props: \`type\` (string), \`size\` (xs|sm|md|lg|xl, default sm) +\`\`\`jsx + + + + +\`\`\` + +**Available bindings in templates:** +- \`data\` — the full API response JSON object +- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\` — type coercion helpers +- \`Math.round\`, \`Math.floor\`, \`Math.ceil\`, \`Math.abs\`, \`Math.min\`, \`Math.max\`, \`Math.pow\`, \`Math.sqrt\`, \`Math.PI\` +- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Object.keys(v)\`, \`Object.values(v)\`, \`Object.entries(v)\` +- Expression arrows for \`.map()\`, \`.filter()\`, \`.slice()\` — e.g. \`{data.items.map(item => {item.name})}\` +- Ternaries for conditionals — e.g. \`{data.count > 0 ? "active" : "idle"}\` + +**FORBIDDEN keywords (template will be rejected):** constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch + +**Chart data formats:** +- BarChart/LineChart/AreaChart: \`data={data.items}\` + \`dataKey="month"\` (x-axis key) + \`series={[{ name: "count", color: "blue" }]}\` where series \`name\` matches a numeric key in each data row +- DonutChart/PieChart: \`data={data.items}\` where each item is \`{ name: "Label", value: 42, color: "blue" }\` — NO dataKey or series +- Sparkline: \`data={[1,2,3]}\` — flat number array +- NumberFormatter: \`\` + +### Full Pokémon Card Example (demonstrating TypeBadge, StatBar, TabsContainer, Collapsible) +API: \`https://pokeapi.co/api/v2/pokemon/charizard\` +\`\`\`jsx + + + + + {data.name} + #{String(data.id).padStart(3, "0")} + + {data.types.map(t => )} + + + + + + + {data.stats.map(s => + 100 ? "green" : "red"} /> + )} + + + + + {data.abilities.map(a => + + {a.ability.name}{a.is_hidden ? " (hidden)" : ""} + + )} + + + + + + {data.moves.slice(0, 20).map(m => + {m.move.name} + )} + + + + + +\`\`\` + +### Paginated List Example +API: \`https://pokeapi.co/api/v2/pokemon?limit=50\` +\`\`\`jsx + + + Pokédex + {data.count} Pokémon + + + {data.results.map((pokemon, i) => + + + + + {pokemon.name} + #{String(i + 1)} + + + + )} + + +\`\`\` + +## API Response +`; + +const PROMPT_NO_RESPONSE = `Paste the raw JSON response from your API endpoint below: + +\`\`\`json +PASTE_YOUR_API_RESPONSE_HERE +\`\`\` +`; + +const PROMPT_FOOTER = ` +## Your Request +Describe what you want the widget to show: + +`; + +function buildAiPrompt( + jsonSchema: unknown, + rawResponse?: string | null, + currentConfig?: Record | null, +) { + const schemaStr = JSON.stringify(jsonSchema, null, 2); + const responseSection = rawResponse + ? `The API returned the following JSON:\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n` + : PROMPT_NO_RESPONSE; + + const configSection = currentConfig + ? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n` + : ""; + + return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER; +} + +interface CopyAiPromptButtonProps { + rawResponse?: string | null; + currentConfig?: Record | null; +} + +export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => { + const t = useScopedI18n("customWidget"); + const [opened, { open, close }] = useDisclosure(false); + const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery(); + + const handleCopy = async () => { + if (!schema) return; + const prompt = buildAiPrompt(schema, rawResponse, currentConfig); + try { + await navigator.clipboard.writeText(prompt); + close(); + showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") }); + } catch { + showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") }); + } + }; + + return ( + + + + + + + {t("notification.aiPromptDescription")} + {!rawResponse && ( + + {t("notification.aiPromptNoResponse")} + + )} + + + + + ); +}; 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 new file mode 100644 index 000000000..ec8ba59c9 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useRef } from "react"; +import { ActionIcon, Menu } from "@mantine/core"; +import { + IconCopy, + IconDots, + IconDownload, + IconToggleLeft, + IconToggleRight, + IconTrash, + IconUpload, +} 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 { useScopedI18n } from "@homarr/translation/client"; + +import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; + +const iconProps = { size: 16, stroke: 1.5 }; + +interface WidgetRef { + id: string; + name: string; + enabled: boolean; +} + +export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { + const t = useScopedI18n("customWidget"); + const { openConfirmModal } = useConfirmModal(); + const deleteMutation = clientApi.customWidget.delete.useMutation(); + const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); + const toggleEnabledMutation = clientApi.customWidget.toggleEnabled.useMutation(); + const utils = clientApi.useUtils(); + + const handleToggleEnabled = () => { + toggleEnabledMutation.mutate( + { id: widget.id, enabled: !widget.enabled }, + { + onSuccess: () => { + void utils.customWidget.all.invalidate(); + void utils.widget.customApi.getData.invalidate({ definitionId: widget.id }); + void revalidatePathActionAsync("/manage/custom-widgets"); + }, + onError: () => { + showErrorNotification({ + title: widget.enabled ? t("action.disable") : t("action.enable"), + message: t("notification.toggleError"), + }); + }, + }, + ); + }; + + const handleExport = async () => { + try { + const data = await utils.customWidget.export.fetch({ id: widget.id }); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${widget.name}.json`; + a.click(); + URL.revokeObjectURL(url); + } catch { + showErrorNotification({ title: t("action.export"), message: t("notification.exportError") }); + } + }; + + const handleDuplicate = () => { + duplicateMutation.mutate( + { id: widget.id }, + { + onSuccess: (result) => { + showSuccessNotification({ + title: t("action.duplicate"), + message: t("notification.duplicated", { name: result.name }), + }); + void utils.customWidget.all.invalidate(); + void revalidatePathActionAsync("/manage/custom-widgets"); + }, + onError: () => { + showErrorNotification({ title: t("action.duplicate"), message: t("notification.duplicateError") }); + }, + }, + ); + }; + + 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.all.invalidate(); + void utils.widget.customApi.getData.invalidate({ definitionId: widget.id }); + void revalidatePathActionAsync("/manage/custom-widgets"); + }, + onError: () => { + showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); + }, + }, + ); + }, + }); + }; + + return ( + + + + + + + + : } + disabled={toggleEnabledMutation.isPending} + > + {widget.enabled ? t("action.disable") : t("action.enable")} + + + } + disabled={duplicateMutation.isPending} + > + {t("action.duplicate")} + + void handleExport()} leftSection={}> + {t("action.export")} + + + } + onClick={handleDelete} + disabled={deleteMutation.isPending} + > + {t("action.delete")} + + + + ); +}; + +export const ImportCustomWidgetButton = () => { + const t = useScopedI18n("customWidget"); + const fileInputRef = useRef(null); + const utils = clientApi.useUtils(); + const importMutation = clientApi.customWidget.import.useMutation({ + onSuccess: () => { + showSuccessNotification({ title: t("action.import"), message: t("notification.imported") }); + void utils.customWidget.all.invalidate(); + void revalidatePathActionAsync("/manage/custom-widgets"); + }, + onError: () => { + showErrorNotification({ title: t("action.import"), message: t("notification.importError") }); + }, + }); + + const handleImport = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const json = JSON.parse(e.target?.result as string); + importMutation.mutate(json); + } catch { + showErrorNotification({ title: t("action.import"), message: t("notification.importError") }); + } + }; + reader.onerror = () => { + showErrorNotification({ title: t("action.import"), message: t("notification.importError") }); + }; + reader.readAsText(file); + event.target.value = ""; + }; + + return ( + <> + } + onClick={() => fileInputRef.current?.click()} + loading={importMutation.isPending} + > + {t("action.import")} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx new file mode 100644 index 000000000..6bb7f76cf --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx @@ -0,0 +1,1293 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + ActionIcon, + Box, + Button, + Fieldset, + Group, + NumberInput, + PasswordInput, + SegmentedControl, + Select, + Stack, + Switch, + Text, + TextInput, + Textarea, +} from "@mantine/core"; +import { IconPlayerPlay, IconPlus, IconTrash } from "@tabler/icons-react"; +import { z } from "zod/v4"; + +import { clientApi } from "@homarr/api/client"; +import { useZodForm } from "@homarr/form"; +import { IconPicker } from "@homarr/forms-collection"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { + CustomWidgetAuthType, + CustomWidgetDisplayType, + CustomWidgetMethod, + CustomWidgetSecretKind, +} from "@homarr/validation/custom-widget"; +import type { displayConfigSchema } from "@homarr/validation/custom-widget"; +import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker"; + +import { CopyAiPromptButton } from "./_copy-ai-prompt-button"; +import { CustomWidgetPreview } from "./_custom-widget-preview"; +import type { PreviewFetchResult } from "./_custom-widget-preview"; + +const requiredFieldValidators: Record, ctx: z.core.$RefinementCtx) => void> = { + singleValue: (data, ctx) => { + if (!data.jsonPath) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: data.jsonPath, + path: ["jsonPath"], + }); + }, + keyValue: (data, ctx) => { + (data.mappings as Array<{ label: string; jsonPath: string }>)?.forEach((m, i) => { + if (!m.label) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: m.label, + path: ["mappings", i, "label"], + }); + if (!m.jsonPath) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: m.jsonPath, + path: ["mappings", i, "jsonPath"], + }); + }); + }, + table: (data, ctx) => { + if (!data.tablePath) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: data.tablePath, + path: ["tablePath"], + }); + (data.columns as Array<{ header: string; jsonPath: string }>)?.forEach((c, i) => { + if (!c.header) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: c.header, + path: ["columns", i, "header"], + }); + if (!c.jsonPath) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: c.jsonPath, + path: ["columns", i, "jsonPath"], + }); + }); + }, + statGrid: (data, ctx) => { + (data.statGridItems as Array<{ label: string; jsonPath: string }>)?.forEach((item, i) => { + if (!item.label) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: item.label, + path: ["statGridItems", i, "label"], + }); + if (!item.jsonPath) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: item.jsonPath, + path: ["statGridItems", i, "jsonPath"], + }); + }); + }, + progressBars: (data, ctx) => { + (data.progressBars as Array<{ label: string; valuePath: string }>)?.forEach((bar, i) => { + if (!bar.label) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: bar.label, + path: ["progressBars", i, "label"], + }); + if (!bar.valuePath) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: bar.valuePath, + path: ["progressBars", i, "valuePath"], + }); + }); + }, + statusIndicator: (data, ctx) => { + (data.statusItems as Array<{ label: string; jsonPath: string; goodValues: string }>)?.forEach((item, i) => { + if (!item.label) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: item.label, + path: ["statusItems", i, "label"], + }); + if (!item.jsonPath) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: item.jsonPath, + path: ["statusItems", i, "jsonPath"], + }); + if (!item.goodValues) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: item.goodValues, + path: ["statusItems", i, "goodValues"], + }); + }); + }, + countGrid: (data, ctx) => { + (data.countGridItems as Array<{ label: string; jsonPath: string }>)?.forEach((item, i) => { + if (!item.label) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: item.label, + path: ["countGridItems", i, "label"], + }); + if (!item.jsonPath) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: item.jsonPath, + path: ["countGridItems", i, "jsonPath"], + }); + }); + }, + raw: (data, ctx) => { + if (!data.rawJsonPath) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: data.rawJsonPath, + path: ["rawJsonPath"], + }); + }, + actionButton: (data, ctx) => { + if (!data.buttonLabel) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: data.buttonLabel, + path: ["buttonLabel"], + }); + }, + customJsx: (data, ctx) => { + if (!data.template) + ctx.addIssue({ + code: "too_small", + minimum: 1, + origin: "string", + inclusive: true, + input: data.template, + path: ["template"], + }); + }, +}; + +const formSchema = z + .object({ + name: z.string().min(1).max(128), + description: z.string(), + iconUrl: z.string(), + url: z.string().min(1), + authType: z.string(), + headerName: z.string(), + method: z.string(), + requestBody: z.string(), + displayType: z.string(), + jsonPath: z.string(), + label: z.string(), + unit: z.string(), + valueSize: z.string(), + labelPosition: z.string(), + mappings: z.array(z.object({ label: z.string(), jsonPath: z.string(), unit: z.string() })), + kvLayout: z.string(), + kvColumns: z.number(), + tablePath: z.string(), + columns: z.array(z.object({ header: z.string(), jsonPath: z.string() })), + striped: z.boolean(), + compact: z.boolean(), + statGridItems: z.array(z.object({ label: z.string(), jsonPath: z.string(), unit: z.string(), color: z.string() })), + statGridColumns: z.number(), + cardStyle: z.string(), + progressBars: z.array( + z.object({ label: z.string(), valuePath: z.string(), maxPath: z.string(), unit: z.string(), color: z.string() }), + ), + showPercentage: z.boolean(), + barSize: z.string(), + statusItems: z.array(z.object({ label: z.string(), jsonPath: z.string(), goodValues: z.string() })), + statusLayout: z.string(), + dotSize: z.string(), + countGridItems: z.array(z.object({ label: z.string(), jsonPath: z.string(), unit: z.string() })), + countGridColumns: z.number(), + countValueSize: z.string(), + rawJsonPath: z.string(), + rawMaxHeight: z.number(), + buttonLabel: z.string(), + buttonColor: z.string(), + confirmText: z.string(), + successMessage: z.string(), + template: z.string(), + secrets: z.array(z.object({ kind: z.string(), value: z.string(), hasValue: z.boolean().optional() })), + }) + .superRefine((data, ctx) => { + const validator = requiredFieldValidators[data.displayType]; + validator?.(data as unknown as Record, ctx); + }); + +const authTypeSecretFields: Record> = { + bearer: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }], + basic: [ + { kind: "username", labelKey: "username", isPassword: false }, + { kind: "password", labelKey: "password", isPassword: true }, + ], + apiKeyHeader: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }], + apiKeyQuery: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }], +}; + +const showHeaderName: Record = { + apiKeyHeader: true, + apiKeyQuery: true, +}; + +const defaultCreateValues: z.infer = { + name: "", + description: "", + iconUrl: "", + url: "", + authType: "none", + headerName: "", + method: "GET", + requestBody: "", + displayType: "singleValue", + jsonPath: "$", + label: "", + unit: "", + valueSize: "lg", + labelPosition: "below", + mappings: [{ label: "", jsonPath: "$", unit: "" }], + kvLayout: "list", + kvColumns: 2, + tablePath: "$", + columns: [{ header: "", jsonPath: "$" }], + striped: true, + compact: false, + statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }], + statGridColumns: 2, + cardStyle: "filled", + progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }], + showPercentage: true, + barSize: "md", + statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }], + statusLayout: "list", + dotSize: "md", + countGridItems: [{ label: "", jsonPath: "$", unit: "" }], + countGridColumns: 2, + countValueSize: "md", + rawJsonPath: "$", + rawMaxHeight: 300, + buttonLabel: "Execute", + buttonColor: "blue", + confirmText: "", + successMessage: "", + template: "", + secrets: [], +}; + +interface CustomWidgetFormProps { + mode: "create" | "edit"; + initialValues?: Partial>; + definitionId?: string; +} + +const displayConfigBuilders: Record) => Record> = { + singleValue: (v) => ({ + type: "singleValue", + jsonPath: v.jsonPath, + label: v.label, + unit: v.unit, + valueSize: v.valueSize, + labelPosition: v.labelPosition, + }), + keyValue: (v) => ({ type: "keyValue", mappings: v.mappings, layout: v.kvLayout, columns: v.kvColumns }), + table: (v) => ({ type: "table", tablePath: v.tablePath, columns: v.columns, striped: v.striped, compact: v.compact }), + statGrid: (v) => ({ type: "statGrid", items: v.statGridItems, columns: v.statGridColumns, cardStyle: v.cardStyle }), + progressBars: (v) => ({ + type: "progressBars", + bars: v.progressBars.map((b) => ({ ...b, maxPath: b.maxPath || undefined })), + showPercentage: v.showPercentage, + barSize: v.barSize, + }), + statusIndicator: (v) => ({ + type: "statusIndicator", + items: v.statusItems.map((item) => ({ + ...item, + goodValues: item.goodValues + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + })), + layout: v.statusLayout, + dotSize: v.dotSize, + }), + countGrid: (v) => ({ + type: "countGrid", + items: v.countGridItems, + columns: v.countGridColumns, + valueSize: v.countValueSize, + }), + raw: (v) => ({ type: "raw", jsonPath: v.rawJsonPath, maxHeight: v.rawMaxHeight }), + actionButton: (v) => ({ + type: "actionButton", + buttonLabel: v.buttonLabel, + buttonColor: v.buttonColor, + confirmText: v.confirmText || undefined, + successMessage: v.successMessage || undefined, + }), + customJsx: (v) => ({ type: "customJsx", template: v.template }), +}; + +const serverToFormFieldMap: Record> = { + statGrid: { items: "statGridItems" }, + countGrid: { items: "countGridItems" }, + statusIndicator: { items: "statusItems" }, + progressBars: { bars: "progressBars" }, + keyValue: { mappings: "mappings" }, + table: { columns: "columns", tablePath: "tablePath" }, + singleValue: { jsonPath: "jsonPath", label: "label", unit: "unit" }, + raw: { jsonPath: "rawJsonPath" }, + actionButton: { buttonLabel: "buttonLabel" }, + customJsx: { template: "template" }, +}; + +function extractServerErrors(err: unknown, displayType: string): Record { + const errors: Record = {}; + const trpcErr = err as { data?: { zodError?: { fieldErrors?: Record } }; message?: string }; + + if (trpcErr?.data?.zodError?.fieldErrors) { + for (const [field, messages] of Object.entries(trpcErr.data.zodError.fieldErrors)) { + if (messages?.[0]) { + errors[field] = messages[0]; + } + } + return errors; + } + + try { + const issues = JSON.parse(trpcErr?.message ?? "[]") as Array<{ path: (string | number)[]; message: string }>; + const fieldMap = serverToFormFieldMap[displayType] ?? {}; + + for (const issue of issues) { + const path = [...issue.path]; + if (path[0] === "displayConfig") { + path.shift(); + const serverField = String(path[0]); + const formField = fieldMap[serverField] ?? serverField; + path[0] = formField; + } + errors[path.join(".")] = issue.message; + } + } catch { + // not parseable, ignore + } + + return errors; +} + +const listItemDefaults = { + mapping: { label: "", jsonPath: "$", unit: "" }, + column: { header: "", jsonPath: "$" }, + statGridItem: { label: "", jsonPath: "$", unit: "", color: "blue" }, + progressBar: { label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }, + statusItem: { label: "", jsonPath: "$", goodValues: "online,true" }, + countGridItem: { label: "", jsonPath: "$", unit: "" }, +} as const; + +function cloneLast>(arr: T[], fallback: T): T { + const last = arr[arr.length - 1]; + return last ? { ...last } : { ...fallback }; +} + +const ALL_DISPLAY_TYPES = [ + "singleValue", + "keyValue", + "table", + "statGrid", + "progressBars", + "statusIndicator", + "countGrid", + "raw", + "actionButton", + "customJsx", +] as const; +const MANTINE_COLORS = [ + "blue", + "teal", + "green", + "red", + "orange", + "yellow", + "violet", + "pink", + "cyan", + "grape", + "indigo", + "lime", +] as const; + +export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) { + const router = useRouter(); + const t = useScopedI18n("customWidget"); + const utils = clientApi.useUtils(); + const createMutation = clientApi.customWidget.create.useMutation(); + const updateMutation = clientApi.customWidget.update.useMutation(); + const previewMutation = clientApi.customWidget.preview.useMutation(); + const [previewRefreshSignal, setPreviewRefreshSignal] = useState(0); + const [previewJson, setPreviewJson] = useState(null); + const [previewFetchResult, setPreviewFetchResult] = useState(null); + const hasTestedRef = useRef(false); + + const form = useZodForm(formSchema, { + initialValues: { ...defaultCreateValues, ...initialValues }, + }); + + const handleSubmit = form.onSubmit(async (values) => { + const buildConfig = displayConfigBuilders[values.displayType] ?? displayConfigBuilders.singleValue; + const displayConfig = buildConfig?.(values); + + const payload = { + name: values.name, + description: values.description || undefined, + iconUrl: values.iconUrl || undefined, + url: values.url, + authType: values.authType as CustomWidgetAuthType, + headerName: values.headerName || undefined, + method: values.method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + requestBody: values.requestBody || undefined, + displayType: values.displayType as CustomWidgetDisplayType, + displayConfig: displayConfig as never, + secrets: values.secrets + .filter((s) => s.value) + .map((s) => ({ kind: s.kind as "apiKey" | "username" | "password", value: s.value })), + }; + + try { + if (mode === "create") { + const result = await createMutation.mutateAsync(payload); + showSuccessNotification({ + title: t("action.create"), + message: t("notification.created", { name: values.name }), + }); + await utils.customWidget.all.invalidate(); + router.push(`/manage/custom-widgets/edit/${result.id}`); + } else if (definitionId) { + await updateMutation.mutateAsync({ id: definitionId, ...payload }); + showSuccessNotification({ title: t("action.save"), message: t("notification.updated", { name: values.name }) }); + setPreviewRefreshSignal((n) => n + 1); + await utils.customWidget.all.invalidate(); + await utils.customWidget.byId.invalidate({ id: definitionId }); + await utils.widget.customApi.getData.invalidate({ definitionId }); + } + } catch (err) { + const serverErrors = extractServerErrors(err, values.displayType); + if (Object.keys(serverErrors).length > 0) { + form.setErrors(serverErrors); + } + const errorKey = mode === "create" ? "notification.createError" : "notification.updateError"; + showErrorNotification({ title: t("action.save"), message: t(errorKey as never) }); + } + }); + + const secretFields = authTypeSecretFields[form.values.authType] ?? []; + + const getPreviewInput = useCallback(() => { + const values = form.values; + const buildConfig = displayConfigBuilders[values.displayType] ?? displayConfigBuilders.singleValue; + return { + url: values.url, + method: values.method as CustomWidgetMethod, + authType: values.authType as CustomWidgetAuthType, + headerName: values.headerName || undefined, + requestBody: values.requestBody || undefined, + displayType: values.displayType as CustomWidgetDisplayType, + displayConfig: buildConfig?.(values) as z.infer, + secrets: values.secrets + .filter((s) => s.value) + .map((s) => ({ kind: s.kind as CustomWidgetSecretKind, value: s.value })), + definitionId, + }; + }, [form.values, definitionId]); + + const handlePreviewTest = useCallback(async () => { + const input = getPreviewInput(); + if (!input.url) return; + try { + const res = await previewMutation.mutateAsync(input); + hasTestedRef.current = true; + setPreviewFetchResult({ + success: res.success, + error: res.success ? undefined : res.error, + responseInfo: res.responseInfo, + rawResponse: res.rawResponse, + }); + if (res.success && res.rawResponse) { + try { + setPreviewJson(JSON.parse(res.rawResponse)); + } catch { + setPreviewJson(null); + } + } else { + setPreviewJson(null); + } + } catch { + setPreviewFetchResult({ + success: false, + error: t("notification.previewError"), + responseInfo: null, + rawResponse: null, + }); + setPreviewJson(null); + } + }, [getPreviewInput, previewMutation]); + + useEffect(() => { + if (previewRefreshSignal > 0 && hasTestedRef.current) { + void handlePreviewTest(); + } + }, [previewRefreshSignal]); + + const previewInput = getPreviewInput(); + + return ( +
+ + +
+ + +