feat(custom-widgets): full Mantine v9 + SubFetch support for custom JSX
Massively expands the custom JSX widget system to support virtually all Mantine v9 components and adds in-widget HTTP request capabilities via a server-proxied SubFetch system. - Whitelist ~100 Mantine components (core, all 16 charts, dates) - Add SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton - Add server-side subFetch tRPC mutation with same-origin + auth - Show HTTP method warning badges in widget and admin preview - Remove `fetch` from forbidden keywords, increase template to 50KB - Expand bindings: Date.*, parseInt, parseFloat, Array.from, etc. - Update AI prompt with full component reference + demo templates
This commit is contained in:
12 files changed
+1164
-257
No files matched your search
@@ -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 ? <Text>Yes</Text> : <Text>No</Text>}\`
|
||||
- **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
|
||||
<PaginatedList pageSize={8}>
|
||||
{data.results.map(item =>
|
||||
<Card withBorder p="xs" mb="xs">
|
||||
<Text>{item.name}</Text>
|
||||
</Card>
|
||||
)}
|
||||
</PaginatedList>
|
||||
<SubFetch url="/api/v1/status" trigger="auto">
|
||||
<SubData path="name" as="Title" order={3} />
|
||||
<SubData path="status" as="Badge" color="green" />
|
||||
</SubFetch>
|
||||
\`\`\`
|
||||
|
||||
**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
|
||||
<TabsContainer defaultTab="overview">
|
||||
<TabPanel value="overview" label="Overview">
|
||||
<Text>{data.description}</Text>
|
||||
</TabPanel>
|
||||
<TabPanel value="stats" label="Statistics">
|
||||
<Text>Total: {data.count}</Text>
|
||||
</TabPanel>
|
||||
</TabsContainer>
|
||||
<ActionButton url="/api/lights/1/toggle" method="POST" label="Toggle Light" color="yellow" icon="power" confirmMessage="Toggle the light?" />
|
||||
\`\`\`
|
||||
|
||||
**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
|
||||
<Collapsible title="Details" defaultOpen={true}>
|
||||
<Text>{data.details}</Text>
|
||||
</Collapsible>
|
||||
<ToggleSwitch url="/api/lights/1" onBody='{"state":"on"}' offBody='{"state":"off"}' label="Living Room" color="yellow" />
|
||||
\`\`\`
|
||||
|
||||
**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
|
||||
<StatBar label="HP" value={45} max={100} color="red" />
|
||||
<StatBar label="ATK" value={80} max={255} color="orange" />
|
||||
\`\`\`
|
||||
**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
|
||||
<Group gap="xs">
|
||||
<TypeBadge type="fire" />
|
||||
<TypeBadge type="flying" />
|
||||
</Group>
|
||||
\`\`\`
|
||||
### 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 => <Text>{item.name}</Text>)}\`
|
||||
- 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: \`<NumberFormatter value={data.price} thousandSeparator prefix="$" />\`
|
||||
### 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
|
||||
<Stack gap="sm" p="xs">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={data.sprites.front_default} size={80} radius="sm" />
|
||||
<Stack gap={4} style={{flex: 1}}>
|
||||
<Title order={3} tt="capitalize">{data.name}</Title>
|
||||
<Text size="xs" c="dimmed">#{String(data.id).padStart(3, "0")}</Text>
|
||||
<Group gap="xs">
|
||||
{data.types.map(t => <TypeBadge type={t.type.name} />)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
<TabsContainer defaultTab="stats">
|
||||
<TabPanel value="stats" label="Stats">
|
||||
<Stack gap="xs" pt="xs">
|
||||
{data.stats.map(s =>
|
||||
<StatBar label={s.stat.name} value={s.base_stat} max={255} color={s.base_stat > 100 ? "green" : "red"} />
|
||||
)}
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
<TabPanel value="abilities" label="Abilities">
|
||||
<Stack gap="xs" pt="xs">
|
||||
{data.abilities.map(a =>
|
||||
<Badge variant={a.is_hidden ? "outline" : "filled"} tt="capitalize">
|
||||
{a.ability.name}{a.is_hidden ? " (hidden)" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
<TabPanel value="moves" label="Moves">
|
||||
<Collapsible title="Move list">
|
||||
<Group gap="xs" pt="xs">
|
||||
{data.moves.slice(0, 20).map(m =>
|
||||
<Badge size="xs" variant="light" tt="capitalize">{m.move.name}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Collapsible>
|
||||
</TabPanel>
|
||||
</TabsContainer>
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### 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
|
||||
<Stack gap="sm" p="xs">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Pokédex</Title>
|
||||
<Badge size="lg" color="red">{data.count} Pokémon</Badge>
|
||||
<Badge size="lg" color="red">{data.count} total</Badge>
|
||||
</Group>
|
||||
<PaginatedList pageSize={10}>
|
||||
{data.results.map((pokemon, i) =>
|
||||
<Card withBorder p="xs" mb="xs">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar
|
||||
src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"}
|
||||
size="lg"
|
||||
radius="sm"
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1 }}>
|
||||
<Avatar src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"} size="lg" radius="sm" />
|
||||
<Stack gap={0} style={{flex: 1}}>
|
||||
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
|
||||
<Text size="xs" c="dimmed">#{String(i + 1)}</Text>
|
||||
<Text size="xs" c="dimmed">#{String(i + 1).padStart(3, "0")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
@@ -255,6 +156,90 @@ API: \`https://pokeapi.co/api/v2/pokemon?limit=50\`
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### Pokédex with Detail Fetch (SubFetch for sub-resources)
|
||||
API: \`https://pokeapi.co/api/v2/pokemon?limit=75\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm" p="xs">
|
||||
<Title order={3}>Pokédex</Title>
|
||||
<PaginatedList pageSize={5}>
|
||||
{data.results.map((pokemon, i) =>
|
||||
<Card withBorder p="xs" mb="xs">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"} size={60} radius="sm" />
|
||||
<Stack gap={2} style={{flex: 1}}>
|
||||
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
|
||||
<SubFetch url={pokemon.url} trigger="auto">
|
||||
<Group gap="xs">
|
||||
<SubData path="types.0.type.name" as="Badge" color="red" />
|
||||
<SubData path="stats.0.base_stat" as="Text" size="xs" />
|
||||
</Group>
|
||||
</SubFetch>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### Stocks Dashboard (Charts + Live Data)
|
||||
API: \`https://your-stocks-api.com/portfolio\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Portfolio</Title>
|
||||
<RefreshButton label="Refresh" size="xs" />
|
||||
</Group>
|
||||
<SimpleGrid cols={3}>
|
||||
{data.holdings.map(stock =>
|
||||
<Card withBorder p="xs">
|
||||
<Text fw={700}>{stock.symbol}</Text>
|
||||
<NumberFormatter value={stock.price} prefix="$" decimalScale={2} />
|
||||
<Sparkline data={stock.history} h={40} color={stock.change > 0 ? "green" : "red"} />
|
||||
</Card>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<LineChart h={200} data={data.timeline} dataKey="date" series={[{name: "value", color: "blue"}]} />
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### Navidrome Mini-Player (Buttons + State)
|
||||
API: \`https://your-navidrome.local/api/nowplaying\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm" p="sm">
|
||||
<Group wrap="nowrap">
|
||||
<Image src={data.albumArt} w={80} h={80} radius="sm" />
|
||||
<Stack gap={2} style={{flex: 1}}>
|
||||
<Text fw={700} lineClamp={1}>{data.title}</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>{data.artist}</Text>
|
||||
<Progress value={data.progress} size="xs" color="red" />
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group justify="center" gap="sm">
|
||||
<ActionButton url="/api/player/previous" method="POST" label="Prev" size="xs" variant="subtle" />
|
||||
<ActionButton url="/api/player/toggle" method="POST" label={data.playing ? "Pause" : "Play"} icon="play" color="red" />
|
||||
<ActionButton url="/api/player/next" method="POST" label="Next" size="xs" variant="subtle" />
|
||||
</Group>
|
||||
<Group justify="center">
|
||||
<ToggleSwitch url="/api/player/shuffle" method="POST" onBody='{"shuffle":true}' offBody='{"shuffle":false}' initialValue={data.shuffle} label="Shuffle" size="xs" />
|
||||
<ToggleSwitch url="/api/player/repeat" method="POST" onBody='{"repeat":true}' offBody='{"repeat":false}' initialValue={data.repeat} label="Repeat" size="xs" />
|
||||
</Group>
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### Smart Home Light Switch
|
||||
API: \`https://your-ha.local/api/states/light.living_room\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>Living Room</Title>
|
||||
<Badge color={data.state === "on" ? "yellow" : "gray"}>{data.state}</Badge>
|
||||
</Group>
|
||||
<ToggleSwitch url="/api/services/light/toggle" method="POST" onBody='{"entity_id":"light.living_room"}' offBody='{"entity_id":"light.living_room"}' initialValue={data.state === "on"} label="Power" color="yellow" />
|
||||
<ActionButton url="/api/services/light/turn_off" method="POST" body='{"entity_id":"light.living_room"}' label="Force Off" color="red" icon="power" confirmMessage="Turn off?" />
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
## API Response
|
||||
`;
|
||||
|
||||
|
||||
@@ -1393,9 +1393,9 @@ function DisplayTypeFields({
|
||||
'<Stack gap="sm">\n <Title order={3}>{data.name}</Title>\n <Text>{data.description}</Text>\n</Stack>'
|
||||
}
|
||||
minRows={8}
|
||||
maxRows={20}
|
||||
maxRows={30}
|
||||
autosize
|
||||
maxLength={10000}
|
||||
maxLength={50000}
|
||||
styles={{ input: { fontFamily: "monospace" } }}
|
||||
{...form.getInputProps("template")}
|
||||
/>
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}, [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({
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{httpMethods.length > 0 && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconNetwork size={14} />}>
|
||||
<Group gap={4} align="center">
|
||||
<Text size="xs" fw={500}>
|
||||
Widget makes HTTP requests:
|
||||
</Text>
|
||||
{httpMethods.map((m) => (
|
||||
<Badge key={m} size="xs" color={m === "DELETE" ? "red" : m === "GET" ? "blue" : "orange"} variant="filled">
|
||||
{m}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{testError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{testError}</Text>
|
||||
|
||||
@@ -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<unknown> => {
|
||||
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);
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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",
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 <Component data={enrichedData} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
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 (/<ActionButton\b/.test(template) && methods.size === 0) methods.add("POST");
|
||||
if (/<ToggleSwitch\b/.test(template) && methods.size === 0) methods.add("POST");
|
||||
if (/<SubFetch\b/.test(template) && methods.size === 0) methods.add("GET");
|
||||
}
|
||||
return [...methods].sort();
|
||||
}
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "blue",
|
||||
POST: "orange",
|
||||
PUT: "yellow",
|
||||
DELETE: "red",
|
||||
PATCH: "grape",
|
||||
};
|
||||
|
||||
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const template = String(data.template ?? "");
|
||||
const apiData = data.data;
|
||||
const definitionId = String(data.widgetDefinitionId ?? "");
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
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<string, unknow
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
<JsxParser
|
||||
jsx={template}
|
||||
components={WHITELISTED_COMPONENTS as never}
|
||||
bindings={bindings}
|
||||
disableKeyGeneration
|
||||
componentsOnly
|
||||
allowUnknownElements={false}
|
||||
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
|
||||
blacklistedTags={["script", "iframe", "object", "embed", "form", "style", "link", "meta", "base"]}
|
||||
onError={handleError}
|
||||
renderError={({ error }) => (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{String(error)}</Text>
|
||||
<WidgetDefinitionProvider definitionId={definitionId}>
|
||||
<Stack gap={0} h="100%">
|
||||
{httpMethods.length > 0 && (
|
||||
<Alert color="yellow" variant="light" p={4} mb={4} icon={<IconNetwork size={14} />}>
|
||||
<Group gap={4} align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
HTTP:
|
||||
</Text>
|
||||
{httpMethods.map((m) => (
|
||||
<Badge key={m} size="xs" color={METHOD_COLORS[m] ?? "gray"} variant="filled">
|
||||
{m}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
/>
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="yellow" variant="light" p="xs" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{parseErrors.length} template warning(s):
|
||||
</Text>
|
||||
{parseErrors.map((msg) => (
|
||||
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{msg}
|
||||
<JsxParser
|
||||
jsx={template}
|
||||
components={WHITELISTED_COMPONENTS as never}
|
||||
bindings={bindings}
|
||||
disableKeyGeneration
|
||||
componentsOnly
|
||||
allowUnknownElements={false}
|
||||
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
|
||||
blacklistedTags={["script", "iframe", "object", "embed", "form", "style", "link", "meta", "base"]}
|
||||
onError={handleError}
|
||||
renderError={({ error }) => (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{String(error)}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
/>
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="yellow" variant="light" p="xs" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{parseErrors.length} template warning(s):
|
||||
</Text>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
{parseErrors.map((msg) => (
|
||||
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</WidgetDefinitionProvider>
|
||||
);
|
||||
}
|
||||
@@ -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<string, number[]>();
|
||||
|
||||
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<string, ComponentType<{ size?: number | string }>> = {
|
||||
play: IconPlayerPlay,
|
||||
check: IconCheck,
|
||||
refresh: IconRefresh,
|
||||
power: IconPower,
|
||||
trash: IconTrash,
|
||||
};
|
||||
|
||||
const SubFetchDataContext = createContext<unknown>(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<string, string> | 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<string, string> = {};
|
||||
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<unknown>((acc, key) => {
|
||||
if (acc == null || typeof acc !== "object") return undefined;
|
||||
return (acc as Record<string, unknown>)[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<SubFetchResult> => {
|
||||
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<unknown>(undefined);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Button color={color} variant={variant as never} onClick={() => void runFetch()} loading={isPending}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<Skeleton height={8} radius="xl" mb="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{loadingText}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{error}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const contextValue = subData;
|
||||
|
||||
if (display === "json") {
|
||||
return (
|
||||
<SubFetchDataContext.Provider value={contextValue}>
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(subData, null, 2)}
|
||||
</Code>
|
||||
</SubFetchDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (display === "text" && path) {
|
||||
return (
|
||||
<SubFetchDataContext.Provider value={contextValue}>
|
||||
<Text size="sm">{formatDisplayValue(getByPath(subData, path))}</Text>
|
||||
</SubFetchDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!children) {
|
||||
return (
|
||||
<SubFetchDataContext.Provider value={contextValue}>
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(subData, null, 2)}
|
||||
</Code>
|
||||
</SubFetchDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return <SubFetchDataContext.Provider value={contextValue}>{children}</SubFetchDataContext.Provider>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const displayValue = as === "Code" ? JSON.stringify(value, null, 2) : formatDisplayValue(value);
|
||||
|
||||
if (as === "Title") {
|
||||
return (
|
||||
<Title order={(props.order as 1 | 2 | 3 | 4 | 5 | 6) ?? 3} c={props.c}>
|
||||
{displayValue}
|
||||
</Title>
|
||||
);
|
||||
}
|
||||
|
||||
if (as === "Badge") {
|
||||
return (
|
||||
<Badge color={props.color} variant={props.variant as never} size={props.size as never}>
|
||||
{displayValue}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (as === "Code") {
|
||||
return <Code block>{displayValue}</Code>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size={props.size as never} fw={props.fw} c={props.c}>
|
||||
{displayValue}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button
|
||||
color={color}
|
||||
variant={variant as never}
|
||||
size={size as never}
|
||||
onClick={handleClick}
|
||||
loading={isPending}
|
||||
leftSection={Icon ? <Icon size={16} /> : undefined}
|
||||
fullWidth={parseBool(fullWidth)}
|
||||
disabled={parseBool(disabled)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Switch
|
||||
label={label}
|
||||
color={color}
|
||||
size={size as never}
|
||||
checked={checked}
|
||||
onChange={(event) => 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 (
|
||||
<Button
|
||||
color={color}
|
||||
variant={variant as never}
|
||||
size={size as never}
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => void handleRefresh()}
|
||||
loading={loading}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
const safe: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (/^on[A-Z]/.test(key)) continue;
|
||||
safe[key] = value;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function SafeAnchor(props: Record<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
const href = props.href;
|
||||
const safeHref = href && isSafeUrl(href) ? href : undefined;
|
||||
return createElement(NavLink as never, { ...stripEventHandlers(props), href: safeHref });
|
||||
}
|
||||
|
||||
function SafeButton(props: Record<string, unknown>) {
|
||||
return createElement(Button as never, stripEventHandlers(props));
|
||||
}
|
||||
|
||||
function SafeActionIcon(props: Record<string, unknown>) {
|
||||
return createElement(ActionIcon as never, stripEventHandlers(props));
|
||||
}
|
||||
|
||||
function SafeBurger(props: Record<string, unknown>) {
|
||||
return createElement(Burger as never, stripEventHandlers(props));
|
||||
}
|
||||
|
||||
function SafeCloseButton(props: Record<string, unknown>) {
|
||||
return createElement(CloseButton as never, stripEventHandlers(props));
|
||||
}
|
||||
|
||||
function SafeChip(props: Record<string, unknown>) {
|
||||
return createElement(Chip as never, stripEventHandlers(props));
|
||||
}
|
||||
|
||||
function SafeNotification(props: Record<string, unknown>) {
|
||||
return createElement(Notification as never, stripEventHandlers(props));
|
||||
}
|
||||
|
||||
function SafeRating(props: Record<string, unknown>) {
|
||||
return createElement(Rating as never, { ...stripEventHandlers(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafeSlider(props: Record<string, unknown>) {
|
||||
return createElement(Slider as never, { ...stripEventHandlers(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafeSwitch(props: Record<string, unknown>) {
|
||||
return createElement(Switch as never, { ...stripEventHandlers(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafeSegmentedControl(props: Record<string, unknown>) {
|
||||
return createElement(SegmentedControl as never, stripEventHandlers(props));
|
||||
}
|
||||
|
||||
function SafePagination(props: Record<string, unknown>) {
|
||||
return createElement(Pagination as never, stripEventHandlers(props));
|
||||
}
|
||||
|
||||
function SafeStepper(props: Record<string, unknown>) {
|
||||
return createElement(Stepper as never, stripEventHandlers(props));
|
||||
}
|
||||
|
||||
function SafeCalendar(props: Record<string, unknown>) {
|
||||
return createElement(Calendar as never, { ...stripEventHandlers(props), static: true });
|
||||
}
|
||||
|
||||
function SafeMiniCalendar(props: Record<string, unknown>) {
|
||||
return createElement(MiniCalendar as never, { ...stripEventHandlers(props), static: true });
|
||||
}
|
||||
|
||||
function SafeTree(props: Record<string, unknown>) {
|
||||
return createElement(Tree as never, stripEventHandlers(props));
|
||||
}
|
||||
|
||||
function SafeDatePicker(props: Record<string, unknown>) {
|
||||
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<string, ComponentType<never>> = {
|
||||
// Layout
|
||||
Box,
|
||||
Stack,
|
||||
Group,
|
||||
Flex,
|
||||
@@ -80,6 +218,10 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
Space,
|
||||
Container,
|
||||
AspectRatio,
|
||||
Overlay,
|
||||
ScrollArea,
|
||||
|
||||
// Typography & text
|
||||
Text,
|
||||
Title,
|
||||
Code,
|
||||
@@ -89,6 +231,10 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
Blockquote,
|
||||
Anchor: SafeAnchor,
|
||||
NumberFormatter,
|
||||
Marquee,
|
||||
RollingNumber,
|
||||
|
||||
// Data display
|
||||
Badge,
|
||||
Card,
|
||||
"Card.Section": Card.Section,
|
||||
@@ -99,6 +245,8 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
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<string, ComponentType<never>> = {
|
||||
Progress,
|
||||
"Progress.Section": Progress.Section,
|
||||
RingProgress,
|
||||
SemiCircleProgress,
|
||||
Skeleton,
|
||||
Loader,
|
||||
Image,
|
||||
@@ -124,7 +273,59 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
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<string, ComponentType<never>> = {
|
||||
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<string, unknown> = Object.create(null);
|
||||
safeArray.isArray = (v: unknown) => Array.isArray(v);
|
||||
safeArray.from = <T>(v: ArrayLike<T> | Iterable<T>) => Array.from(v);
|
||||
Object.freeze(safeArray);
|
||||
|
||||
const safeObject: Record<string, unknown> = 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<string, unknown> = 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)),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const WidgetDefinitionContext = createContext<string>("");
|
||||
|
||||
export function WidgetDefinitionProvider({
|
||||
definitionId,
|
||||
children,
|
||||
}: {
|
||||
definitionId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <WidgetDefinitionContext.Provider value={definitionId}>{children}</WidgetDefinitionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useWidgetDefinitionId(): string {
|
||||
return useContext(WidgetDefinitionContext);
|
||||
}
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user