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 d78aafbab..119cf2666 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
@@ -34,219 +34,120 @@ const PROMPT_RULES = `
- 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}\`).
+- \`singleValue\`: One prominent number/text
+- \`keyValue\`: Labeled pairs — use \`mappings\` array
+- \`table\`: Tabular data — \`tablePath\` + \`columns\`
+- \`statGrid\`: Grid of stat cards with colors
+- \`progressBars\`: Visual progress bars with value/max
+- \`statusIndicator\`: Green/red dots based on value matching
+- \`countGrid\`: Simple count grid
+- \`raw\`: Raw JSON display
+- \`actionButton\`: Button that triggers the API call on click
+- \`customJsx\`: **Full Mantine v9 JSX** — complete creative control. Set \`displayConfig.template\` to a JSX string.
-## 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.
+## Custom JSX (\`customJsx\`) — Component Reference
-### 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)
+All Mantine v9 components listed below are available. For full Mantine prop documentation, refer to https://mantine.dev/llms.txt or use Context7/web search for any component's detailed API.
-### 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)
+### Layout
+Box, Stack, Group, Flex, Grid + Grid.Col, SimpleGrid, Center, Space, Container, AspectRatio, Overlay, ScrollArea
-### 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\`
+### Typography
+Text, Title, Code, Highlight, Mark, Kbd, Blockquote, Anchor (href sanitized), NumberFormatter, Marquee, RollingNumber
-### 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)
+### Data Display
+Badge, Card + Card.Section, Paper, Alert, ThemeIcon, ColorSwatch, Table (+ Thead/Tbody/Tfoot/Caption/Tr/Th/Td), List + List.Item, Timeline + Timeline.Item, Accordion (+ Item/Control/Panel), Indicator, Pill, Spoiler, Progress + Progress.Section, RingProgress, SemiCircleProgress, Skeleton, Loader, Image, Avatar + Avatar.Group, BackgroundImage, Tooltip, Divider, DataList (+ Item/ItemLabel/ItemValue), EmptyState (+ Indicator/Title/Description/Actions), Fieldset, Notification (visual), Rating (read-only)
-### 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)
+### Navigation
+Breadcrumbs, NavLink (href sanitized), Stepper + Stepper.Step, Tabs + Tabs.List + Tabs.Tab + Tabs.Panel, Tree
-### 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\`
+### Interactive Display (visual only — no event handlers)
+Button, ActionIcon, Burger, CloseButton, Chip + Chip.Group, Pagination, SegmentedControl, Slider (read-only), Switch (read-only)
-### 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" }}\`
+### Hover Overlays & Menus
+HoverCard + HoverCard.Target + HoverCard.Dropdown, Menu + Menu.Target + Menu.Dropdown + Menu.Item + Menu.Label + Menu.Divider
-### Interactive Components (built-in, state-managed)
-These components manage their own internal state — they provide interactivity without event handlers:
+### Utility
+CopyButton (click-to-copy, props: \`value\`), Collapse (animate show/hide, props: \`in\`), Transition (animate, props: \`mounted\`, \`transition\`)
-**PaginatedList** — Paginates its children with prev/next buttons.
-Props: \`pageSize\` (number, default 6)
-Usage: Wrap a \`.map()\` expression to paginate results.
+### Charts (@mantine/charts — ALL)
+AreaChart, BarChart, LineChart, DonutChart, PieChart, RadarChart, RadialBarChart, Sparkline, BubbleChart, CompositeChart, FunnelChart, Heatmap, ScatterChart, SankeyChart, Treemap, BarsList
+
+### Dates (@mantine/dates)
+Calendar (static), MiniCalendar (static), DatePicker (static/read-only), TimeValue
+
+### SubFetch — In-Widget HTTP Requests (server-proxied)
+These components make HTTP requests through the Homarr server proxy (same-origin as widget URL, inherits widget auth). HTTP method warnings are shown in the widget UI.
+
+**SubFetch** — Fetch data from a sub-endpoint and display results.
+Props: \`url\` (relative or absolute), \`method\` (GET|POST|PUT|DELETE|PATCH), \`body\` (JSON string), \`headers\` (JSON string), \`trigger\` ("auto"|"manual"), \`label\`, \`color\`, \`display\` ("json"|"text"), \`path\` (dot-path for display="text")
\`\`\`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)
+**SubData** — Reads data from parent SubFetch context.
+Props: \`path\` (dot-notation), \`as\` ("Text"|"Title"|"Badge"|"Code"), \`size\`, \`color\`, \`fw\`, \`c\`, \`order\`
+
+**ActionButton** — Button that fires an HTTP request on click.
+Props: \`url\`, \`method\` (default POST), \`body\`, \`headers\`, \`label\`, \`color\`, \`variant\`, \`size\`, \`confirmMessage\`, \`successMessage\`, \`icon\` (play|check|refresh|power|trash), \`fullWidth\`, \`disabled\`
\`\`\`jsx
-
-
- {data.description}
-
-
- Total: {data.count}
-
-
+
\`\`\`
-**Collapsible** — Expandable/collapsible section with a title.
-Props: \`title\` (string, required), \`defaultOpen\` (boolean, default false)
+**ToggleSwitch** — Switch that sends different payloads for on/off states.
+Props: \`url\`, \`method\` (default POST), \`onBody\` (JSON), \`offBody\` (JSON), \`initialValue\`, \`label\`, \`color\`, \`size\`, \`disabled\`
\`\`\`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
-
-
-\`\`\`
+**RefreshButton** — Re-fetches the parent widget data.
+Props: \`label\`, \`color\`, \`variant\`, \`size\`
-**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
-
-
-
-
-\`\`\`
+### Custom Interactive Components (built-in state)
+**PaginatedList** — Paginates children. Props: \`pageSize\` (default 6)
+**TabsContainer + TabPanel** — Tabbed UI. TabsContainer: \`defaultTab\`. TabPanel: \`value\`, \`label\`
+**Collapsible** — Expand/collapse. Props: \`title\`, \`defaultOpen\`
+**StatBar** — Horizontal stat bar. Props: \`value\`, \`max\`, \`label\`, \`color\`
+**TypeBadge** — Colored type badge (auto-maps: fire→red, water→blue, etc.). Props: \`type\`, \`size\`
-**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"}\`
+### Bindings
+- \`data\` — full API response
+- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\`, \`parseInt(v)\`, \`parseFloat(v)\`
+- \`Math.round/floor/ceil/abs/min/max/pow/sqrt/PI\`
+- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Array.from(v)\`, \`Object.keys/values/entries(v)\`
+- \`Date.now()\`, \`Date.create(v)\`, \`Date.toISOString(v)\`, \`Date.toLocaleDateString(v, locale?)\`, \`Date.toLocaleTimeString(v, locale?)\`, \`Date.getTime(v)\`, \`Date.getYear(v)\`, \`Date.getMonth(v)\`, \`Date.getDay(v)\`
+- \`encodeURIComponent(v)\`, \`decodeURIComponent(v)\`, \`isNaN(v)\`, \`isFinite(v)\`
+- \`.map()\`, \`.filter()\`, \`.slice()\`, ternaries for conditionals
-**FORBIDDEN keywords (template will be rejected):** constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch
+**FORBIDDEN keywords:** constructor, __proto__, eval, Function, import, require, globalThis, window, document
-**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: \`\`
+### Chart Data Formats
+- BarChart/LineChart/AreaChart: \`data={items}\` + \`dataKey="x"\` + \`series={[{name:"y",color:"blue"}]}\`
+- DonutChart/PieChart: \`data={[{name,value,color}]}\`
+- ScatterChart: \`data={items}\` + \`dataKey={xKey}\` + \`series={[{name, color}]}\`
+- CompositeChart: same as Bar/Line/Area + \`composedChart\` with type per series
+- Sparkline: \`data={[numbers]}\`
+- Heatmap: \`data={items}\` + \`dataKey\` + \`series\`
-### 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\`
+### Pokédex Example (75 pokemon)
+API: \`https://pokeapi.co/api/v2/pokemon?limit=75\`
\`\`\`jsx
Pokédex
- {data.count} Pokémon
+ {data.count} total
{data.results.map((pokemon, i) =>
-
-
+
+
{pokemon.name}
- #{String(i + 1)}
+ #{String(i + 1).padStart(3, "0")}
@@ -255,6 +156,90 @@ API: \`https://pokeapi.co/api/v2/pokemon?limit=50\`
\`\`\`
+### Pokédex with Detail Fetch (SubFetch for sub-resources)
+API: \`https://pokeapi.co/api/v2/pokemon?limit=75\`
+\`\`\`jsx
+
+ Pokédex
+
+ {data.results.map((pokemon, i) =>
+
+
+
+
+ {pokemon.name}
+
+
+
+
+
+
+
+
+
+ )}
+
+
+\`\`\`
+
+### Stocks Dashboard (Charts + Live Data)
+API: \`https://your-stocks-api.com/portfolio\`
+\`\`\`jsx
+
+
+ Portfolio
+
+
+
+ {data.holdings.map(stock =>
+
+ {stock.symbol}
+
+ 0 ? "green" : "red"} />
+
+ )}
+
+
+
+\`\`\`
+
+### Navidrome Mini-Player (Buttons + State)
+API: \`https://your-navidrome.local/api/nowplaying\`
+\`\`\`jsx
+
+
+
+
+ {data.title}
+ {data.artist}
+
+
+
+
+
+
+
+
+
+
+
+
+
+\`\`\`
+
+### Smart Home Light Switch
+API: \`https://your-ha.local/api/states/light.living_room\`
+\`\`\`jsx
+
+
+ Living Room
+ {data.state}
+
+
+
+
+\`\`\`
+
## API Response
`;
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
index c2820757f..2bb7424b0 100644
--- 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
@@ -1393,9 +1393,9 @@ function DisplayTypeFields({
'\n {data.name}\n {data.description}\n'
}
minRows={8}
- maxRows={20}
+ maxRows={30}
autosize
- maxLength={10000}
+ maxLength={50000}
styles={{ input: { fontFamily: "monospace" } }}
{...form.getInputProps("template")}
/>
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx
index 25a53ffd2..8f19067e2 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx
@@ -2,10 +2,11 @@
import { useMemo } from "react";
import { Alert, Badge, Button, Card, Center, Code, Group, Loader, ScrollArea, Stack, Text, Title } from "@mantine/core";
-import { IconAlertTriangle, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
+import { IconAlertTriangle, IconExternalLink, IconNetwork, IconPlayerPlay } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
+import { extractHttpMethods } from "@homarr/widgets/custom-api/custom-jsx-display";
import { displayComponents } from "@homarr/widgets/custom-api/component";
@@ -58,6 +59,12 @@ export function CustomWidgetPreview({
return extractDisplayData(cachedJson, displayType, displayConfig) as Record;
}, [fetchResult?.success, cachedJson, formValues]);
+ const httpMethods = useMemo(() => {
+ if (formValues.displayType !== "customJsx") return [];
+ const template = (formValues.displayConfig.template as string) ?? "";
+ return extractHttpMethods(template);
+ }, [formValues.displayType, formValues.displayConfig]);
+
const handleTest = () => {
const values = getFormValues();
if (!values.url) return;
@@ -80,6 +87,21 @@ export function CustomWidgetPreview({
+ {httpMethods.length > 0 && (
+ }>
+
+
+ Widget makes HTTP requests:
+
+ {httpMethods.map((m) => (
+
+ {m}
+
+ ))}
+
+
+ )}
+
{testError && (
} p="xs">
{testError}
diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts
index 09fafa880..c48d7219d 100644
--- a/packages/api/src/router/widgets/custom-api.ts
+++ b/packages/api/src/router/widgets/custom-api.ts
@@ -13,9 +13,37 @@ import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../c
const logger = createLogger({ module: "widget:customApi" });
const FETCH_TIMEOUT_MS = 10_000;
+const MAX_BODY_SIZE = 10_000;
+
+const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
const validateUrl = (urlString: string): URL => new URL(urlString);
+const resolveTargetUrl = (definitionUrl: string, targetUrl: string): URL => new URL(targetUrl, validateUrl(definitionUrl));
+
+const assertSameOrigin = (definitionUrl: string, targetUrl: URL): void => {
+ const definitionOrigin = validateUrl(definitionUrl).origin;
+ if (targetUrl.origin !== definitionOrigin) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "URL must be on the same domain as the widget definition",
+ });
+ }
+};
+
+const parseResponseData = async (response: Response): Promise => {
+ const contentType = response.headers.get("content-type") ?? "";
+ if (contentType.includes("application/json")) {
+ return response.json();
+ }
+ const text = await response.text();
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+};
+
export const customApiRouter = createTRPCRouter({
getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -88,4 +116,97 @@ export const customApiRouter = createTRPCRouter({
clearTimeout(timeout);
}
}),
+
+ subFetch: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Proxy an HTTP request from a custom JSX widget to an external API on the same domain as the widget definition. Applies the definition's auth credentials. Requires definitionId from the widget configuration.",
+ },
+ })
+ .input(
+ z.object({
+ definitionId: z.string(),
+ url: z.string().min(1).max(2048),
+ method: z.enum(HTTP_METHODS).default("GET"),
+ body: z.string().max(MAX_BODY_SIZE).optional(),
+ headers: z.record(z.string(), z.string()).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ if (input.body && input.body.length > MAX_BODY_SIZE) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Request body exceeds 10KB limit" });
+ }
+
+ const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
+ where: eq(customWidgetDefinitions.id, input.definitionId),
+ with: { secrets: true },
+ });
+
+ if (!definition) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
+ }
+
+ if (!definition.enabled) {
+ throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
+ }
+
+ const decryptedSecrets = definition.secrets.map((s) => ({
+ kind: s.kind,
+ value: decryptSecret(s.value),
+ }));
+
+ const targetUrl = resolveTargetUrl(definition.url, input.url);
+ assertSameOrigin(definition.url, targetUrl);
+
+ const headers = new Headers({ Accept: "application/json" });
+ if (input.headers) {
+ for (const [key, value] of Object.entries(input.headers)) {
+ headers.set(key, value);
+ }
+ }
+
+ if (input.method !== "GET" && input.body) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ applyAuth(headers, targetUrl, definition.authType, decryptedSecrets, definition.headerName);
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(targetUrl.toString(), {
+ method: input.method,
+ headers,
+ body: input.method !== "GET" ? input.body : undefined,
+ redirect: "follow",
+ signal: controller.signal,
+ });
+
+ const data = await parseResponseData(response);
+
+ if (!response.ok) {
+ return {
+ ok: false as const,
+ status: response.status,
+ data,
+ error: `HTTP ${response.status}: ${response.statusText}`,
+ };
+ }
+
+ return { ok: true as const, status: response.status, data };
+ } catch (error) {
+ logger.error("SubFetch proxy request failed", { definitionId: input.definitionId, url: input.url, error });
+ return {
+ ok: false as const,
+ status: 0,
+ data: null,
+ error: error instanceof Error ? error.message : "Request failed",
+ };
+ } finally {
+ clearTimeout(timeout);
+ }
+ }),
});
diff --git a/packages/validation/src/custom-widget.ts b/packages/validation/src/custom-widget.ts
index a9298c20d..7c14ad6f6 100644
--- a/packages/validation/src/custom-widget.ts
+++ b/packages/validation/src/custom-widget.ts
@@ -258,7 +258,7 @@ const rawDisplayConfigSchema = z.object({
});
const FORBIDDEN_TEMPLATE_PATTERN =
- /\bconstructor\b|\b__proto__\b|\bprototype\b|\beval\b|\bFunction\b|\bimport\s*\(|\brequire\b|\bglobalThis\b|\bwindow\b|\bdocument\b|\bfetch\b|\bXMLHttpRequest\b/i;
+ /\bconstructor\b|\b__proto__\b|\bprototype\b|\beval\b|\bFunction\b|\bimport\s*\(|\brequire\b|\bglobalThis\b|\bwindow\b|\bdocument\b|\bXMLHttpRequest\b/i;
const customJsxDisplayConfigSchema = z.object({
type: z
@@ -267,13 +267,13 @@ const customJsxDisplayConfigSchema = z.object({
template: z
.string()
.min(1)
- .max(10000)
+ .max(50000)
.refine((t) => !FORBIDDEN_TEMPLATE_PATTERN.test(t), {
message:
- "Template contains forbidden keywords (constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch)",
+ "Template contains forbidden keywords (constructor, __proto__, eval, Function, import, require, globalThis, window, document)",
})
.describe(
- "JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Forbidden keywords: constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch",
+ "JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Use SubFetch for in-widget HTTP requests. Forbidden keywords: constructor, __proto__, eval, Function, import, require, globalThis, window, document",
),
});
diff --git a/packages/widgets/package.json b/packages/widgets/package.json
index 2f835dafd..aaab3859d 100644
--- a/packages/widgets/package.json
+++ b/packages/widgets/package.json
@@ -15,6 +15,7 @@
".": "./index.ts",
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
+ "./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
"./errors": "./src/errors/component.tsx",
"./errors/classes": "./src/errors/index.ts",
@@ -54,6 +55,7 @@
"@homarr/validation": "workspace:^0.1.0",
"@mantine/charts": "catalog:",
"@mantine/core": "catalog:",
+ "@mantine/dates": "catalog:",
"@mantine/form": "catalog:",
"@mantine/hooks": "catalog:",
"@tabler/icons-react": "catalog:",
diff --git a/packages/widgets/src/custom-api/component.tsx b/packages/widgets/src/custom-api/component.tsx
index ea89301d8..cbcf77f84 100644
--- a/packages/widgets/src/custom-api/component.tsx
+++ b/packages/widgets/src/custom-api/component.tsx
@@ -479,7 +479,10 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
- const enrichedData = dataType === "actionButton" ? { ...widgetData, widgetDefinitionId: definitionId } : widgetData;
+ const enrichedData =
+ dataType === "actionButton" || dataType === "customJsx"
+ ? { ...widgetData, widgetDefinitionId: definitionId }
+ : widgetData;
return ;
}
diff --git a/packages/widgets/src/custom-api/custom-jsx-display.tsx b/packages/widgets/src/custom-api/custom-jsx-display.tsx
index 1a6bf2f93..15fc496cc 100644
--- a/packages/widgets/src/custom-api/custom-jsx-display.tsx
+++ b/packages/widgets/src/custom-api/custom-jsx-display.tsx
@@ -2,10 +2,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import JsxParser from "react-jsx-parser";
-import { Alert, Stack, Text } from "@mantine/core";
-import { IconAlertTriangle } from "@tabler/icons-react";
+import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
+import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
+import { WidgetDefinitionProvider } from "./widget-definition-context";
const MAX_PARSE_ERRORS = 5;
@@ -15,11 +16,40 @@ function appendParseError(prev: string[], message: string): string[] {
return [...prev, message];
}
+const METHOD_PATTERN = /(?:method|method)\s*=\s*["'](\w+)["']/gi;
+const SUBFETCH_PATTERN = /<(?:SubFetch|ActionButton|ToggleSwitch)\b/g;
+
+export function extractHttpMethods(template: string): string[] {
+ const methods = new Set();
+ if (SUBFETCH_PATTERN.test(template)) {
+ SUBFETCH_PATTERN.lastIndex = 0;
+ let match = METHOD_PATTERN.exec(template);
+ while (match) {
+ methods.add(match[1].toUpperCase());
+ match = METHOD_PATTERN.exec(template);
+ }
+ if (/ = {
+ GET: "blue",
+ POST: "orange",
+ PUT: "yellow",
+ DELETE: "red",
+ PATCH: "grape",
+};
+
export default function CustomJsxDisplay({ data }: { data: Record }) {
const template = String(data.template ?? "");
const apiData = data.data;
+ const definitionId = String(data.widgetDefinitionId ?? "");
const [parseErrors, setParseErrors] = useState([]);
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
+ const httpMethods = useMemo(() => extractHttpMethods(template), [template]);
useEffect(() => {
setParseErrors([]);
@@ -40,35 +70,51 @@ export default function CustomJsxDisplay({ data }: { data: Record
- (
- } p="xs">
- {String(error)}
+
+
+ {httpMethods.length > 0 && (
+ }>
+
+
+ HTTP:
+
+ {httpMethods.map((m) => (
+
+ {m}
+
+ ))}
+
)}
- />
- {parseErrors.length > 0 && (
-
-
- {parseErrors.length} template warning(s):
-
- {parseErrors.map((msg) => (
-
- {msg}
+ (
+ } p="xs">
+ {String(error)}
+
+ )}
+ />
+ {parseErrors.length > 0 && (
+
+
+ {parseErrors.length} template warning(s):
- ))}
-
- )}
-
+ {parseErrors.map((msg) => (
+
+ {msg}
+
+ ))}
+
+ )}
+
+
);
}
diff --git a/packages/widgets/src/custom-api/jsx-sub-fetch.tsx b/packages/widgets/src/custom-api/jsx-sub-fetch.tsx
new file mode 100644
index 000000000..c18eb7f05
--- /dev/null
+++ b/packages/widgets/src/custom-api/jsx-sub-fetch.tsx
@@ -0,0 +1,459 @@
+"use client";
+
+import type { ComponentType, ReactNode } from "react";
+import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
+import { Alert, Badge, Button, Code, Skeleton, Switch, Text, Title } from "@mantine/core";
+import {
+ IconAlertTriangle,
+ IconCheck,
+ IconPlayerPlay,
+ IconPower,
+ IconRefresh,
+ IconTrash,
+} from "@tabler/icons-react";
+
+import { clientApi } from "@homarr/api/client";
+import { useConfirmModal } from "@homarr/modals";
+import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
+
+import { useWidgetDefinitionId } from "./widget-definition-context";
+
+const MAX_CALLS_PER_MINUTE = 10;
+const RATE_WINDOW_MS = 60_000;
+const callTimestamps = new Map();
+
+const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
+type HttpMethod = (typeof HTTP_METHODS)[number];
+
+type SubFetchResult =
+ | { ok: true; status: number; data: unknown }
+ | { ok: false; status: number; data: unknown; error?: string };
+
+const ICON_MAP: Record> = {
+ play: IconPlayerPlay,
+ check: IconCheck,
+ refresh: IconRefresh,
+ power: IconPower,
+ trash: IconTrash,
+};
+
+const SubFetchDataContext = createContext(undefined);
+
+function checkRateLimit(definitionId: string): boolean {
+ const now = Date.now();
+ const calls = callTimestamps.get(definitionId) ?? [];
+ const recent = calls.filter((t) => now - t < RATE_WINDOW_MS);
+ if (recent.length >= MAX_CALLS_PER_MINUTE) return false;
+ recent.push(now);
+ callTimestamps.set(definitionId, recent);
+ return true;
+}
+
+function parseBool(value: unknown, defaultValue = false): boolean {
+ if (typeof value === "boolean") return value;
+ if (value === "true") return true;
+ if (value === "false") return false;
+ return defaultValue;
+}
+
+function parseMethod(method?: string): HttpMethod {
+ const upper = (method ?? "GET").toUpperCase();
+ if (HTTP_METHODS.includes(upper as HttpMethod)) return upper as HttpMethod;
+ return "GET";
+}
+
+function parseJsonRecord(str?: string): Record | undefined {
+ if (!str?.trim()) return undefined;
+ try {
+ const parsed: unknown = JSON.parse(str);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
+ const result: Record = {};
+ for (const [key, value] of Object.entries(parsed)) {
+ result[key] = String(value);
+ }
+ return result;
+ } catch {
+ return undefined;
+ }
+}
+
+function getByPath(obj: unknown, path?: string): unknown {
+ if (!path?.trim()) return obj;
+ return path.split(".").reduce((acc, key) => {
+ if (acc == null || typeof acc !== "object") return undefined;
+ return (acc as Record)[key];
+ }, obj);
+}
+
+function formatDisplayValue(value: unknown): string {
+ if (value == null) return "—";
+ if (typeof value === "object") return JSON.stringify(value);
+ return String(value);
+}
+
+function useSubFetchExecutor() {
+ const definitionId = useWidgetDefinitionId();
+ const mutation = clientApi.widget.customApi.subFetch.useMutation();
+
+ const execute = useCallback(
+ async (params: { url: string; method?: string; body?: string; headers?: string }): Promise => {
+ if (!definitionId) {
+ return { ok: false as const, status: 0, data: null, error: "Widget definition not available" };
+ }
+ if (!checkRateLimit(definitionId)) {
+ return { ok: false as const, status: 0, data: null, error: "Rate limit exceeded (10 calls/minute)" };
+ }
+
+ return mutation.mutateAsync({
+ definitionId,
+ url: params.url,
+ method: parseMethod(params.method),
+ body: params.body,
+ headers: parseJsonRecord(params.headers),
+ });
+ },
+ [definitionId, mutation],
+ );
+
+ return { execute, isPending: mutation.isPending };
+}
+
+export interface SubFetchProps {
+ url: string;
+ method?: string;
+ body?: string;
+ headers?: string;
+ trigger?: "auto" | "manual";
+ label?: string;
+ color?: string;
+ variant?: string;
+ children?: ReactNode;
+ loadingText?: string;
+ errorText?: string;
+ display?: "json" | "text";
+ path?: string;
+}
+
+export function SubFetch({
+ url,
+ method,
+ body,
+ headers,
+ trigger = "auto",
+ label = "Fetch",
+ color = "blue",
+ variant = "filled",
+ children,
+ loadingText = "Loading...",
+ errorText,
+ display,
+ path,
+}: SubFetchProps) {
+ const { execute, isPending } = useSubFetchExecutor();
+ const [subData, setSubData] = useState(undefined);
+ const [error, setError] = useState(null);
+ const [fetched, setFetched] = useState(false);
+
+ const runFetch = useCallback(async () => {
+ setError(null);
+ const result = await execute({ url, method, body, headers });
+ if (result.ok) {
+ setSubData(result.data);
+ setFetched(true);
+ } else {
+ setError(result.error ?? errorText ?? `Request failed (${result.status})`);
+ setFetched(true);
+ }
+ }, [execute, url, method, body, headers, errorText]);
+
+ useEffect(() => {
+ if (trigger === "auto") {
+ void runFetch();
+ }
+ }, [trigger, runFetch]);
+
+ if (trigger === "manual" && !fetched && !isPending) {
+ return (
+
+ );
+ }
+
+ if (isPending) {
+ return (
+
+
+
+ {loadingText}
+
+
+ );
+ }
+
+ if (error) {
+ return (
+ } p="xs">
+ {error}
+
+ );
+ }
+
+ const contextValue = subData;
+
+ if (display === "json") {
+ return (
+
+
+ {JSON.stringify(subData, null, 2)}
+
+
+ );
+ }
+
+ if (display === "text" && path) {
+ return (
+
+ {formatDisplayValue(getByPath(subData, path))}
+
+ );
+ }
+
+ if (!children) {
+ return (
+
+
+ {JSON.stringify(subData, null, 2)}
+
+
+ );
+ }
+
+ return {children};
+}
+
+export interface SubDataProps {
+ path?: string;
+ as?: string;
+ order?: number;
+ size?: string;
+ color?: string;
+ variant?: string;
+ fw?: number;
+ c?: string;
+}
+
+
+export function SubData({ path, as = "Text", ...props }: SubDataProps) {
+ const subData = useContext(SubFetchDataContext);
+ const value = getByPath(subData, path);
+
+ if (value === undefined && path) {
+ return (
+
+ —
+
+ );
+ }
+
+ const displayValue = as === "Code" ? JSON.stringify(value, null, 2) : formatDisplayValue(value);
+
+ if (as === "Title") {
+ return (
+
+ {displayValue}
+
+ );
+ }
+
+ if (as === "Badge") {
+ return (
+
+ {displayValue}
+
+ );
+ }
+
+ if (as === "Code") {
+ return {displayValue};
+ }
+
+ return (
+
+ {displayValue}
+
+ );
+}
+
+export interface ActionButtonProps {
+ url: string;
+ method?: string;
+ body?: string;
+ headers?: string;
+ label: string;
+ color?: string;
+ variant?: string;
+ size?: string;
+ confirmMessage?: string;
+ successMessage?: string;
+ icon?: string;
+ fullWidth?: boolean | string;
+ disabled?: boolean | string;
+}
+
+export function ActionButton({
+ url,
+ method = "POST",
+ body,
+ headers,
+ label,
+ color = "blue",
+ variant = "filled",
+ size = "sm",
+ confirmMessage,
+ successMessage,
+ icon,
+ fullWidth,
+ disabled,
+}: ActionButtonProps) {
+ const { execute, isPending } = useSubFetchExecutor();
+ const { openConfirmModal } = useConfirmModal();
+ const Icon = icon ? ICON_MAP[icon.toLowerCase()] : undefined;
+
+ const runAction = async () => {
+ const result = await execute({ url, method, body, headers });
+ if (result.ok) {
+ if (successMessage) {
+ showSuccessNotification({ title: label, message: successMessage });
+ }
+ } else {
+ showErrorNotification({ title: label, message: result.error ?? `Request failed (${result.status})` });
+ }
+ };
+
+ const handleClick = () => {
+ if (confirmMessage) {
+ openConfirmModal({
+ title: label,
+ children: confirmMessage,
+ onConfirm: () => void runAction(),
+ });
+ } else {
+ void runAction();
+ }
+ };
+
+ return (
+ : undefined}
+ fullWidth={parseBool(fullWidth)}
+ disabled={parseBool(disabled)}
+ >
+ {label}
+
+ );
+}
+
+export interface ToggleSwitchProps {
+ url: string;
+ method?: string;
+ onBody?: string;
+ offBody?: string;
+ initialValue?: boolean | string;
+ label?: string;
+ color?: string;
+ size?: string;
+ disabled?: boolean | string;
+}
+
+export function ToggleSwitch({
+ url,
+ method = "POST",
+ onBody,
+ offBody,
+ initialValue = false,
+ label,
+ color = "blue",
+ size = "sm",
+ disabled,
+}: ToggleSwitchProps) {
+ const { execute, isPending } = useSubFetchExecutor();
+ const [checked, setChecked] = useState(parseBool(initialValue));
+ const pendingRef = useRef(false);
+
+ const handleChange = async (next: boolean) => {
+ if (pendingRef.current) return;
+ pendingRef.current = true;
+ const prev = checked;
+ setChecked(next);
+
+ const result = await execute({
+ url,
+ method,
+ body: next ? onBody : offBody,
+ });
+
+ pendingRef.current = false;
+ if (!result.ok) {
+ setChecked(prev);
+ showErrorNotification({ title: label ?? "Toggle", message: result.error ?? "Request failed" });
+ }
+ };
+
+ return (
+ void handleChange(event.currentTarget.checked)}
+ disabled={parseBool(disabled) || isPending}
+ />
+ );
+}
+
+export interface RefreshButtonProps {
+ label?: string;
+ color?: string;
+ variant?: string;
+ size?: string;
+}
+
+export function RefreshButton({
+ label = "Refresh",
+ color = "blue",
+ variant = "light",
+ size = "sm",
+}: RefreshButtonProps) {
+ const definitionId = useWidgetDefinitionId();
+ const utils = clientApi.useUtils();
+ const [loading, setLoading] = useState(false);
+
+ const handleRefresh = async () => {
+ if (!definitionId) return;
+ setLoading(true);
+ try {
+ await utils.widget.customApi.getData.invalidate({ definitionId });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ }
+ onClick={() => void handleRefresh()}
+ loading={loading}
+ >
+ {label}
+
+ );
+}
diff --git a/packages/widgets/src/custom-api/jsx-whitelist.ts b/packages/widgets/src/custom-api/jsx-whitelist.ts
index 983821955..5c0bfebdc 100644
--- a/packages/widgets/src/custom-api/jsx-whitelist.ts
+++ b/packages/widgets/src/custom-api/jsx-whitelist.ts
@@ -1,60 +1,99 @@
-import type { ComponentType } from "react";
-import { createElement } from "react";
+import type { ComponentType, ReactNode } from "react";
+import { createElement, useState } from "react";
import {
- Stack,
- Group,
- Flex,
- Grid,
- SimpleGrid,
- Center,
- Space,
- Container,
- AspectRatio,
- Text,
- Title,
- Code,
- Highlight,
- Mark,
- Kbd,
- Blockquote,
- Anchor,
- NumberFormatter,
- Badge,
- Card,
- Paper,
- Alert,
- ThemeIcon,
- ColorSwatch,
- Table,
- List,
- Timeline,
Accordion,
- Indicator,
- Pill,
- Spoiler,
- Progress,
- RingProgress,
- Skeleton,
- Loader,
- Image,
+ ActionIcon,
+ Alert,
+ Anchor,
+ AspectRatio,
Avatar,
BackgroundImage,
- Tooltip,
+ Badge,
+ Blockquote,
+ Box,
+ Breadcrumbs,
+ Burger,
+ Button,
+ Card,
+ Center,
+ Chip,
+ CloseButton,
+ Code,
+ Collapse,
+ ColorSwatch,
+ Container,
+ CopyButton,
+ DataList,
Divider,
+ EmptyState,
+ Fieldset,
+ Flex,
+ Grid,
+ Group,
+ Highlight,
+ HoverCard,
+ Image,
+ Indicator,
+ Kbd,
+ List,
+ Loader,
+ Mark,
+ Marquee,
+ Menu,
+ NavLink,
+ Notification,
+ NumberFormatter,
+ Overlay,
+ Pagination,
+ Paper,
+ Pill,
+ Progress,
+ Rating,
+ RingProgress,
+ RollingNumber,
ScrollArea,
+ SegmentedControl,
+ SemiCircleProgress,
+ SimpleGrid,
+ Skeleton,
+ Slider,
+ Space,
+ Spoiler,
+ Stack,
+ Stepper,
+ Switch,
+ Table,
+ Tabs,
+ Text,
+ ThemeIcon,
+ Timeline,
+ Title,
+ Tooltip,
+ Transition,
+ Tree,
} from "@mantine/core";
import {
AreaChart,
BarChart,
- LineChart,
+ BarsList,
+ BubbleChart,
+ CompositeChart,
DonutChart,
+ FunnelChart,
+ Heatmap,
+ LineChart,
PieChart,
RadarChart,
RadialBarChart,
+ SankeyChart,
+ ScatterChart,
Sparkline,
+ Treemap,
} from "@mantine/charts";
+import { Calendar, DatePicker, MiniCalendar, TimeValue } from "@mantine/dates";
import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components";
+import { SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton } from "./jsx-sub-fetch";
const SAFE_URL_PATTERN = /^https?:\/\//i;
@@ -63,13 +102,112 @@ function isSafeUrl(url: unknown): boolean {
return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#");
}
+function stripEventHandlers(props: Record): Record {
+ const safe: Record = {};
+ for (const [key, value] of Object.entries(props)) {
+ if (/^on[A-Z]/.test(key)) continue;
+ safe[key] = value;
+ }
+ return safe;
+}
+
function SafeAnchor(props: Record) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
- return createElement(Anchor as never, { ...props, href: safeHref });
+ return createElement(Anchor as never, { ...stripEventHandlers(props), href: safeHref });
+}
+
+function SafeNavLink(props: Record) {
+ const href = props.href;
+ const safeHref = href && isSafeUrl(href) ? href : undefined;
+ return createElement(NavLink as never, { ...stripEventHandlers(props), href: safeHref });
+}
+
+function SafeButton(props: Record) {
+ return createElement(Button as never, stripEventHandlers(props));
+}
+
+function SafeActionIcon(props: Record) {
+ return createElement(ActionIcon as never, stripEventHandlers(props));
+}
+
+function SafeBurger(props: Record) {
+ return createElement(Burger as never, stripEventHandlers(props));
+}
+
+function SafeCloseButton(props: Record) {
+ return createElement(CloseButton as never, stripEventHandlers(props));
+}
+
+function SafeChip(props: Record) {
+ return createElement(Chip as never, stripEventHandlers(props));
+}
+
+function SafeNotification(props: Record) {
+ return createElement(Notification as never, stripEventHandlers(props));
+}
+
+function SafeRating(props: Record) {
+ return createElement(Rating as never, { ...stripEventHandlers(props), readOnly: true });
+}
+
+function SafeSlider(props: Record) {
+ return createElement(Slider as never, { ...stripEventHandlers(props), readOnly: true });
+}
+
+function SafeSwitch(props: Record) {
+ return createElement(Switch as never, { ...stripEventHandlers(props), readOnly: true });
+}
+
+function SafeSegmentedControl(props: Record) {
+ return createElement(SegmentedControl as never, stripEventHandlers(props));
+}
+
+function SafePagination(props: Record) {
+ return createElement(Pagination as never, stripEventHandlers(props));
+}
+
+function SafeStepper(props: Record) {
+ return createElement(Stepper as never, stripEventHandlers(props));
+}
+
+function SafeCalendar(props: Record) {
+ return createElement(Calendar as never, { ...stripEventHandlers(props), static: true });
+}
+
+function SafeMiniCalendar(props: Record) {
+ return createElement(MiniCalendar as never, { ...stripEventHandlers(props), static: true });
+}
+
+function SafeTree(props: Record) {
+ return createElement(Tree as never, stripEventHandlers(props));
+}
+
+function SafeDatePicker(props: Record) {
+ return createElement(DatePicker as never, { ...stripEventHandlers(props), static: true });
+}
+
+function SafeCopyButton({ value }: { value?: string; children?: ReactNode }) {
+ const [copied, setCopied] = useState(false);
+ const handleCopy = () => {
+ if (!value) return;
+ void navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+ return createElement(Button as never, {
+ size: "xs",
+ variant: copied ? "filled" : "light",
+ color: copied ? "teal" : "blue",
+ onClick: handleCopy,
+ children: copied ? "Copied!" : "Copy",
+ });
}
export const WHITELISTED_COMPONENTS: Record> = {
+ // Layout
+ Box,
Stack,
Group,
Flex,
@@ -80,6 +218,10 @@ export const WHITELISTED_COMPONENTS: Record> = {
Space,
Container,
AspectRatio,
+ Overlay,
+ ScrollArea,
+
+ // Typography & text
Text,
Title,
Code,
@@ -89,6 +231,10 @@ export const WHITELISTED_COMPONENTS: Record> = {
Blockquote,
Anchor: SafeAnchor,
NumberFormatter,
+ Marquee,
+ RollingNumber,
+
+ // Data display
Badge,
Card,
"Card.Section": Card.Section,
@@ -99,6 +245,8 @@ export const WHITELISTED_COMPONENTS: Record> = {
Table,
"Table.Thead": Table.Thead,
"Table.Tbody": Table.Tbody,
+ "Table.Tfoot": Table.Tfoot,
+ "Table.Caption": Table.Caption,
"Table.Tr": Table.Tr,
"Table.Th": Table.Th,
"Table.Td": Table.Td,
@@ -116,6 +264,7 @@ export const WHITELISTED_COMPONENTS: Record> = {
Progress,
"Progress.Section": Progress.Section,
RingProgress,
+ SemiCircleProgress,
Skeleton,
Loader,
Image,
@@ -124,7 +273,59 @@ export const WHITELISTED_COMPONENTS: Record> = {
BackgroundImage,
Tooltip,
Divider,
- ScrollArea,
+ DataList,
+ "DataList.Item": DataList.Item,
+ "DataList.ItemLabel": DataList.ItemLabel,
+ "DataList.ItemValue": DataList.ItemValue,
+ EmptyState,
+ "EmptyState.Indicator": EmptyState.Indicator,
+ "EmptyState.Title": EmptyState.Title,
+ "EmptyState.Description": EmptyState.Description,
+ "EmptyState.Actions": EmptyState.Actions,
+ Fieldset,
+ Notification: SafeNotification,
+ Rating: SafeRating,
+
+ // Navigation & structure
+ Breadcrumbs,
+ NavLink: SafeNavLink,
+ Stepper: SafeStepper,
+ "Stepper.Step": Stepper.Step,
+ Tabs,
+ "Tabs.List": Tabs.List,
+ "Tabs.Tab": Tabs.Tab,
+ "Tabs.Panel": Tabs.Panel,
+ Tree: SafeTree,
+
+ // Interactive display (handlers stripped — wired via SubFetch separately)
+ Button: SafeButton,
+ ActionIcon: SafeActionIcon,
+ Burger: SafeBurger,
+ CloseButton: SafeCloseButton,
+ Chip: SafeChip,
+ "Chip.Group": Chip.Group,
+ Pagination: SafePagination,
+ SegmentedControl: SafeSegmentedControl,
+ Slider: SafeSlider,
+ Switch: SafeSwitch,
+
+ // Hover overlays & menus
+ HoverCard,
+ "HoverCard.Target": HoverCard.Target,
+ "HoverCard.Dropdown": HoverCard.Dropdown,
+ Menu,
+ "Menu.Target": Menu.Target,
+ "Menu.Dropdown": Menu.Dropdown,
+ "Menu.Item": Menu.Item,
+ "Menu.Label": Menu.Label,
+ "Menu.Divider": Menu.Divider,
+
+ // Utility
+ CopyButton: SafeCopyButton,
+ Collapse,
+ Transition,
+
+ // Charts
AreaChart,
BarChart,
LineChart,
@@ -133,12 +334,35 @@ export const WHITELISTED_COMPONENTS: Record> = {
RadarChart,
RadialBarChart,
Sparkline,
+ BubbleChart,
+ CompositeChart,
+ FunnelChart,
+ Heatmap,
+ ScatterChart,
+ SankeyChart,
+ Treemap,
+ BarsList,
+
+ // Dates
+ Calendar: SafeCalendar,
+ MiniCalendar: SafeMiniCalendar,
+ DatePicker: SafeDatePicker,
+ TimeValue,
+
+ // Custom interactive components (own state management)
PaginatedList,
TabsContainer,
TabPanel,
Collapsible,
StatBar,
TypeBadge,
+
+ // SubFetch — server-proxied HTTP from within templates
+ SubFetch,
+ SubData,
+ ActionButton,
+ ToggleSwitch,
+ RefreshButton,
};
function sanitizeData(obj: unknown): unknown {
@@ -172,6 +396,7 @@ Object.freeze(safeJSON);
const safeArray: Record = Object.create(null);
safeArray.isArray = (v: unknown) => Array.isArray(v);
+safeArray.from = (v: ArrayLike | Iterable) => Array.from(v);
Object.freeze(safeArray);
const safeObject: Record = Object.create(null);
@@ -180,6 +405,20 @@ safeObject.values = (v: object) => Object.values(v);
safeObject.entries = (v: object) => Object.entries(v);
Object.freeze(safeObject);
+const safeDate: Record = Object.create(null);
+safeDate.now = () => Date.now();
+safeDate.create = (v?: string | number) => (v != null ? new Date(v) : new Date());
+safeDate.toISOString = (v: string | number) => new Date(v).toISOString();
+safeDate.toLocaleDateString = (v: string | number, locale?: string) =>
+ new Date(v).toLocaleDateString(locale ?? "en-US");
+safeDate.toLocaleTimeString = (v: string | number, locale?: string) =>
+ new Date(v).toLocaleTimeString(locale ?? "en-US");
+safeDate.getTime = (v: string | number) => new Date(v).getTime();
+safeDate.getYear = (v: string | number) => new Date(v).getFullYear();
+safeDate.getMonth = (v: string | number) => new Date(v).getMonth();
+safeDate.getDay = (v: string | number) => new Date(v).getDate();
+Object.freeze(safeDate);
+
export const SAFE_BINDINGS = (apiData: unknown) => ({
data: sanitizeData(apiData),
String: (v: unknown) => String(v),
@@ -189,4 +428,11 @@ export const SAFE_BINDINGS = (apiData: unknown) => ({
JSON: safeJSON,
Array: safeArray,
Object: safeObject,
+ Date: safeDate,
+ parseInt: (v: string, radix?: number) => parseInt(v, radix),
+ parseFloat: (v: string) => parseFloat(v),
+ encodeURIComponent: (v: string) => encodeURIComponent(v),
+ decodeURIComponent: (v: string) => decodeURIComponent(v),
+ isNaN: (v: unknown) => Number.isNaN(Number(v)),
+ isFinite: (v: unknown) => Number.isFinite(Number(v)),
});
diff --git a/packages/widgets/src/custom-api/widget-definition-context.tsx b/packages/widgets/src/custom-api/widget-definition-context.tsx
new file mode 100644
index 000000000..4db3a541a
--- /dev/null
+++ b/packages/widgets/src/custom-api/widget-definition-context.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { createContext, useContext } from "react";
+
+const WidgetDefinitionContext = createContext("");
+
+export function WidgetDefinitionProvider({
+ definitionId,
+ children,
+}: {
+ definitionId: string;
+ children: ReactNode;
+}) {
+ return {children};
+}
+
+export function useWidgetDefinitionId(): string {
+ return useContext(WidgetDefinitionContext);
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 574aad9dc..38b46700c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2587,6 +2587,9 @@ importers:
'@mantine/core':
specifier: 'catalog:'
version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@mantine/dates':
+ specifier: 'catalog:'
+ version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/form':
specifier: 'catalog:'
version: 9.4.1(react@19.2.7)