diff --git a/apps/docs/docs/community/faq.mdx b/apps/docs/docs/community/faq.mdx index 465c7bbaf..55736cb33 100644 --- a/apps/docs/docs/community/faq.mdx +++ b/apps/docs/docs/community/faq.mdx @@ -5,43 +5,47 @@ tags: - Help --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; ### Can I install Homarr on a Raspberry Pi? + Yes, Homarr is compatible with the architecture of the Raspberry Pi. Have a look in our installation guide on how to do so. ### I have a suggestion / idea for improvement or want to report a bug + You can report issues, suggestions, or ideas for improvements here. ### Can I use my own custom icons for apps? + Yes, check out our guide on how to add your custom icons. ### Some of my online icons are not working (anymore) + It seems the `cdn.jsdelivr.net` database of icon sometimes appear in the lists for DNS-Holes (PiHole, AdGuard Home...).
So make sure to manually whitelist this address. ### Can I add my own widgets? Do I need programming skills? -We have decided, not to support custom widgets, until Homarr is in a stable and highly polished state. -Enabling users to write their own widgets is easy at the first glance, but there are some major issues with it: -- There are mainly two options for adding plugins or custom widgets to Homarr. The first option is, to allow and inject custom code in Homarr. Apps like Nextcloud can do this easily, because they use the Runtime to render all components of the application (In reality, the process is still a bit more complicated). In contrast, Homarr is using Next.js - a framework that needs to be compiled in production. That means, that each time you would modify a custom widget or plugin, the app would have to be restarted. There are options to avoid this, but they are quite flakey and bring disadvantages in our experience. The second option is, to write widgets declaratively instead of imperatively. This way, we would be able to render and modify widgets without any restarting at all. However, we'd have to write our own interpreter for said declarative schema. This is totally possible, but requires a lot of planning and architecture design to function properly. The required work to implement either option is therefore quite substantial. -- Custom widgets would need to be able to request data, so they work properly. We use tRPC for this. Many APIs use different formats and we'd have to convert and read those. -- Using custom widgets, one could easily create harmful widgets, leak your personal data (potentially including your tokens) and trick your users. A proper implementation (eg. official plugin store, where we can ban harmful widgets from) would resolve this problem to a certain extent, but again requires additional work and time to be put in. +Yes. Administrators can create [Custom Widgets](/docs/management/custom-widgets) that read an HTTP API and render the response with a built-in display type. These display types require no programming. -If you still want to add a widget, please consider to contribute it directly to Homarr. For that, basic programming skills are required. Our [Developer Guides](/docs/advanced/development/getting-started) explain this even more detailed. +Custom JSX is available when you need a more specific layout or an interactive control. It uses a restricted declarative JSX interpreter with a classified Mantine component set. Network operations are declared as named, same-origin requests and are checked against board permissions on the server. It does not execute arbitrary JavaScript or expose browser `fetch`. + +You should be comfortable reading JSON and JSX to author complex templates. The management workbench includes examples, diagnostics, a component reference, API response inspection, and a prompt you can copy into an AI assistant. Always review imported templates and request capabilities before saving them. ### Is it secure to expose Homarr to the web? -Yes, since v0.14.0, we are confident that Homarr is secure enough to be exposed to the web. +Yes, since v0.14.0, we are confident that Homarr is secure enough to be exposed to the web. Make sure to enable the "allow anonymous access" [option in the settings](/docs/management/boards#access-control), if you want to allow people without an account to be able to see your dashboard (not edit) ### How do I open the console / log? + There are two different sources of information: + - console: This is your client (browser). It can tell us, if there is an issue on the client (crash, bug, ...). - log: This is your server. It will tell us, if there are issues with connections for the integrations, crashes and errors. -Both variants *can* contain personal data. It's recommended, that you don't post them publicly or remove personal data from it. +Both variants _can_ contain personal data. It's recommended, that you don't post them publicly or remove personal data from it. @@ -49,39 +53,45 @@ Both variants *can* contain personal data. It's recommended, that you don't post > Note: This will not work on most mobile browsers. Open your browser and ensure, that Homarr is your current tab. -Press ``CTRL`` + ``SHIFT`` + ``I`` on your keyboard. If this shortcut doesn't work, please use one of the alternatives: https://developer.chrome.com/docs/devtools/open/. -Click on ``Console`` in the new window, that opened. There, you should see white, orange and sometimes red messages. You can use the Windows Snip & Sketch tool (or your respective tool on Linux / MacOS) to screenshot those messages. +Press `CTRL` + `SHIFT` + `I` on your keyboard. If this shortcut doesn't work, please use one of the alternatives: https://developer.chrome.com/docs/devtools/open/. +Click on `Console` in the new window, that opened. There, you should see white, orange and sometimes red messages. You can use the Windows Snip & Sketch tool (or your respective tool on Linux / MacOS) to screenshot those messages. > Please never execute any commands in the console. Scammers are using the console to let you execute harmful commands. If you want to resolve an issue in Homarr, **you'll never have to execute any commands here** -This depends on your installation method. If you use Unraid, you can simply click on the App and on ``Log``. If you have a bare Docker install, use ``docker container logs homarr``. If you used a different name than ``homarr`` for your container, you need to replace said argument. Here is some additional information about the command: https://geekflare.com/check-docker-logs/. +This depends on your installation method. If you use Unraid, you can simply click on the App and on `Log`. If you have a bare Docker install, use `docker container logs homarr`. If you used a different name than `homarr` for your container, you need to replace said argument. Here is some additional information about the command: https://geekflare.com/check-docker-logs/. ### How do I post logs? + Please check [the section above](#how-do-i-open-the-console--log), on how to obtain logs. Logs should always be posted to https://pastebin.com/ or https://rentry.co/. ### Can I use Homarr at my company? + Yes, you can! But please consider a donation, if our work has been useful to you. ### I think I found a bug. What can I do? + If you think, that you found a bug, please create an issue here. ### Why can't I use my custom icons? + You need to restart the Homarr Docker container after adding icons. ### Why can't homarr see/ping containers running locally behind GluetunVPN? + This is because GluetunVPN, and many other docker VPN services, secure their VPN connections with strict firewalls. These firewalls often block access to local containers, even on the same docker network. The workaround for GluetunVPN is to add "- FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24" into the environmental variables of the docker compose file. If you are using a firewall on your system, you will need to add a rule that allows requests from ip's in the range of 192.168.xxx.xxx . If you are using UFW the command would be "sudo ufw allow from 192.168.0.0/16". Tip: If you are running your torrent client behind a VPN, and running Sonarr (or something similar) normally, make sure to open your torrent client's port in your firewall. If you don't, Sonarr will not be able to see it. Furthermore, use your machines IP (192.168.x.x) rather then 127.0.0.1 or localhost. ### Can I get in touch with you? + Yes, go here to get with us in touch. -
+
:::tip diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 0395253dc..07099c696 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -6,203 +6,188 @@ tags: - JSX --- +import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference"; + # Custom Widgets -:::warning +Custom widgets connect a dashboard item to an HTTP API without compiling a new Homarr widget. Administrators choose the endpoint and authentication, then use a built-in display type or a Custom JSX template to render the response. -Custom widgets are a new feature. Share feedback, ask questions, and discuss ideas in our [GitHub Discussions](https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets). +:::caution + +A custom widget can expose data or perform actions with the credentials configured for it. Import definitions only from sources you trust and review every network capability before using the widget on a shared board. ::: -Custom widgets allow you to fetch data from any HTTP API and display it on your Homarr dashboard. You define the API endpoint, authentication, and how the response should be rendered - no code compilation or server restart required. +## Create a widget -## Creating a Custom Widget +Open **Management → Custom Widgets**, select **Add**, and configure: -To create a custom widget, navigate to the **Custom Widgets** section in the management sidebar and click the **Add** button. Fill in the configuration: +- **Name, description, and icon**: identify the widget in management and the widget picker. +- **URL**: the full HTTP or HTTPS base endpoint. +- **Authentication**: none, bearer token, basic authentication, API-key header, or API-key query parameter. +- **Display type**: a built-in renderer or Custom JSX. -- **Name** - Display name shown in the widget picker and on the dashboard -- **Description** - Optional description shown in tooltips and management UI -- **Icon URL** - Optional URL to an icon image for the widget -- **URL** - The API endpoint to fetch data from (must include protocol, e.g. `https://api.example.com/stats`) -- **Authentication** - How the API request is authenticated: - - `none` - Public APIs with no authentication - - `bearer` - Bearer token authentication - - `basic` - HTTP Basic auth (username + password) - - `apiKeyHeader` - API key sent as a custom HTTP header - - `apiKeyQuery` - API key sent as a query parameter -- **Header Name** - Custom header or query parameter name for API key auth (e.g. `X-API-Key`) -- **HTTP Method** - `GET`, `POST`, `PUT`, `PATCH`, or `DELETE` -- **Request Body** - JSON string body for `POST`/`PUT`/`PATCH` requests -- **Display Type** - How the API response is rendered (see below) +Use **Test** to run the current GET request. The preview workbench separates the rendered widget, sanitized response tree, redacted request journal, and diagnostics. It marks sample data as stale whenever the connection or display configuration changes after a test. -## Display Types +Testing creates a five-minute, administrator-bound preview session for unsaved named queries. Action controls are simulated by default. An administrator can explicitly enable live actions for that session; DELETE still requires confirmation. -| Display Type | Description | Best For | -| -------------------- | ---------------------------------------------------- | ---------------------------------------- | -| **Single Value** | Extracts a single value with a label and unit | Metrics: temperature, uptime, user count | -| **Key Value** | Multiple labeled key-value pairs with optional units | System stats: CPU, memory, disk | -| **Table** | API array data rendered as a sortable table | Lists of items with columns | -| **Stat Grid** | Labeled values in colored stat cards | Counts: movies, series, albums | -| **Progress Bars** | Values as progress bars with percentages | Usage: disk, quota, capacity | -| **Status Indicator** | Green/red status dots for health checks | Service monitoring | -| **Count Grid** | Simple counts in a grid (no colors) | Library counts | -| **Raw** | Pretty-prints the raw JSON response | Debugging | -| **Action Button** | Clickable button executing the API request | Triggers: restart, clear cache | -| **Custom JSX** | Custom JSX template with Mantine v9 components | Full layout control | +## Display types -### AI-Powered JSX Generation +| Display type | Use it for | +| ---------------- | --------------------------------------------------------- | +| Single Value | One metric with a label and unit | +| Key Value | Several labeled values | +| Table | Arrays of records | +| Stat Grid | Compact colored metrics | +| Progress Bars | Capacity, quota, and completion | +| Status Indicator | Health and availability | +| Count Grid | Compact counters | +| Raw | Sanitized JSON inspection | +| Action Button | One explicit API action | +| Custom JSX | A declarative Mantine layout with optional named requests | -When editing or creating a custom widget with the **Custom JSX** display type, you'll find a **Copy Prompt** button. Clicking it copies a pre-formatted prompt describing your widget's name, API endpoint, and available Mantine components. Paste this into your preferred AI assistant (ChatGPT, Claude, Copilot, etc.) to generate a JSX template tailored to your data. +## Custom JSX workbench -
-Example: Pokédex widget using Custom JSX +The JSX editor provides syntax highlighting, formatting, undo/redo buttons and Ctrl/Cmd+Z shortcuts, a searchable component reference, character and cursor status, and diagnostics. Errors block saving; warnings identify behavior that still receives server-side validation. Templates can contain up to 50,000 characters and render up to 10,000 nodes. + +The template reads the base GET response from `data`: + +```jsx + + {data.name} + {data.status} + +``` + +The **Copy AI prompt** action is available on desktop and mobile. Its prompt, component list, and examples are generated from the same metadata used by the renderer, so the reference stays aligned with the installed Homarr version. + +### JSX API versions + +- **Version 2** is the default. Network access is declared in a named-request manifest and enforced by the server. +- **Version 1** remains available for display-only templates. Inline network components are disabled until the definition is reviewed and migrated. + +Definitions without `jsxApiVersion` are treated as version 1. Display-only templates can be migrated by selecting version 2 and saving an empty request list. + +## Named requests + +Version 2 templates never choose an arbitrary URL, method, header, or body at runtime. An administrator declares each allowed operation once, and the template references its `requestId` with typed parameters. ```json { - "$schema": "homarr-custom-widget-v2", - "name": "Pokédex", - "description": "Browseable Pokémon list", - "iconUrl": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png", - "url": "https://pokeapi.co/api/v2/pokemon?limit=75", - "authType": "none", - "method": "GET", - "displayType": "customJsx", - "displayConfig": { - "type": "customJsx", - "template": "\n \n \n Pokédex\n \n \n \n \n {data.results.map((pokemon, i) =>\n \n \n \n \n \n #{i + 1}\n {pokemon.name}\n \n \n \n \n \n \n )}\n \n \n" - } + "type": "customJsx", + "jsxApiVersion": 2, + "networkScope": "public", + "template": "", + "requests": [ + { + "id": "service-detail", + "kind": "query", + "method": "GET", + "pathTemplate": "/api/services/{id}", + "parameters": { "id": "string" }, + "auth": "inherit", + "minimumBoardPermission": "view", + "cacheTtlSeconds": 30 + } + ] } ``` -
+Path placeholders use `{parameter}` and are URL-encoded by the server. JSON body placeholders use `{ "$param": "parameter" }` and preserve the declared string, number, or boolean type. -## Importing and Exporting +`SubData` remains available for simple paths. For richer nested output, pass an interpreter-created render child; it receives the response data and safe status metadata: -Custom widgets can be exported and imported as JSON files. To export, select the widget in the list and click the export button. To import, click the import button and paste the JSON. Secrets (API keys, passwords) are never included in exports and must be configured after import. +```jsx + + {(detail, meta) => ( + + {detail.status} + HTTP {meta.status} + + )} + +``` -The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the widget version. +| Component | Request kind | Behavior | +| --------------- | ------------ | ------------------------------------------------------------- | +| `SubFetch` | `query` | Loads a named GET request automatically or manually | +| `ActionButton` | `action` | Runs a named POST, PUT, PATCH, or DELETE after a user gesture | +| `ToggleSwitch` | `action` | Passes declared on/off parameters and rolls back on failure | +| `RefreshButton` | None | Refreshes the parent GET response | -## Available Mantine Components for Custom JSX +Queries require board **view** permission. POST, PUT, and PATCH actions require **modify** by default. DELETE requires **full** permission and confirmation. Allowing viewers to execute a non-DELETE action requires explicit administrator approval. Anonymous viewers cannot execute actions. -When using the `customJsx` display type, your template has access to a whitelist of [Mantine v9](https://mantine.dev) components. Unrecognized components, event handlers, and dangerous tags are stripped for security. +### Network scopes -### Layout +| Scope | Allowed destinations | +| ---------- | ------------------------------------------------------------ | +| `public` | Public IP addresses only | +| `private` | Public addresses plus RFC1918 and IPv6 ULA private addresses | +| `loopback` | Public, private, and loopback addresses | -| Component | Doc | -| ------------------- | --------------------------------------------- | -| `Stack` | [docs](https://mantine.dev/core/stack) | -| `Group` | [docs](https://mantine.dev/core/group) | -| `Flex` | [docs](https://mantine.dev/core/flex) | -| `Grid` / `Grid.Col` | [docs](https://mantine.dev/core/grid) | -| `SimpleGrid` | [docs](https://mantine.dev/core/simple-grid) | -| `Center` | [docs](https://mantine.dev/core/center) | -| `Container` | [docs](https://mantine.dev/core/container) | -| `Space` | [docs](https://mantine.dev/core/space) | -| `AspectRatio` | [docs](https://mantine.dev/core/aspect-ratio) | +Link-local, cloud-metadata, unspecified, multicast, documentation, carrier-grade NAT, and IPv4-mapped bypass addresses remain blocked in every scope. -### Typography & Content +### Example with a named query -| Component | Doc | -| ----------------- | ------------------------------------------------- | -| `Text` | [docs](https://mantine.dev/core/text) | -| `Title` | [docs](https://mantine.dev/core/title) | -| `Code` | [docs](https://mantine.dev/core/code) | -| `Highlight` | [docs](https://mantine.dev/core/highlight) | -| `Mark` | [docs](https://mantine.dev/core/mark) | -| `Kbd` | [docs](https://mantine.dev/core/kbd) | -| `Blockquote` | [docs](https://mantine.dev/core/blockquote) | -| `Anchor` | [docs](https://mantine.dev/core/anchor) | -| `NumberFormatter` | [docs](https://mantine.dev/core/number-formatter) | +The editor and AI prompt include a shared Pokédex fixture that loads details only for the visible page. Its named request is equivalent to: -### Data Display +```json +{ + "id": "pokemon-detail", + "kind": "query", + "method": "GET", + "pathTemplate": "/api/v2/pokemon/{name}", + "parameters": { "name": "string" }, + "auth": "none", + "minimumBoardPermission": "view", + "cacheTtlSeconds": 300 +} +``` -| Component | Doc | -| ------------------------- | ------------------------------------------------- | -| `Badge` | [docs](https://mantine.dev/core/badge) | -| `Card` / `Card.Section` | [docs](https://mantine.dev/core/card) | -| `Paper` | [docs](https://mantine.dev/core/paper) | -| `Alert` | [docs](https://mantine.dev/core/alert) | -| `ThemeIcon` | [docs](https://mantine.dev/core/theme-icon) | -| `ColorSwatch` | [docs](https://mantine.dev/core/color-swatch) | -| `Avatar` / `Avatar.Group` | [docs](https://mantine.dev/core/avatar) | -| `Image` | [docs](https://mantine.dev/core/image) | -| `BackgroundImage` | [docs](https://mantine.dev/core/background-image) | -| `Indicator` | [docs](https://mantine.dev/core/indicator) | -| `Pill` | [docs](https://mantine.dev/core/pill) | -| `Spoiler` | [docs](https://mantine.dev/core/spoiler) | +## Agent authoring through MCP -### Tables +Administrators can use Homarr's MCP server to iterate on custom widgets without bypassing validation or network controls. A typical agent workflow is: -| Component | Doc | -| --------------------------------------- | -------------------------------------- | -| `Table` / `Table.Thead` / `Table.Tbody` | [docs](https://mantine.dev/core/table) | -| `Table.Tr` / `Table.Th` / `Table.Td` | [docs](https://mantine.dev/core/table) | +1. Call `customWidget_schema` to inspect the current import format. +2. Generate a complete `homarr-custom-widget-v3` draft. +3. Call `customWidget_validate` and correct every returned issue. +4. Call `customWidget_preview` to test the base GET request through Homarr's hardened executor. +5. Use `customWidget_previewQuery` for named queries and `customWidget_simulatePreviewAction` to verify action parameters without sending mutations. +6. Inspect `customWidget_previewJournal`, then validate again. +7. Call `customWidget_import`, or read an existing widget with `customWidget_byId` and apply a focused `customWidget_update`. -### Lists +MCP preview actions are simulation-only. Enabling live preview actions remains an explicit administrator choice in the browser workbench. Stored secret values are never returned by MCP; omit `secrets` when updating an existing widget to preserve them. -| Component | Doc | -| ---------------------------- | ----------------------------------------- | -| `List` / `List.Item` | [docs](https://mantine.dev/core/list) | -| `Timeline` / `Timeline.Item` | [docs](https://mantine.dev/core/timeline) | +## Component reference -### Feedback & Loading +Custom JSX exposes a classified subset of Mantine Core, Charts, Dates, and Homarr components. Allowed components render directly. Wrapped components receive safe props or read-only behavior. Denied components remain in the registry so dependency upgrades cannot silently expand the template surface. -| Component | Doc | -| ------------------------------- | ---------------------------------------------- | -| `Progress` / `Progress.Section` | [docs](https://mantine.dev/core/progress) | -| `RingProgress` | [docs](https://mantine.dev/core/ring-progress) | -| `Skeleton` | [docs](https://mantine.dev/core/skeleton) | -| `Loader` | [docs](https://mantine.dev/core/loader) | + -### Navigation & Overlay +This table and the searchable component list in the editor are generated from Homarr's runtime registry. -| Component | Doc | -| ------------------------------------------------------------------------ | -------------------------------------------- | -| `TabsContainer` / `TabPanel` | Custom interactive component | -| `Accordion` / `Accordion.Item` / `Accordion.Control` / `Accordion.Panel` | [docs](https://mantine.dev/core/accordion) | -| `ScrollArea` | [docs](https://mantine.dev/core/scroll-area) | -| `Tooltip` | [docs](https://mantine.dev/core/tooltip) | +Component props are filtered. Polymorphic roots, portals, refs, unsafe positioning, raw event handlers, and `dangerouslySetInnerHTML` are not exposed. Direct browser globals and direct `fetch` calls are unavailable, while ordinary text containing the word “fetch” is valid. -### Charts +Safe bindings include conversion helpers, a restricted `Math` and `JSON` surface, object and array helpers, URI encoding helpers, and pure date formatting helpers. Collection callbacks and rendered output are subject to interpreter budgets. -| Component | Doc | -| ---------------- | --------------------------------------------------- | -| `AreaChart` | [docs](https://mantine.dev/charts/area-chart) | -| `BarChart` | [docs](https://mantine.dev/charts/bar-chart) | -| `LineChart` | [docs](https://mantine.dev/charts/line-chart) | -| `DonutChart` | [docs](https://mantine.dev/charts/donut-chart) | -| `PieChart` | [docs](https://mantine.dev/charts/pie-chart) | -| `RadarChart` | [docs](https://mantine.dev/charts/radar-chart) | -| `RadialBarChart` | [docs](https://mantine.dev/charts/radial-bar-chart) | -| `Sparkline` | [docs](https://mantine.dev/charts/sparkline) | +## HTTP protections and limits -### Interactive Components +All base, preview, query, and action requests use the same server executor: -| Component | Description | -| --------------- | ---------------------------------------- | -| `PaginatedList` | Client-side pagination wrapper | -| `Collapsible` | Expandable/collapsible section | -| `StatBar` | Horizontal stat bar | -| `TypeBadge` | Type-styled badge | -| `Divider` | [docs](https://mantine.dev/core/divider) | +- Only HTTP and HTTPS are accepted. URL credentials and fragments are rejected. +- Named paths must stay on the base endpoint origin. +- DNS results are validated against the selected network scope and pinned for the request. +- Queries can follow up to three revalidated same-origin redirects. Actions do not follow redirects. +- Authorization, cookie, host, forwarding, proxy, content-length, and hop-by-hop headers cannot be supplied by a template. +- Request bodies are limited to 10 KiB. Decompressed responses are limited to 1 MiB, 32 JSON levels, and 50,000 JSON nodes. +- GET requests can be cached and deduplicated. Actions are never cached, polled, or retried automatically. -## Template Bindings +## Import and export -Your template receives: +New exports use `"$schema": "homarr-custom-widget-v3"`. Version 2 imports remain accepted. Secrets are never exported and must be entered again after import. -- `data` - The sanitized API response object. Access fields via `data.fieldName` or `data.results[0].name`. -- `String(v)` - Type-safe string conversion -- `Number(v)` - Type-safe number conversion -- `Boolean(v)` - Type-safe boolean conversion -- `Math` - Safe subset: `round`, `floor`, `ceil`, `abs`, `min`, `max`, `pow`, `sqrt`, `PI` -- `JSON.stringify(v)` - JSON serialization -- `Array.isArray(v)` - Array type check -- `Object.keys(v)`, `Object.values(v)`, `Object.entries(v)` - Object utilities +Review the base origin, network scope, named request methods, inherited authentication, and required permissions before saving an imported widget. A version 1 template that contains inline network components stays disabled until it is converted to named requests. -## Security Constraints +AI-generated Custom JSX can also be pasted as a Homarr authoring bundle: one fenced JSON block with `"template": "__HOMARR_TEMPLATE__"`, followed by one fenced JSX block containing the readable multiline template. Homarr combines the blocks before validation, avoiding JSON newline and quote escaping. Raw JSON imports remain supported. -- Event handlers (`onClick`, `onChange`, etc.) are stripped -- Dangerous HTML attributes (`dangerouslySetInnerHTML`) are stripped -- `script`, `iframe`, `object`, `embed`, `form`, `style`, `link`, `meta`, `base` tags are forbidden -- Template strings containing `eval`, `Function`, `import`, `require`, `globalThis`, `window`, `document`, `fetch` are rejected -- Anchor `href` attributes are validated to only allow `https://`, relative (`/`), and hash (`#`) URLs +On the custom-widget list, paste a complete export with Ctrl+V or Command+V to open the import review and create it as a new definition. On an edit page, the same shortcut opens a replacement review and fills the existing form after confirmation. Pasted definitions never include secrets. diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 7c79d9a8d..aa1b5972c 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -73,13 +73,7 @@ Replace `` with your actual API key. Never commit API keys to vers "mcpServers": { "homarr": { "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://your-homarr-instance.com/api/mcp/mcp", - "--header", - "ApiKey:" - ] + "args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:"] } } } @@ -126,25 +120,26 @@ curl -s -X POST \ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** (GET, read-only) or **mutation** (POST, write action). -| Namespace | Example Tools | Type | -| ------------------ | -------------------------------------------------------- | ---------- | -| `app` | List all apps, get app by ID, create/update/delete app | GET + POST | -| `apiKeys` | List API keys | GET | -| `beszel` | Systems status, alerts, historical system metrics | GET | -| `board` | List boards, get board by ID, create/update/delete board | GET + POST | -| `docker` | List containers, inspect container, start/stop/restart | GET + POST | -| `icon` | Search icons | GET | -| `info` | Server health, version info | GET | -| `integration` | List integrations, test connection, delete integration | GET + POST | -| `invite` | List invitations, delete invite | GET + POST | -| `calendar` | Calendar events from media servers | GET | -| `dnsHole` | DNS hole summary, toggle blocking | GET + POST | -| `downloads` | Active downloads, pause/resume/delete | GET + POST | -| `healthMonitoring` | Resource usage stats | GET | -| `patchmon` | Host patch and security update statistics | GET | -| `mediaRequests` | Approve/decline media requests | GET + POST | -| `mediaServer` | Media server sessions and stats | GET | -| `smartHome` | Execute automations, device states | GET + POST | +| Namespace | Example Tools | Type | +| ------------------ | --------------------------------------------------------- | ---------- | +| `app` | List all apps, get app by ID, create/update/delete app | GET + POST | +| `apiKeys` | List API keys | GET | +| `beszel` | Systems status, alerts, historical system metrics | GET | +| `board` | List boards, get board by ID, create/update/delete board | GET + POST | +| `docker` | List containers, inspect container, start/stop/restart | GET + POST | +| `icon` | Search icons | GET | +| `info` | Server health, version info | GET | +| `integration` | List integrations, test connection, delete integration | GET + POST | +| `invite` | List invitations, delete invite | GET + POST | +| `calendar` | Calendar events from media servers | GET | +| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST | +| `dnsHole` | DNS hole summary, toggle blocking | GET + POST | +| `downloads` | Active downloads, pause/resume/delete | GET + POST | +| `healthMonitoring` | Resource usage stats | GET | +| `patchmon` | Host patch and security update statistics | GET | +| `mediaRequests` | Approve/decline media requests | GET + POST | +| `mediaServer` | Media server sessions and stats | GET | +| `smartHome` | Execute automations, device states | GET + POST | The tools table on the MCP tab is dynamically generated - it always reflects the latest available procedures from your Homarr instance. @@ -154,6 +149,8 @@ The tools table on the MCP tab is dynamically generated - it always reflects the - Tools inherit the permissions of the API key's owner - OAuth endpoints are rate-limited to prevent abuse - Tool execution errors are contained - a failing tool call does not crash the MCP server +- Custom-widget MCP actions are simulation-only; live preview actions must be enabled explicitly in the browser workbench +- Custom-widget secret values are never returned through MCP - All communication uses HTTPS (configure HTTP → HTTPS redirects on your reverse proxy) ## Use Cases @@ -165,3 +162,58 @@ The tools table on the MCP tab is dynamically generated - it always reflects the - **"How many users are on my Homarr instance?"** - AI lists all users (admin only) - **"Toggle Pi-hole blocking for 5 minutes"** - AI interacts with DNS controls - **"How are my servers doing?"** - AI fetches Beszel system stats including CPU, memory, disk, and alerts +- **"Build and refine a custom widget for this API"** - AI validates the draft, tests GET data and named queries, simulates actions, and creates or updates the widget + +## Custom Widget Workflow for AI Agents + +AI agents can create and iterate on custom widgets using these tools in sequence. + +:::tip Editing an existing widget? +Skip to step 5. Use `customWidget_byId` to fetch it and `customWidget_readTemplate` / `customWidget_writeTemplate` to edit its template directly. +::: + +### 1. Get the schema + +Call `customWidget_schema` to receive the JSON Schema for the `homarr-custom-widget-v3` format. This tells the agent what fields are required and their types. + +### 2. Draft and validate + +Build a widget JSON object and call `customWidget_validate` with it. The tool returns structured issues (path + message) so the agent can fix and re-validate until the draft is clean. + +### 3. Create the widget + +Call `customWidget_import` with the validated `homarr-custom-widget-v3` draft. This returns the new widget `id`. Use `customWidget_create` only when you intentionally need its lower-level creation input, including secret fields. + +### 4. Test with real data + +Call `customWidget_preview` with the widget's connection info. This makes a real GET request through Homarr's hardened executor and returns the sanitized response plus a preview session ID. + +For Custom JSX v2 widgets with named requests, use `customWidget_previewQuery` to test each named GET query, and `customWidget_simulatePreviewAction` to dry-run actions without network calls. + +### 5. Iterate on the template + +Use `customWidget_readTemplate` to get the current JSX template as plain text, then `customWidget_writeTemplate` to push updates. The write tool accepts either: + +- `template` — a single string (newlines as `\n`) +- `templateLines` — an array of strings (joined with newlines server-side, avoids `\n` escaping) + +Both options validate the template AST before saving. This avoids re-sending the full widget JSON for template-only edits. + +For small changes, prefer `customWidget_patchTemplate`. Pass the `revision` returned by `customWidget_readTemplate` and one or more line edits. Homarr rejects stale revisions, applies all edits atomically, and validates the complete template against its named request manifest before saving. + +### 6. Update non-template fields + +Call `customWidget_byId` to get the full widget, modify fields, and call `customWidget_update` with only the changed fields. + +### Available components in templates + +Custom JSX templates support 100+ Mantine components plus: + +- `` — 100+ curated Tabler icons by kebab-case name (e.g. `"player-play"`, `"alert-triangle"`) +- `` / `` / `` — network-capable components for named requests +- Array methods: `map`, `filter`, `find`, `findIndex`, `some`, `every`, `reduce`, `sort`, `slice`, `flat`, `reverse`, `at`, `pop`, `join`, `includes`, `indexOf` (note: `pop` returns last element without mutating) +- String methods: `toUpperCase`, `toLowerCase`, `trim`, `trimStart`, `trimEnd`, `slice`, `substring`, `includes`, `startsWith`, `endsWith`, `split`, `replace`, `replaceAll`, `repeat`, `padStart`, `padEnd`, `charAt`, `indexOf`, `lastIndexOf` + +:::note +All `customWidget_*` tools require admin-level API key permissions. The preview executor enforces rate limits (60 queries/min, 10 actions/min). +::: diff --git a/apps/docs/docs/widgets/custom-api/index.mdx b/apps/docs/docs/widgets/custom-api/index.mdx index 78d0bcbba..1f61f7aa4 100644 --- a/apps/docs/docs/widgets/custom-api/index.mdx +++ b/apps/docs/docs/widgets/custom-api/index.mdx @@ -1,29 +1,39 @@ --- -title: 'Custom API' -description: 'Displays data from a custom API endpoint using configurable display types and optional JSX templates.' +title: "Custom API" +description: "Displays data from an administrator-defined HTTP API using built-in renderers or safe Custom JSX." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { customApiWidget } from '.'; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { customApiWidget } from "."; + - +The Custom API widget displays an API definition created by an administrator in [Management → Custom Widgets](/docs/management/custom-widgets). A definition owns the endpoint, credentials, display configuration, and any named network capabilities used by Custom JSX. -This widget fetches data from a custom API endpoint defined in [Custom Widgets management](/docs/management/custom-widgets) and renders it using configurable display types. - -Custom API widgets are created and configured through the admin management page. Each widget definition specifies the API endpoint, authentication, display type, and refresh interval. - -See the [Custom Widgets documentation](/docs/management/custom-widgets) for full details on creating and configuring custom widgets. - -### Adding the widget +### Add the widget +Choose a custom-widget definition and a refresh interval. The board item stores only the selected definition and display options; credentials remain server-side. + :::note -Custom API widgets must first be created in **Management → Custom Widgets** before they appear in the widget picker. + +A definition must be enabled and available to the board before it appears in the widget picker. + ::: + +### Permissions and actions + +The base response and named query requests require permission to view the board. Named POST, PUT, and PATCH actions require permission to modify the board by default. DELETE requires full board permission and a confirmation. Anonymous viewers cannot execute actions. + +Interactive controls are disabled while the board is in edit mode. Actions run only after an explicit user gesture and are never triggered by mounting, refresh polling, or automatic retries. Authoring previews simulate actions unless an administrator explicitly enables live actions for the short-lived preview session. + +### Refresh behavior + +The refresh interval applies to the base GET response. A Custom JSX query can declare its own cache duration and can be refreshed manually. Named actions can invalidate the parent response or related named queries after they succeed. + +If the definition needs a non-GET base request, migrate it to a GET base response plus a named action. Existing non-GET Custom JSX definitions require review before network access is enabled. + +See [Custom Widgets management](/docs/management/custom-widgets) for the editor, display types, named-request manifest, network scopes, security limits, and migration guidance. diff --git a/apps/docs/src/components/custom-jsx-component-reference.tsx b/apps/docs/src/components/custom-jsx-component-reference.tsx new file mode 100644 index 000000000..af37a7105 --- /dev/null +++ b/apps/docs/src/components/custom-jsx-component-reference.tsx @@ -0,0 +1,66 @@ +import React from "react"; + +import customJsxComponentRegistry from "../generated/custom-jsx-components.json"; + +const enabledComponents = customJsxComponentRegistry.filter((component) => component.safety !== "denied"); +const deniedComponents = customJsxComponentRegistry.filter((component) => component.safety === "denied"); +const categories = [...new Set(enabledComponents.map((component) => component.category))]; + +export function CustomJsxComponentReference() { + return ( + <> +

Enabled and wrapped components

+ + + + + + + + + {categories.map((category) => ( + + + + + ))} + +
CategoryComponents
+ {category} + + {enabledComponents + .filter((component) => component.category === category) + .map((component) => ( + + + {component.name} + + {component.safety === "wrapped" && (wrapped)} + + ))} +
+ +

Denied components

+ + + + + + + + + {deniedComponents.map((component) => ( + + + + + ))} + +
ComponentReason
+ + {component.name} + + {component.reason}
+ + ); +} diff --git a/apps/docs/src/generated/custom-jsx-components.json b/apps/docs/src/generated/custom-jsx-components.json new file mode 100644 index 000000000..014cbcc49 --- /dev/null +++ b/apps/docs/src/generated/custom-jsx-components.json @@ -0,0 +1,2491 @@ +[ + { + "name": "Box", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/box/" + }, + { + "name": "Stack", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/stack/" + }, + { + "name": "Group", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/group/" + }, + { + "name": "Flex", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/flex/" + }, + { + "name": "Grid", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/grid/" + }, + { + "name": "Grid.Col", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/grid/" + }, + { + "name": "SimpleGrid", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/simplegrid/" + }, + { + "name": "Center", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/center/" + }, + { + "name": "Space", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/space/" + }, + { + "name": "Container", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/container/" + }, + { + "name": "AspectRatio", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/aspectratio/" + }, + { + "name": "ScrollArea", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/scrollarea/" + }, + { + "name": "Paper", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/paper/" + }, + { + "name": "Card", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/card/" + }, + { + "name": "Card.Section", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/card/" + }, + { + "name": "Divider", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/divider/" + }, + { + "name": "Fieldset", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/fieldset/" + }, + { + "name": "VisuallyHidden", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/visuallyhidden/" + }, + { + "name": "Typography", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/typography/" + }, + { + "name": "FloatingIndicator", + "package": "@mantine/core", + "category": "layout", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/floatingindicator/" + }, + { + "name": "Text", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/text/" + }, + { + "name": "Title", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/title/" + }, + { + "name": "Code", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/code/" + }, + { + "name": "Highlight", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/highlight/" + }, + { + "name": "Mark", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/mark/" + }, + { + "name": "Kbd", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/kbd/" + }, + { + "name": "Blockquote", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/blockquote/" + }, + { + "name": "NumberFormatter", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/numberformatter/" + }, + { + "name": "Badge", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/badge/" + }, + { + "name": "ThemeIcon", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/themeicon/" + }, + { + "name": "ColorSwatch", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/colorswatch/" + }, + { + "name": "Table", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/table/" + }, + { + "name": "Table.Thead", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/table/" + }, + { + "name": "Table.Tbody", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/table/" + }, + { + "name": "Table.Tfoot", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/table/" + }, + { + "name": "Table.Caption", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/table/" + }, + { + "name": "Table.Tr", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/table/" + }, + { + "name": "Table.Th", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/table/" + }, + { + "name": "Table.Td", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/table/" + }, + { + "name": "List", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/list/" + }, + { + "name": "List.Item", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/list/" + }, + { + "name": "Timeline", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/timeline/" + }, + { + "name": "Timeline.Item", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/timeline/" + }, + { + "name": "Indicator", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/indicator/" + }, + { + "name": "Pill", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/pill/" + }, + { + "name": "Image", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/image/" + }, + { + "name": "Avatar", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/avatar/" + }, + { + "name": "Avatar.Group", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/avatar/" + }, + { + "name": "BackgroundImage", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/backgroundimage/" + }, + { + "name": "DataList", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/datalist/" + }, + { + "name": "DataList.Item", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/datalist/" + }, + { + "name": "DataList.ItemLabel", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/datalist/" + }, + { + "name": "DataList.ItemValue", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/datalist/" + }, + { + "name": "EmptyState", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/emptystate/" + }, + { + "name": "EmptyState.Indicator", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/emptystate/" + }, + { + "name": "EmptyState.Title", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/emptystate/" + }, + { + "name": "EmptyState.Description", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/emptystate/" + }, + { + "name": "EmptyState.Actions", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/emptystate/" + }, + { + "name": "Marquee", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/marquee/" + }, + { + "name": "RollingNumber", + "package": "@mantine/core", + "category": "content", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/rollingnumber/" + }, + { + "name": "Alert", + "package": "@mantine/core", + "category": "feedback", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/alert/" + }, + { + "name": "Progress", + "package": "@mantine/core", + "category": "feedback", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/progress/" + }, + { + "name": "Progress.Section", + "package": "@mantine/core", + "category": "feedback", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/progress/" + }, + { + "name": "RingProgress", + "package": "@mantine/core", + "category": "feedback", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/ringprogress/" + }, + { + "name": "SemiCircleProgress", + "package": "@mantine/core", + "category": "feedback", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/semicircleprogress/" + }, + { + "name": "Skeleton", + "package": "@mantine/core", + "category": "feedback", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/skeleton/" + }, + { + "name": "Loader", + "package": "@mantine/core", + "category": "feedback", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/loader/" + }, + { + "name": "Notification", + "package": "@mantine/core", + "category": "feedback", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/notification/" + }, + { + "name": "LoadingOverlay", + "package": "@mantine/core", + "category": "feedback", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/loadingoverlay/" + }, + { + "name": "Overlay", + "package": "@mantine/core", + "category": "feedback", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/overlay/" + }, + { + "name": "Accordion", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/accordion/" + }, + { + "name": "Accordion.Item", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/accordion/" + }, + { + "name": "Accordion.Control", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/accordion/" + }, + { + "name": "Accordion.Panel", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/accordion/" + }, + { + "name": "Tabs", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/tabs/" + }, + { + "name": "Tabs.List", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/tabs/" + }, + { + "name": "Tabs.Tab", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/tabs/" + }, + { + "name": "Tabs.Panel", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/tabs/" + }, + { + "name": "Stepper.Step", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/stepper/" + }, + { + "name": "HoverCard", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/hovercard/" + }, + { + "name": "HoverCard.Target", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/hovercard/" + }, + { + "name": "HoverCard.Dropdown", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/hovercard/" + }, + { + "name": "Menu", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/menu/" + }, + { + "name": "Menu.Target", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/menu/" + }, + { + "name": "Menu.Dropdown", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/menu/" + }, + { + "name": "Menu.Item", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/menu/" + }, + { + "name": "Menu.Label", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/menu/" + }, + { + "name": "Menu.Divider", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/menu/" + }, + { + "name": "Tooltip", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/tooltip/" + }, + { + "name": "Spoiler", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/spoiler/" + }, + { + "name": "Collapse", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/collapse/" + }, + { + "name": "Transition", + "package": "@mantine/core", + "category": "navigation", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/core/transition/" + }, + { + "name": "Anchor", + "package": "@mantine/core", + "category": "navigation", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/anchor/" + }, + { + "name": "NavLink", + "package": "@mantine/core", + "category": "navigation", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/navlink/" + }, + { + "name": "Breadcrumbs", + "package": "@mantine/core", + "category": "navigation", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/breadcrumbs/" + }, + { + "name": "Stepper", + "package": "@mantine/core", + "category": "navigation", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/stepper/" + }, + { + "name": "Tree", + "package": "@mantine/core", + "category": "navigation", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/tree/" + }, + { + "name": "Button", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/button/" + }, + { + "name": "ActionIcon", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/actionicon/" + }, + { + "name": "Burger", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/burger/" + }, + { + "name": "CloseButton", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/closebutton/" + }, + { + "name": "Chip", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/chip/" + }, + { + "name": "Chip.Group", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/chip/" + }, + { + "name": "CopyButton", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/copybutton/" + }, + { + "name": "Pagination", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/pagination/" + }, + { + "name": "Rating", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/rating/" + }, + { + "name": "SegmentedControl", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/segmentedcontrol/" + }, + { + "name": "Slider", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/slider/" + }, + { + "name": "Switch", + "package": "@mantine/core", + "category": "interaction", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/core/switch/" + }, + { + "name": "AreaChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/areachart/" + }, + { + "name": "BarChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/barchart/" + }, + { + "name": "BarsList", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/barslist/" + }, + { + "name": "BubbleChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/bubblechart/" + }, + { + "name": "ChartLegend", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/chartlegend/" + }, + { + "name": "ChartTooltip", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/charttooltip/" + }, + { + "name": "CompositeChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/compositechart/" + }, + { + "name": "DonutChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/donutchart/" + }, + { + "name": "FunnelChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/funnelchart/" + }, + { + "name": "Heatmap", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/heatmap/" + }, + { + "name": "LineChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/linechart/" + }, + { + "name": "PieChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/piechart/" + }, + { + "name": "RadarChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/radarchart/" + }, + { + "name": "RadialBarChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/radialbarchart/" + }, + { + "name": "SankeyChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/sankeychart/" + }, + { + "name": "ScatterChart", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/scatterchart/" + }, + { + "name": "Sparkline", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/sparkline/" + }, + { + "name": "Treemap", + "package": "@mantine/charts", + "category": "charts", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/charts/treemap/" + }, + { + "name": "Calendar", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/calendar/" + }, + { + "name": "MiniCalendar", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/minicalendar/" + }, + { + "name": "DatePicker", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/datepicker/" + }, + { + "name": "Day", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/day/" + }, + { + "name": "WeekdaysRow", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/weekdaysrow/" + }, + { + "name": "Month", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/month/" + }, + { + "name": "CalendarHeader", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/calendarheader/" + }, + { + "name": "YearPicker", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/yearpicker/" + }, + { + "name": "MonthPicker", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/monthpicker/" + }, + { + "name": "TimeGrid", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/timegrid/" + }, + { + "name": "PickerControl", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/pickercontrol/" + }, + { + "name": "YearsList", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/yearslist/" + }, + { + "name": "MonthsList", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/monthslist/" + }, + { + "name": "DecadeLevel", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/decadelevel/" + }, + { + "name": "YearLevel", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/yearlevel/" + }, + { + "name": "MonthLevel", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/monthlevel/" + }, + { + "name": "LevelsGroup", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/levelsgroup/" + }, + { + "name": "DecadeLevelGroup", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/decadelevelgroup/" + }, + { + "name": "YearLevelGroup", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/yearlevelgroup/" + }, + { + "name": "MonthLevelGroup", + "package": "@mantine/dates", + "category": "dates", + "safety": "wrapped", + "documentationUrl": "https://mantine.dev/dates/monthlevelgroup/" + }, + { + "name": "TimeValue", + "package": "@mantine/dates", + "category": "dates", + "safety": "allowed", + "documentationUrl": "https://mantine.dev/dates/timevalue/" + }, + { + "name": "PaginatedList", + "package": "@homarr/widgets", + "category": "interaction", + "safety": "allowed", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "TabsContainer", + "package": "@homarr/widgets", + "category": "interaction", + "safety": "allowed", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "TabPanel", + "package": "@homarr/widgets", + "category": "interaction", + "safety": "allowed", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "Collapsible", + "package": "@homarr/widgets", + "category": "interaction", + "safety": "allowed", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "StatBar", + "package": "@homarr/widgets", + "category": "interaction", + "safety": "allowed", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "TypeBadge", + "package": "@homarr/widgets", + "category": "interaction", + "safety": "allowed", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "SubFetch", + "package": "@homarr/widgets", + "category": "network", + "safety": "wrapped", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "SubData", + "package": "@homarr/widgets", + "category": "network", + "safety": "wrapped", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "ActionButton", + "package": "@homarr/widgets", + "category": "network", + "safety": "wrapped", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "ToggleSwitch", + "package": "@homarr/widgets", + "category": "network", + "safety": "wrapped", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "RefreshButton", + "package": "@homarr/widgets", + "category": "network", + "safety": "wrapped", + "documentationUrl": "https://homarr.dev/docs/management/custom-widgets/" + }, + { + "name": "AppShell", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/appshell/" + }, + { + "name": "Affix", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/affix/" + }, + { + "name": "ComboboxPopover", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/comboboxpopover/" + }, + { + "name": "Dialog", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/dialog/" + }, + { + "name": "Drawer", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/drawer/" + }, + { + "name": "FloatingWindow", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/floatingwindow/" + }, + { + "name": "FocusTrap", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/focustrap/" + }, + { + "name": "Menubar", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/menubar/" + }, + { + "name": "Modal", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/modal/" + }, + { + "name": "ModalBase", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/modalbase/" + }, + { + "name": "Portal", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/portal/" + }, + { + "name": "Popover", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/popover/" + }, + { + "name": "RemoveScroll", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Escapes the widget layout or focus boundary", + "documentationUrl": "https://mantine.dev/core/removescroll/" + }, + { + "name": "AngleSlider", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/angleslider/" + }, + { + "name": "Autocomplete", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/autocomplete/" + }, + { + "name": "Checkbox", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/checkbox/" + }, + { + "name": "ColorInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/colorinput/" + }, + { + "name": "ColorPicker", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/colorpicker/" + }, + { + "name": "Combobox", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/combobox/" + }, + { + "name": "FileButton", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/filebutton/" + }, + { + "name": "FileInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/fileinput/" + }, + { + "name": "Input", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/input/" + }, + { + "name": "InputBase", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/inputbase/" + }, + { + "name": "JsonInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/jsoninput/" + }, + { + "name": "MaskInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/maskinput/" + }, + { + "name": "MultiSelect", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/multiselect/" + }, + { + "name": "NativeSelect", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/nativeselect/" + }, + { + "name": "NumberInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/numberinput/" + }, + { + "name": "PasswordInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/passwordinput/" + }, + { + "name": "PillsInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/pillsinput/" + }, + { + "name": "PinInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/pininput/" + }, + { + "name": "Radio", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/radio/" + }, + { + "name": "Select", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/select/" + }, + { + "name": "TagsInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/tagsinput/" + }, + { + "name": "Textarea", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/textarea/" + }, + { + "name": "TextInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/textinput/" + }, + { + "name": "TreeSelect", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/treeselect/" + }, + { + "name": "UnstyledButton", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Arbitrary form controls are not connected to the declarative action model", + "documentationUrl": "https://mantine.dev/core/unstyledbutton/" + }, + { + "name": "OverflowList", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Requires callback, document, or layout capabilities that are not exposed to templates", + "documentationUrl": "https://mantine.dev/core/overflowlist/" + }, + { + "name": "Scroller", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Requires callback, document, or layout capabilities that are not exposed to templates", + "documentationUrl": "https://mantine.dev/core/scroller/" + }, + { + "name": "Splitter", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Requires callback, document, or layout capabilities that are not exposed to templates", + "documentationUrl": "https://mantine.dev/core/splitter/" + }, + { + "name": "TableOfContents", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Requires callback, document, or layout capabilities that are not exposed to templates", + "documentationUrl": "https://mantine.dev/core/tableofcontents/" + }, + { + "name": "AccordionChevron", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/accordionchevron/" + }, + { + "name": "ActionIconGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/actionicongroup/" + }, + { + "name": "ActionIconGroupSection", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/actionicongroupsection/" + }, + { + "name": "AlphaSlider", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/alphaslider/" + }, + { + "name": "AppShellAside", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/appshellaside/" + }, + { + "name": "AppShellFooter", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/appshellfooter/" + }, + { + "name": "AppShellHeader", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/appshellheader/" + }, + { + "name": "AppShellMain", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/appshellmain/" + }, + { + "name": "AppShellNavbar", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/appshellnavbar/" + }, + { + "name": "AppShellSection", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/appshellsection/" + }, + { + "name": "ButtonGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/buttongroup/" + }, + { + "name": "ButtonGroupSection", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/buttongroupsection/" + }, + { + "name": "CheckIcon", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/checkicon/" + }, + { + "name": "CheckboxCard", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/checkboxcard/" + }, + { + "name": "CheckboxGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/checkboxgroup/" + }, + { + "name": "CheckboxIndicator", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/checkboxindicator/" + }, + { + "name": "CloseIcon", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/closeicon/" + }, + { + "name": "ColorSchemeScript", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/colorschemescript/" + }, + { + "name": "ComboboxChevron", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxchevron/" + }, + { + "name": "ComboboxClearButton", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxclearbutton/" + }, + { + "name": "ComboboxDropdown", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxdropdown/" + }, + { + "name": "ComboboxDropdownTarget", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxdropdowntarget/" + }, + { + "name": "ComboboxEmpty", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxempty/" + }, + { + "name": "ComboboxEventsTarget", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxeventstarget/" + }, + { + "name": "ComboboxFooter", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxfooter/" + }, + { + "name": "ComboboxGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxgroup/" + }, + { + "name": "ComboboxHeader", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxheader/" + }, + { + "name": "ComboboxHiddenInput", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxhiddeninput/" + }, + { + "name": "ComboboxOption", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxoption/" + }, + { + "name": "ComboboxOptions", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxoptions/" + }, + { + "name": "ComboboxPopoverTarget", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxpopovertarget/" + }, + { + "name": "ComboboxSearch", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxsearch/" + }, + { + "name": "ComboboxTarget", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/comboboxtarget/" + }, + { + "name": "DirectionProvider", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/directionprovider/" + }, + { + "name": "DrawerBody", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/drawerbody/" + }, + { + "name": "DrawerCloseButton", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/drawerclosebutton/" + }, + { + "name": "DrawerContent", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/drawercontent/" + }, + { + "name": "DrawerHeader", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/drawerheader/" + }, + { + "name": "DrawerOverlay", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/draweroverlay/" + }, + { + "name": "DrawerRoot", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/drawerroot/" + }, + { + "name": "DrawerStack", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/drawerstack/" + }, + { + "name": "DrawerTitle", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/drawertitle/" + }, + { + "name": "FlatTreeNode", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/flattreenode/" + }, + { + "name": "FloatingArrow", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/floatingarrow/" + }, + { + "name": "FocusTrapInitialFocus", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/focustrapinitialfocus/" + }, + { + "name": "HeadlessMantineProvider", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/headlessmantineprovider/" + }, + { + "name": "HoverCardGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/hovercardgroup/" + }, + { + "name": "HueSlider", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/hueslider/" + }, + { + "name": "InlineStyles", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/inlinestyles/" + }, + { + "name": "InputClearButton", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/inputclearbutton/" + }, + { + "name": "InputClearSection", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/inputclearsection/" + }, + { + "name": "InputDescription", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/inputdescription/" + }, + { + "name": "InputError", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/inputerror/" + }, + { + "name": "InputLabel", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/inputlabel/" + }, + { + "name": "InputPlaceholder", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/inputplaceholder/" + }, + { + "name": "InputSuccess", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/inputsuccess/" + }, + { + "name": "InputWrapper", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/inputwrapper/" + }, + { + "name": "MantineProvider", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/mantineprovider/" + }, + { + "name": "MantineThemeProvider", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/mantinethemeprovider/" + }, + { + "name": "MenuCheckboxGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menucheckboxgroup/" + }, + { + "name": "MenuCheckboxItem", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menucheckboxitem/" + }, + { + "name": "MenuContextMenu", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menucontextmenu/" + }, + { + "name": "MenuRadioGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menuradiogroup/" + }, + { + "name": "MenuRadioItem", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menuradioitem/" + }, + { + "name": "MenuSearch", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menusearch/" + }, + { + "name": "MenuSub", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menusub/" + }, + { + "name": "MenuSubDropdown", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menusubdropdown/" + }, + { + "name": "MenuSubItem", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menusubitem/" + }, + { + "name": "MenuSubTarget", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menusubtarget/" + }, + { + "name": "MenubarDropdown", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menubardropdown/" + }, + { + "name": "MenubarMenu", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menubarmenu/" + }, + { + "name": "MenubarTarget", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/menubartarget/" + }, + { + "name": "ModalBaseBody", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalbasebody/" + }, + { + "name": "ModalBaseCloseButton", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalbaseclosebutton/" + }, + { + "name": "ModalBaseContent", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalbasecontent/" + }, + { + "name": "ModalBaseHeader", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalbaseheader/" + }, + { + "name": "ModalBaseOverlay", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalbaseoverlay/" + }, + { + "name": "ModalBaseTitle", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalbasetitle/" + }, + { + "name": "ModalBody", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalbody/" + }, + { + "name": "ModalCloseButton", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalclosebutton/" + }, + { + "name": "ModalContent", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalcontent/" + }, + { + "name": "ModalHeader", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalheader/" + }, + { + "name": "ModalOverlay", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modaloverlay/" + }, + { + "name": "ModalRoot", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalroot/" + }, + { + "name": "ModalStack", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modalstack/" + }, + { + "name": "ModalTitle", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/modaltitle/" + }, + { + "name": "NativeScrollArea", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/nativescrollarea/" + }, + { + "name": "OptionalPortal", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/optionalportal/" + }, + { + "name": "OptionsDropdown", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/optionsdropdown/" + }, + { + "name": "PaginationControl", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/paginationcontrol/" + }, + { + "name": "PaginationDots", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/paginationdots/" + }, + { + "name": "PaginationFirst", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/paginationfirst/" + }, + { + "name": "PaginationItems", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/paginationitems/" + }, + { + "name": "PaginationLabel", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/paginationlabel/" + }, + { + "name": "PaginationLast", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/paginationlast/" + }, + { + "name": "PaginationNext", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/paginationnext/" + }, + { + "name": "PaginationPrevious", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/paginationprevious/" + }, + { + "name": "PaginationRoot", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/paginationroot/" + }, + { + "name": "PillGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/pillgroup/" + }, + { + "name": "PillsInputField", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/pillsinputfield/" + }, + { + "name": "PopoverContextMenu", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/popovercontextmenu/" + }, + { + "name": "PopoverDropdown", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/popoverdropdown/" + }, + { + "name": "PopoverTarget", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/popovertarget/" + }, + { + "name": "ProgressLabel", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/progresslabel/" + }, + { + "name": "ProgressRoot", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/progressroot/" + }, + { + "name": "RadioCard", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/radiocard/" + }, + { + "name": "RadioGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/radiogroup/" + }, + { + "name": "RadioIcon", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/radioicon/" + }, + { + "name": "RadioIndicator", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/radioindicator/" + }, + { + "name": "RangeSlider", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/rangeslider/" + }, + { + "name": "ScrollAreaAutosize", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/scrollareaautosize/" + }, + { + "name": "SplitterPane", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/splitterpane/" + }, + { + "name": "StepperCompleted", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/steppercompleted/" + }, + { + "name": "SwitchGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/switchgroup/" + }, + { + "name": "TableScrollContainer", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/tablescrollcontainer/" + }, + { + "name": "TooltipFloating", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/tooltipfloating/" + }, + { + "name": "TooltipGroup", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/tooltipgroup/" + }, + { + "name": "TreeSelectOption", + "package": "@mantine/core", + "category": "blocked", + "safety": "denied", + "reason": "Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface", + "documentationUrl": "https://mantine.dev/core/treeselectoption/" + }, + { + "name": "AreaGradient", + "package": "@mantine/charts", + "category": "blocked", + "safety": "denied", + "reason": "Low-level chart implementation helper that is not a standalone declarative display component", + "documentationUrl": "https://mantine.dev/charts/areagradient/" + }, + { + "name": "DatesProvider", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/datesprovider/" + }, + { + "name": "HiddenDatesInput", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/hiddendatesinput/" + }, + { + "name": "TimeInput", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/timeinput/" + }, + { + "name": "TimePicker", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/timepicker/" + }, + { + "name": "PickerInputBase", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/pickerinputbase/" + }, + { + "name": "DateInput", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/dateinput/" + }, + { + "name": "DateTimePicker", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/datetimepicker/" + }, + { + "name": "InlineDateTimePicker", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/inlinedatetimepicker/" + }, + { + "name": "YearPickerInput", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/yearpickerinput/" + }, + { + "name": "MonthPickerInput", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/monthpickerinput/" + }, + { + "name": "DatePickerInput", + "package": "@mantine/dates", + "category": "blocked", + "safety": "denied", + "reason": "Input and provider components are outside the read-only template surface", + "documentationUrl": "https://mantine.dev/dates/datepickerinput/" + } +] diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 4a3e0dd3e..65612a08c 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -14,6 +14,12 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { + "@codemirror/autocomplete": "catalog:", + "@codemirror/commands": "catalog:", + "@codemirror/lang-javascript": "catalog:", + "@codemirror/lang-json": "catalog:", + "@codemirror/lint": "catalog:", + "@codemirror/view": "catalog:", "@dnd-kit/core": "catalog:", "@dnd-kit/modifiers": "catalog:", "@dnd-kit/sortable": "catalog:", @@ -68,6 +74,7 @@ "@trpc/next": "catalog:", "@trpc/react-query": "catalog:", "@trpc/server": "catalog:", + "@uiw/react-codemirror": "catalog:", "@xterm/addon-canvas": "catalog:", "@xterm/addon-fit": "catalog:", "@xterm/xterm": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.module.css b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.module.css new file mode 100644 index 000000000..d152dc61e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.module.css @@ -0,0 +1,80 @@ +.root { + margin-top: 4px; + border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4)); + border-radius: var(--mantine-radius-sm); + background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); + overflow: hidden; +} + +.root:focus-within { + border-color: var(--mantine-primary-color-filled); + outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent); + outline-offset: 1px; +} + +.toolbar, +.footer { + padding: 7px 10px; + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8)); +} + +.toolbar { + border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); +} + +.footer { + border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); +} + +.viewport { + max-height: min(62vh, 680px); + overflow: auto; +} + +.viewport :global(.cm-editor) { + background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); + color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0)); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 12px; +} + +.viewport :global(.cm-editor.cm-focused) { + outline: none; +} + +.viewport :global(.cm-scroller) { + line-height: 1.65; + overflow: auto; +} + +.viewport :global(.cm-gutters) { + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8)); + border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); +} + +.diagnosticButton { + min-height: 44px; + border-radius: var(--mantine-radius-sm); +} + +.diagnosticButton:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; +} + +@media (max-width: 48em) { + .viewport { + max-height: 52vh; + } + + .toolbar, + .footer { + align-items: flex-start; + } +} + +@media (prefers-reduced-motion: reduce) { + .root { + scroll-behavior: auto; + } +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx new file mode 100644 index 000000000..97bc4177a --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import { autocompletion } from "@codemirror/autocomplete"; +import type { CompletionSource } from "@codemirror/autocomplete"; +import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands"; +import { javascript } from "@codemirror/lang-javascript"; +import { json } from "@codemirror/lang-json"; +import { linter } from "@codemirror/lint"; +import type { EditorView } from "@codemirror/view"; +import { + Alert, + Badge, + Button, + Group, + Input, + Loader, + Popover, + ScrollArea, + Stack, + Text, + TextInput, + UnstyledButton, + useComputedColorScheme, +} from "@mantine/core"; +import { + IconAlertTriangle, + IconArrowBackUp, + IconArrowForwardUp, + IconCheck, + IconCode, + IconComponents, + IconFileCode, + IconSearch, + IconWand, +} from "@tabler/icons-react"; +import { enabledCustomJsxComponents } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; +import { validateCustomJsxTemplate } from "@homarr/validation/custom-jsx-template"; + +import classes from "./_code-editor.module.css"; + +const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), { + ssr: false, + loading: () => ( + + + + ), +}); + +export const CUSTOM_JSX_TEMPLATE_LIMIT = 50_000; + +export type EditorDiagnosticCode = + | "ast" + | "dangerousProperty" + | "dynamicRequestId" + | "eventHandler" + | "invalidRequestManifest" + | "legacyNetworkProps" + | "missingRequestId" + | "templateEmpty" + | "templateTooLong" + | "unknownRequest" + | "unsafeTag"; + +export interface EditorDiagnostic { + code: EditorDiagnosticCode; + severity: "error" | "warning"; + line?: number; + column?: number; + index?: number; + value?: string; +} + +interface AnalyzeJsxTemplateOptions { + apiVersion?: 1 | 2; + requestIds?: string[]; +} + +const unsafeTagPattern = /<\s*\/?\s*(script|iframe|object|embed|form|style|link|meta|base)\b/giu; +const eventHandlerPattern = /\s(on[A-Z][\w]*)\s*=/gu; +const dangerousPropertyPattern = + /(?:\.|\[\s*["'])(constructor|prototype|__proto__|caller|callee|arguments|bind|call|apply)(?:["']\s*\])?/giu; +const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu; + +const getLine = (source: string, index: number) => source.slice(0, index).split("\n").length; + +const customJsxCompletionSource: CompletionSource = (context) => { + const word = context.matchBefore(/[A-Z][\w.]*/u); + if (!word && !context.explicit) return null; + return { + from: word?.from ?? context.pos, + options: enabledCustomJsxComponents.map((component) => ({ + label: component.name, + type: "class", + detail: `${component.category} · ${component.safety}`, + })), + }; +}; + +export function analyzeJsxTemplate( + template: string, + { apiVersion = 1, requestIds = [] }: AnalyzeJsxTemplateOptions = {}, +): EditorDiagnostic[] { + const diagnostics: EditorDiagnostic[] = []; + + if (template.trim().length === 0) { + diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 }); + } + if (template.length > CUSTOM_JSX_TEMPLATE_LIMIT) { + diagnostics.push({ code: "templateTooLong", severity: "error", line: 1, column: 1, index: 0 }); + } + + for (const diagnostic of validateCustomJsxTemplate(template)) { + diagnostics.push({ + code: "ast", + severity: diagnostic.severity, + line: diagnostic.line, + column: diagnostic.column, + index: diagnostic.index, + value: diagnostic.message, + }); + } + + for (const match of template.matchAll(unsafeTagPattern)) { + diagnostics.push({ + code: "unsafeTag", + severity: "error", + line: getLine(template, match.index), + index: match.index, + value: match[1], + }); + } + for (const match of template.matchAll(eventHandlerPattern)) { + if (match[1] === "onParams") continue; + diagnostics.push({ + code: "eventHandler", + severity: "error", + line: getLine(template, match.index), + index: match.index, + value: match[1], + }); + } + for (const match of template.matchAll(dangerousPropertyPattern)) { + diagnostics.push({ + code: "dangerousProperty", + severity: "error", + line: getLine(template, match.index), + index: match.index, + value: match[1], + }); + } + + if (apiVersion === 2) { + const knownRequestIds = new Set(requestIds); + for (const match of template.matchAll(networkComponentPattern)) { + const props = match[2] ?? ""; + const line = getLine(template, match.index); + if (/\b(?:url|method|headers|body|onBody|offBody)\s*=/iu.test(props)) { + diagnostics.push({ code: "legacyNetworkProps", severity: "error", line, index: match.index }); + } + + const staticRequest = props.match(/\brequestId\s*=\s*["']([^"']+)["']/iu); + if (staticRequest?.[1]) { + if (!knownRequestIds.has(staticRequest[1])) { + diagnostics.push({ + code: "unknownRequest", + severity: "error", + line, + index: match.index, + value: staticRequest[1], + }); + } + } else if (/\brequestId\s*=/iu.test(props)) { + diagnostics.push({ code: "dynamicRequestId", severity: "error", line, index: match.index }); + } else { + diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index }); + } + } + } + + return diagnostics; +} + +export function analyzeRequestManifest(value: string): EditorDiagnostic[] { + try { + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed)) { + return [{ code: "invalidRequestManifest", severity: "error", line: 1 }]; + } + return []; + } catch { + return [{ code: "invalidRequestManifest", severity: "error", line: 1 }]; + } +} + +export function parseRequestManifest(value: string): unknown[] { + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +interface CodeEditorProps { + id: string; + value: string; + onChange: (value: string) => void; + label: string; + description?: string; + placeholder?: string; + language: "jsx" | "json"; + diagnostics?: EditorDiagnostic[]; + error?: React.ReactNode; + required?: boolean; + maxLength?: number; + starter?: string; +} + +export function CodeEditor({ + id, + value, + onChange, + label, + description, + placeholder, + language, + diagnostics = [], + error, + required, + maxLength, + starter, +}: CodeEditorProps) { + const t = useScopedI18n("customWidget.editor"); + const [cursor, setCursor] = useState({ line: 1, column: 1 }); + const [editorView, setEditorView] = useState(null); + const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 }); + const colorScheme = useComputedColorScheme("light"); + const extensions = useMemo(() => { + const diagnosticsExtension = linter((view) => + diagnostics.map((diagnostic) => { + const from = Math.min(diagnostic.index ?? 0, view.state.doc.length); + return { + from, + to: Math.min(from + 1, view.state.doc.length), + severity: diagnostic.severity, + message: t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never), + }; + }), + ); + return language === "jsx" + ? [javascript({ jsx: true }), autocompletion({ override: [customJsxCompletionSource] }), diagnosticsExtension] + : [json(), diagnosticsExtension]; + }, [diagnostics, language, t]); + const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length; + const warningCount = diagnostics.length - errorCount; + + const formattedValue = useMemo(() => { + if (language === "json") { + try { + return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`; + } catch { + return value; + } + } + return value + .replace(/\r\n?/gu, "\n") + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trim(); + }, [language, value]); + + return ( + +
+ + + + + {language === "jsx" ? t("language.jsx") : t("language.json")} + + + + + + {language === "jsx" && } + {starter && value.trim().length === 0 && ( + + )} + + + + +
+ { + if (!update.selectionSet && !update.docChanged) return; + const head = update.state.selection.main.head; + const line = update.state.doc.lineAt(head); + setCursor({ line: line.number, column: head - line.from + 1 }); + setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) }); + }} + /> +
+ + + + {errorCount > 0 ? ( + + {t("status.errors", { count: errorCount })} + + ) : warningCount > 0 ? ( + + {t("status.warnings", { count: warningCount })} + + ) : ( + + + + {t("status.ready")} + + + )} + + {t("status.position", cursor)} + + + maxLength * 0.95 ? (value.length > maxLength ? "red" : "orange") : "dimmed"} + > + {maxLength + ? t("status.charactersWithLimit", { count: value.length, limit: maxLength }) + : t("status.characters", { count: value.length })} + + +
+ + {diagnostics.length > 0 && ( + + {diagnostics.map((diagnostic, index) => ( +
  • + { + if (!editorView || diagnostic.index === undefined) return; + const anchor = Math.min(diagnostic.index, editorView.state.doc.length); + editorView.dispatch({ selection: { anchor }, scrollIntoView: true }); + editorView.focus(); + }} + > + } + > + + {diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""} + {t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never)} + + + +
  • + ))} +
    + )} +
    + ); +} + +function ComponentReference() { + const t = useScopedI18n("customWidget.editor"); + const [query, setQuery] = useState(""); + const components = useMemo(() => { + const normalizedQuery = query.trim().toLocaleLowerCase(); + return enabledCustomJsxComponents.filter( + (component) => + normalizedQuery.length === 0 || + component.name.toLocaleLowerCase().includes(normalizedQuery) || + component.category.toLocaleLowerCase().includes(normalizedQuery), + ); + }, [query]); + + return ( + + + + + + + setQuery(event.currentTarget.value)} + placeholder={t("componentReference.search")} + aria-label={t("componentReference.search")} + leftSection={} + /> + + + {components.map((component) => ( + + + {component.name} + + + {component.category} + + + ))} + {components.length === 0 && ( + + {t("componentReference.empty")} + + )} + + + + {t("componentReference.count", { count: enabledCustomJsxComponents.length })} + + + + + ); +} 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 119cf2666..3ba070b3c 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 @@ -5,272 +5,174 @@ import { useDisclosure } from "@mantine/hooks"; import { IconCopy, IconSparkles } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { customJsxExamples, enabledCustomJsxComponents } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget. +const PROMPT_HEADER = `You are configuring a Homarr custom widget. Ask a concise clarifying question only when the requested behavior is ambiguous. -## Your Task -Generate a valid JSON configuration for a Homarr custom widget based on the user's description and the API response below. If you need clarification, ask specific questions. +For Custom JSX, return a Homarr authoring bundle with exactly two fenced code blocks: +1. A \`json\` block containing the complete import object. Set \`displayConfig.template\` to \`"__HOMARR_TEMPLATE__"\`. +2. A \`jsx\` block containing the readable, multiline template with no JSON escaping. -## Output Format -- Output the JSON inside a \`\`\`json code block for syntax highlighting. If \`\`\`json is not supported, use a generic \`\`\` code block instead. -- Do NOT include any text before or after the JSON block unless you have clarifying questions. -- If your environment supports structured/JSON output mode, use it. +The user can paste both blocks together into Homarr. Homarr combines and validates them before import. For non-Custom-JSX display types, return only the complete JSON block. -## JSON Schema -The output must conform to this JSON Schema: +## Rules +- Use \`"$schema": "homarr-custom-widget-v3"\`. +- \`displayType\` and \`displayConfig.type\` must match. +- Prefer \`customJsx\` with \`jsxApiVersion: 2\` for custom layouts. +- The base widget request must use GET. Mutations belong in named action requests. +- JSX cannot call \`fetch\` or use event handlers. Use SubFetch, ActionButton, ToggleSwitch, and RefreshButton. +- Version 2 network components reference a literal \`requestId\` and pass only declared \`params\`. +- SubFetch can reference query requests only. ActionButton and ToggleSwitch can reference action requests only. +- Prefer the SubFetch function-as-children API for fetched data. The callback receives \`(response, metadata)\` and may return any safe JSX tree. Use SubData only for very small path-based displays. +- SubFetch owns loading, failure, retry, cancellation, and stale-response handling. The render callback runs only when response data is available. +- Named request paths are same-origin paths beginning with \`/\`. Do not put credentials in a template or export. +- Query requests use GET and view permission. POST, PUT, and PATCH actions default to modify. DELETE requires full permission and confirmation. +- Use only components in the generated component reference below. +- Template output must fit inside a dashboard widget and remain usable at narrow widths. +- Return the complete import object, not a template fragment. Preserve unrelated fields from the current configuration. +- Include stable keys for rendered collections, accessible labels for controls and images, useful empty states, and concise content that works in light and dark themes. +- Prefer one clear information hierarchy over nested cards. Use ActionButton and ToggleSwitch only when the named action and required permission are declared. -`; +## Named request shape +\`\`\`ts +interface CustomJsxRequest { + id: string; + kind: "query" | "action"; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + pathTemplate: \`/\${string}\`; + parameters: Record; + bodyTemplate?: unknown; + staticHeaders?: Record; + auth: "inherit" | "none"; + minimumBoardPermission: "view" | "modify" | "full"; + cacheTtlSeconds?: number; +} +\`\`\` -const PROMPT_RULES = ` +Path parameters use \`{name}\`. JSON body parameters use \`{ "$param": "name" }\` and preserve the declared type. -## Key Rules -- The \`displayConfig.type\` field MUST match the \`displayType\` field exactly -- All \`jsonPath\` fields use JSONPath syntax (e.g. \`$.data.count\`, \`$.items[*].name\`) -- The \`$schema\` field should be \`"homarr-custom-widget-v2"\` -- Do NOT include secrets/passwords in the output — those are configured separately in the UI -- \`url\` must be a full URL including protocol (e.g. \`https://...\`) -- For arrays of items, inspect the API response to find the correct paths +## SubFetch rendering +Use a render child when a named query needs its own layout: -## Display Type Guide -- \`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\`) — Component Reference - -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. - -### Layout -Box, Stack, Group, Flex, Grid + Grid.Col, SimpleGrid, Center, Space, Container, AspectRatio, Overlay, ScrollArea - -### Typography -Text, Title, Code, Highlight, Mark, Kbd, Blockquote, Anchor (href sanitized), NumberFormatter, Marquee, RollingNumber - -### 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) - -### Navigation -Breadcrumbs, NavLink (href sanitized), Stepper + Stepper.Step, Tabs + Tabs.List + Tabs.Tab + Tabs.Panel, Tree - -### Interactive Display (visual only — no event handlers) -Button, ActionIcon, Burger, CloseButton, Chip + Chip.Group, Pagination, SegmentedControl, Slider (read-only), Switch (read-only) - -### Hover Overlays & Menus -HoverCard + HoverCard.Target + HoverCard.Dropdown, Menu + Menu.Target + Menu.Dropdown + Menu.Item + Menu.Label + Menu.Divider - -### Utility -CopyButton (click-to-copy, props: \`value\`), Collapse (animate show/hide, props: \`in\`), Transition (animate, props: \`mounted\`, \`transition\`) - -### 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 - - - + + {(detail, meta) => + + + {detail.name} + HTTP {meta.status} + + {detail.metrics.map((metric) => + + {metric.name} + {metric.value} + + )} + + } \`\`\` -**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 - -\`\`\` - -**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 - -\`\`\` - -**RefreshButton** — Re-fetches the parent widget data. -Props: \`label\`, \`color\`, \`variant\`, \`size\` - -### 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\` - -### 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:** constructor, __proto__, eval, Function, import, require, globalThis, window, document - -### 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\` - -### Pokédex Example (75 pokemon) -API: \`https://pokeapi.co/api/v2/pokemon?limit=75\` -\`\`\`jsx - - - Pokédex - {data.count} total - - - {data.results.map((pokemon, i) => - - - - - {pokemon.name} - #{String(i + 1).padStart(3, "0")} - - - - )} - - -\`\`\` - -### 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 +The callback is an interpreter-managed inline arrow function. Do not use native callbacks, event handlers, global state, or direct network access. Keep list keys stable and design for narrow and wide widget sizes. `; -const PROMPT_NO_RESPONSE = `Paste the raw JSON response from your API endpoint below: +const PROMPT_NO_RESPONSE = `No sample response is available. Leave this marker in your reasoning and ask the user for a representative response if its shape is required: \`\`\`json -PASTE_YOUR_API_RESPONSE_HERE +PASTE_API_RESPONSE_HERE \`\`\` `; -const PROMPT_FOOTER = ` -## Your Request -Describe what you want the widget to show: +function buildComponentReference() { + const categories = new Map(); + for (const component of enabledCustomJsxComponents) { + const names = categories.get(component.category) ?? []; + names.push(component.name); + categories.set(component.category, names); + } -`; + return [...categories.entries()].map(([category, names]) => `- ${category}: ${names.join(", ")}`).join("\n"); +} + +function buildExamples() { + return customJsxExamples + .map( + (example) => `### ${example.title} +${example.description} +\`\`\`json +${JSON.stringify( + { + displayType: "customJsx", + displayConfig: { + type: "customJsx", + jsxApiVersion: 2, + networkScope: "public", + template: "__HOMARR_TEMPLATE__", + requests: example.requests, + }, + }, + null, + 2, +)} +\`\`\` +\`\`\`jsx +${example.template} +\`\`\``, + ) + .join("\n\n"); +} function buildAiPrompt( jsonSchema: unknown, rawResponse?: string | null, currentConfig?: Record | null, ) { - const schemaStr = JSON.stringify(jsonSchema, null, 2); const responseSection = rawResponse - ? `The API returned the following JSON:\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n` - : PROMPT_NO_RESPONSE; - - const configSection = currentConfig - ? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n` + ? `## API response\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n` + : `## API response\n\n${PROMPT_NO_RESPONSE}`; + const currentDisplayConfig = currentConfig?.displayConfig; + const currentTemplate = + currentDisplayConfig && typeof currentDisplayConfig === "object" && "template" in currentDisplayConfig + ? currentDisplayConfig.template + : null; + const readableCurrentConfig = currentConfig + ? { + ...currentConfig, + ...(currentTemplate && typeof currentTemplate === "string" + ? { + displayConfig: { + ...(currentDisplayConfig as Record), + template: "__HOMARR_TEMPLATE__", + }, + } + : {}), + } + : null; + const configSection = readableCurrentConfig + ? `## Current configuration\n\nUse this as the starting point and preserve unrelated settings.\n\n\`\`\`json\n${JSON.stringify(readableCurrentConfig, null, 2)}\n\`\`\`\n${typeof currentTemplate === "string" ? `\n\`\`\`jsx\n${currentTemplate}\n\`\`\`\n` : ""}` : ""; - return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER; + return `${PROMPT_HEADER} +## Import schema +The \`__HOMARR_TEMPLATE__\` value is an authoring transport placeholder. Homarr replaces it with the following JSX block before schema and AST validation. + +\`\`\`json +${JSON.stringify(jsonSchema, null, 2)} +\`\`\` + +## Generated component reference +${buildComponentReference()} + +## Tested examples +${buildExamples()} + +${responseSection} +${configSection} +## Requested widget +Describe what the widget should show and which interactions it needs: +`; } interface CopyAiPromptButtonProps { @@ -285,9 +187,8 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB const handleCopy = async () => { if (!schema) return; - const prompt = buildAiPrompt(schema, rawResponse, currentConfig); try { - await navigator.clipboard.writeText(prompt); + await navigator.clipboard.writeText(buildAiPrompt(schema, rawResponse, currentConfig)); close(); showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") }); } catch { diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index ec8ba59c9..bb3fd3724 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -1,16 +1,22 @@ "use client"; -import { useRef } from "react"; -import { ActionIcon, Menu } from "@mantine/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - IconCopy, - IconDots, - IconDownload, - IconToggleLeft, - IconToggleRight, - IconTrash, - IconUpload, -} from "@tabler/icons-react"; + ActionIcon, + Alert, + Badge, + Button, + Code, + Group, + Menu, + Modal, + Paper, + SimpleGrid, + Stack, + Text, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconAlertTriangle, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; @@ -19,6 +25,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica import { useScopedI18n } from "@homarr/translation/client"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; +import { getImportReview, parseCustomWidgetClipboard } from "./_custom-widget-import-utils"; const iconProps = { size: 16, stroke: 1.5 }; @@ -33,28 +40,8 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { const { openConfirmModal } = useConfirmModal(); const deleteMutation = clientApi.customWidget.delete.useMutation(); const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); - const toggleEnabledMutation = clientApi.customWidget.toggleEnabled.useMutation(); const utils = clientApi.useUtils(); - const handleToggleEnabled = () => { - toggleEnabledMutation.mutate( - { id: widget.id, enabled: !widget.enabled }, - { - onSuccess: () => { - void utils.customWidget.all.invalidate(); - void utils.widget.customApi.getData.invalidate({ definitionId: widget.id }); - void revalidatePathActionAsync("/manage/custom-widgets"); - }, - onError: () => { - showErrorNotification({ - title: widget.enabled ? t("action.disable") : t("action.enable"), - message: t("notification.toggleError"), - }); - }, - }, - ); - }; - const handleExport = async () => { try { const data = await utils.customWidget.export.fetch({ id: widget.id }); @@ -103,7 +90,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { message: t("notification.deleted", { name: widget.name }), }); void utils.customWidget.all.invalidate(); - void utils.widget.customApi.getData.invalidate({ definitionId: widget.id }); + void utils.widget.customApi.getData.invalidate(); void revalidatePathActionAsync("/manage/custom-widgets"); }, onError: () => { @@ -123,14 +110,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { - : } - disabled={toggleEnabledMutation.isPending} - > - {widget.enabled ? t("action.disable") : t("action.enable")} - - } @@ -158,9 +137,13 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { export const ImportCustomWidgetButton = () => { const t = useScopedI18n("customWidget"); const fileInputRef = useRef(null); + const [pendingImport, setPendingImport] = useState | null>(null); + const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false); const utils = clientApi.useUtils(); const importMutation = clientApi.customWidget.import.useMutation({ onSuccess: () => { + closeReview(); + setPendingImport(null); showSuccessNotification({ title: t("action.import"), message: t("notification.imported") }); void utils.customWidget.all.invalidate(); void revalidatePathActionAsync("/manage/custom-widgets"); @@ -169,23 +152,45 @@ export const ImportCustomWidgetButton = () => { showErrorNotification({ title: t("action.import"), message: t("notification.importError") }); }, }); + const review = useMemo(() => getImportReview(pendingImport), [pendingImport]); + const queueImport = useCallback( + (value: Record) => { + setPendingImport(value); + openReview(); + }, + [openReview], + ); + + useEffect(() => { + const handlePaste = (event: ClipboardEvent) => { + const target = event.target as HTMLElement | null; + if (target?.matches("input, textarea, [contenteditable='true']")) return; + const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? ""); + if (!widget) return; + event.preventDefault(); + queueImport(widget); + }; + window.addEventListener("paste", handlePaste); + return () => window.removeEventListener("paste", handlePaste); + }, [queueImport]); const handleImport = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); - reader.onload = (e) => { + reader.addEventListener("load", (e) => { try { - const json = JSON.parse(e.target?.result as string); - importMutation.mutate(json); + const widget = parseCustomWidgetClipboard(e.target?.result as string); + if (!widget) throw new Error("Invalid import"); + queueImport(widget); } catch { showErrorNotification({ title: t("action.import"), message: t("notification.importError") }); } - }; - reader.onerror = () => { + }); + reader.addEventListener("error", () => { showErrorNotification({ title: t("action.import"), message: t("notification.importError") }); - }; + }); reader.readAsText(file); event.target.value = ""; }; @@ -197,17 +202,92 @@ export const ImportCustomWidgetButton = () => { leftSection={} onClick={() => fileInputRef.current?.click()} loading={importMutation.isPending} + title={t("action.pasteImportHint")} > {t("action.import")} + + {pendingImport && review && ( + + + {t("importReview.description")} + + + + + + + + +
    + + {t("importReview.methods")} + + + {review.methods.map((method) => ( + + {method} + + ))} + +
    + +
    + + {t("importReview.permissions")} + + + {review.permissions.map((permission) => ( + + {t(`preview.request.permission.${permission}` as never)} + + ))} + +
    + + {review.hasActions && ( + }> + + {t("importReview.actionWarning.title")} + + {t("importReview.actionWarning.description")} + + )} + + + + + +
    + )} +
    ); }; + +function ImportFact({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + {value || "—"} + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css new file mode 100644 index 000000000..54bff70b6 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css @@ -0,0 +1,117 @@ +.form { + min-width: 0; +} + +.workbench { + display: grid; + gap: var(--mantine-spacing-xl); + align-items: start; +} + +.paneSwitcher { + position: sticky; + top: var(--mantine-spacing-xs); + z-index: 10; + margin-bottom: var(--mantine-spacing-lg); + background: var(--mantine-color-body); +} + +@media (max-width: 74.9375em) { + .workbench[data-mobile-pane="configure"] .previewPane, + .workbench[data-mobile-pane="preview"] .configuration { + display: none; + } +} + +.configuration, +.previewPane { + min-width: 0; +} + +.previewPane { + display: flex; + flex-direction: column; + gap: var(--mantine-spacing-md); +} + +.actionBar { + position: sticky; + bottom: var(--mantine-spacing-sm); + z-index: 10; + background: var(--mantine-color-body); +} + +.mobileSaveBar { + position: sticky; + bottom: var(--mantine-spacing-sm); + z-index: 10; + background: var(--mantine-color-body); +} + +.displayTypeOption { + display: flex; + align-items: flex-start; + gap: var(--mantine-spacing-sm); + min-height: 7rem; + padding: var(--mantine-spacing-md); + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-body); + transition: + border-color 160ms ease, + background-color 160ms ease; +} + +.displayTypeOption:hover { + background: var(--mantine-color-default-hover); +} + +.displayTypeOption[data-selected] { + border-color: var(--mantine-primary-color-filled); + background: var(--mantine-primary-color-light); +} + +.displayTypeOption:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; +} + +.displayTypeCopy { + min-width: 0; +} + +@media (prefers-reduced-motion: reduce) { + .displayTypeOption { + transition: none; + } +} + +@media (min-width: 75em) { + .paneSwitcher { + display: none; + } + + .mobileSaveBar { + display: none; + } + + .workbench { + grid-template-columns: 1fr 1fr; + } + + .previewPane { + position: sticky; + top: 5rem; + } + + .actionBar { + top: 5rem; + bottom: auto; + } +} + +@media (min-width: 100em) { + .workbench { + grid-template-columns: 0.9fr 1.1fr; + } +} 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 2bb7424b0..2a3599b17 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 @@ -4,28 +4,52 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { ActionIcon, + Alert, + Badge, Box, Button, Fieldset, Group, NumberInput, + Paper, PasswordInput, SegmentedControl, Select, + SimpleGrid, Stack, + Stepper, Switch, Text, TextInput, Textarea, + ThemeIcon, + UnstyledButton, } from "@mantine/core"; -import { IconPlayerPlay, IconPlus, IconTrash } from "@tabler/icons-react"; +import { + Icon123, + IconActivityHeartbeat, + IconAlertTriangle, + IconBraces, + IconCheck, + IconCode, + IconHash, + IconLayoutGrid, + IconListDetails, + IconPlayerPlay, + IconPointer, + IconProgress, + IconTable, + IconTrash, + IconPlus, +} from "@tabler/icons-react"; import { z } from "zod/v4"; import { clientApi } from "@homarr/api/client"; import { useZodForm } from "@homarr/form"; import { IconPicker } from "@homarr/forms-collection"; +import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { CustomWidgetAuthType, @@ -36,9 +60,23 @@ import type { import type { displayConfigSchema } from "@homarr/validation/custom-widget"; import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker"; +import { + analyzeJsxTemplate, + analyzeRequestManifest, + CodeEditor, + CUSTOM_JSX_TEMPLATE_LIMIT, + parseRequestManifest, +} from "./_code-editor"; import { CopyAiPromptButton } from "./_copy-ai-prompt-button"; import { CustomWidgetPreview } from "./_custom-widget-preview"; import type { PreviewFetchResult } from "./_custom-widget-preview"; +import { + buildDisplayFormValues, + CUSTOM_JSX_STARTER, + getImportReview, + parseCustomWidgetClipboard, +} from "./_custom-widget-import-utils"; +import classes from "./_custom-widget-form.module.css"; const requiredFieldValidators: Record, ctx: z.core.$RefinementCtx) => void> = { singleValue: (data, ctx) => { @@ -240,15 +278,23 @@ const requiredFieldValidators: Record, ct input: data.template, path: ["template"], }); + if (data.jsxApiVersion === "2" && analyzeRequestManifest(String(data.requestManifest ?? "[]")).length > 0) { + ctx.addIssue({ + code: "custom", + message: "invalidRequestManifest", + input: data.requestManifest, + path: ["requestManifest"], + }); + } }, }; const formSchema = z .object({ - name: z.string().min(1).max(128), + name: z.string().trim().min(1).max(128), description: z.string(), iconUrl: z.string(), - url: z.string().min(1), + url: z.string().trim().min(1), authType: z.string(), headerName: z.string(), method: z.string(), @@ -306,6 +352,9 @@ const formSchema = z confirmText: z.string(), successMessage: z.string(), template: z.string(), + jsxApiVersion: z.string(), + networkScope: z.string(), + requestManifest: z.string(), secrets: z.array( z.object({ kind: z.string(), @@ -374,7 +423,10 @@ const defaultCreateValues: z.infer = { buttonColor: "blue", confirmText: "", successMessage: "", - template: "", + template: CUSTOM_JSX_STARTER, + jsxApiVersion: "2", + networkScope: "public", + requestManifest: "[]", secrets: [], }; @@ -451,7 +503,16 @@ const displayConfigBuilders: Record) confirmText: v.confirmText || undefined, successMessage: v.successMessage || undefined, }), - customJsx: (v) => ({ type: "customJsx", template: v.template }), + customJsx: (v) => + v.jsxApiVersion === "2" + ? { + type: "customJsx", + jsxApiVersion: 2, + template: v.template.trim() || CUSTOM_JSX_STARTER, + networkScope: v.networkScope, + requests: parseRequestManifest(v.requestManifest), + } + : { type: "customJsx", template: v.template.trim() || CUSTOM_JSX_STARTER }, }; const serverToFormFieldMap: Record> = { @@ -464,7 +525,7 @@ const serverToFormFieldMap: Record> = { singleValue: { jsonPath: "jsonPath", label: "label", unit: "unit" }, raw: { jsonPath: "rawJsonPath" }, actionButton: { buttonLabel: "buttonLabel" }, - customJsx: { template: "template" }, + customJsx: { template: "template", requests: "requestManifest", networkScope: "networkScope" }, }; function extractServerErrors(err: unknown, displayType: string): Record { @@ -539,6 +600,19 @@ const ALL_DISPLAY_TYPES = [ "actionButton", "customJsx", ] as const; + +const DISPLAY_TYPE_ICONS: Record<(typeof ALL_DISPLAY_TYPES)[number], typeof Icon123> = { + singleValue: Icon123, + keyValue: IconListDetails, + table: IconTable, + statGrid: IconLayoutGrid, + progressBars: IconProgress, + statusIndicator: IconActivityHeartbeat, + countGrid: IconHash, + raw: IconBraces, + actionButton: IconPointer, + customJsx: IconCode, +}; const MANTINE_COLORS = [ "blue", "teal", @@ -554,23 +628,165 @@ const MANTINE_COLORS = [ "lime", ] as const; +const REQUEST_MANIFEST_STARTER = `[ + { + "id": "details", + "kind": "query", + "method": "GET", + "pathTemplate": "/api/details/{id}", + "parameters": { "id": "string" }, + "auth": "inherit", + "minimumBoardPermission": "view", + "cacheTtlSeconds": 30 + } +]`; + export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) { const router = useRouter(); + const globalT = useI18n(); const t = useScopedI18n("customWidget"); + const { openConfirmModal } = useConfirmModal(); const utils = clientApi.useUtils(); const createMutation = clientApi.customWidget.create.useMutation(); const updateMutation = clientApi.customWidget.update.useMutation(); const previewMutation = clientApi.customWidget.preview.useMutation(); + const setPreviewLiveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation(); const [previewRefreshSignal, setPreviewRefreshSignal] = useState(0); const [previewJson, setPreviewJson] = useState(null); const [previewFetchResult, setPreviewFetchResult] = useState(null); + const [lastPreviewFingerprint, setLastPreviewFingerprint] = useState(null); + const [activeStep, setActiveStep] = useState(0); + const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure"); const hasTestedRef = useRef(false); - const form = useZodForm(formSchema, { - initialValues: { ...defaultCreateValues, ...initialValues }, - }); + const mergedInitialValues = { ...defaultCreateValues, ...initialValues }; + if (mergedInitialValues.displayType === "customJsx" && !mergedInitialValues.template.trim()) { + mergedInitialValues.template = CUSTOM_JSX_STARTER; + } + const form = useZodForm(formSchema, { initialValues: mergedInitialValues }); + + const queueClipboardReplacement = useCallback( + (widget: Record) => { + const review = getImportReview(widget); + if (!review) return; + openConfirmModal({ + title: t("importReview.replaceTitle"), + children: ( + + {t("importReview.replaceDescription", { name: review.name, origin: review.origin })} + + {review.methods.map((method) => ( + + {method} + + ))} + + {review.hasActions && ( + }> + {t("importReview.actionWarning.description")} + + )} + + ), + labels: { confirm: t("importReview.replaceConfirm"), cancel: t("importReview.cancel") }, + onConfirm: () => { + const displayType = String(widget.displayType); + const displayConfig = widget.displayConfig as Record; + const authType = typeof widget.authType === "string" ? widget.authType : "none"; + const secrets = (authTypeSecretFields[authType] ?? []).map((field) => { + const existing = form.values.secrets.find((secret) => secret.kind === field.kind); + return { kind: field.kind, value: existing?.value ?? "", hasValue: existing?.hasValue ?? false }; + }); + form.setValues({ + ...form.values, + name: String(widget.name), + description: typeof widget.description === "string" ? widget.description : "", + iconUrl: typeof widget.iconUrl === "string" ? widget.iconUrl : "", + url: String(widget.url), + authType, + headerName: typeof widget.headerName === "string" ? widget.headerName : "", + method: typeof widget.method === "string" ? widget.method : "GET", + requestBody: typeof widget.requestBody === "string" ? widget.requestBody : "", + ...buildDisplayFormValues(displayType, displayConfig), + secrets, + }); + setPreviewJson(null); + setPreviewFetchResult(null); + setLastPreviewFingerprint(null); + showSuccessNotification({ title: t("action.import"), message: t("notification.pastedForReplacement") }); + }, + }); + }, + [form, openConfirmModal, t], + ); + + useEffect(() => { + if (mode !== "edit") return; + const handlePaste = (event: ClipboardEvent) => { + const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? ""); + if (!widget) return; + event.preventDefault(); + queueClipboardReplacement(widget); + }; + window.addEventListener("paste", handlePaste); + return () => window.removeEventListener("paste", handlePaste); + }, [mode, queueClipboardReplacement]); + + const requestManifest = parseRequestManifest(form.values.requestManifest); + const requestIds = requestManifest + .map((request) => (request && typeof request === "object" && "id" in request ? request.id : null)) + .filter((id): id is string => typeof id === "string"); + const editorDiagnostics = + form.values.displayType === "customJsx" + ? analyzeJsxTemplate(form.values.template, { + apiVersion: form.values.jsxApiVersion === "2" ? 2 : 1, + requestIds, + }) + : []; + const requestDiagnostics = + form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2" + ? analyzeRequestManifest(form.values.requestManifest) + : []; + const hasEditorErrors = [...editorDiagnostics, ...requestDiagnostics].some( + (diagnostic) => diagnostic.severity === "error", + ); + const isDirtyRef = useRef(false); + isDirtyRef.current = form.isDirty(); + + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (isDirtyRef.current) event.preventDefault(); + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, []); + + useEffect(() => { + if (form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2" && form.values.method !== "GET") { + form.setFieldValue("method", "GET"); + form.setFieldValue("requestBody", ""); + } + }, [form, form.values.displayType, form.values.jsxApiVersion, form.values.method]); + + const focusStepForErrors = (errors: Record) => { + const fields = Object.keys(errors); + if (fields.some((field) => ["name", "description", "iconUrl", "displayType"].includes(field.split(".")[0] ?? ""))) { + setActiveStep(0); + return; + } + if ( + fields.some((field) => + ["url", "method", "authType", "headerName", "requestBody", "secrets"].includes(field.split(".")[0] ?? ""), + ) + ) { + setActiveStep(1); + return; + } + setActiveStep(2); + }; const handleSubmit = form.onSubmit(async (values) => { + if (hasEditorErrors) return; const buildConfig = displayConfigBuilders[values.displayType] ?? displayConfigBuilders.singleValue; const displayConfig = buildConfig?.(values); @@ -611,12 +827,15 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi setPreviewRefreshSignal((n) => n + 1); await utils.customWidget.all.invalidate(); await utils.customWidget.byId.invalidate({ id: definitionId }); - await utils.widget.customApi.getData.invalidate({ definitionId }); + await utils.widget.customApi.getData.invalidate(); + form.setInitialValues(values); + form.resetDirty(); } } catch (err) { const serverErrors = extractServerErrors(err, values.displayType); if (Object.keys(serverErrors).length > 0) { form.setErrors(serverErrors); + focusStepForErrors(serverErrors); } const errorKey = mode === "create" ? "notification.createError" : "notification.updateError"; showErrorNotification({ @@ -624,9 +843,21 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi message: t(errorKey as never), }); } - }); + }, focusStepForErrors); const secretFields = authTypeSecretFields[form.values.authType] ?? []; + const authTypeOptions = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"].map((value) => ({ + value, + label: t(`authType.${value}` as never), + })); + const handleAuthTypeChange = (value: string) => { + form.setFieldValue("authType", value); + const newSecrets = (authTypeSecretFields[value] ?? []).map((field) => ({ + kind: field.kind, + value: form.values.secrets.find((secret) => secret.kind === field.kind)?.value ?? "", + })); + form.setFieldValue("secrets", newSecrets); + }; const getPreviewInput = useCallback(() => { const values = form.values; @@ -651,7 +882,9 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi const handlePreviewTest = useCallback(async () => { const input = getPreviewInput(); - if (!input.url) return; + if (!input.url || input.method !== "GET") return; + setMobilePane("preview"); + setLastPreviewFingerprint(JSON.stringify(input)); try { const res = await previewMutation.mutateAsync(input); hasTestedRef.current = true; @@ -660,6 +893,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi error: res.success ? undefined : res.error, responseInfo: res.responseInfo, rawResponse: res.rawResponse, + previewSession: res.previewSession, }); if (res.success && res.rawResponse) { try { @@ -676,205 +910,411 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi error: t("notification.previewError"), responseInfo: null, rawResponse: null, + previewSession: null, }); setPreviewJson(null); } - }, [getPreviewInput, previewMutation]); + }, [getPreviewInput, previewMutation, t]); + + const handleSetPreviewLiveActions = useCallback( + async (enabled: boolean) => { + const sessionId = previewFetchResult?.previewSession?.id; + if (!sessionId) return; + try { + const previewSession = await setPreviewLiveActionsMutation.mutateAsync({ sessionId, enabled }); + setPreviewFetchResult((current) => (current ? { ...current, previewSession } : current)); + } catch { + showErrorNotification({ + title: t("preview.capabilities.liveActions"), + message: t("notification.previewActionsError"), + }); + } + }, + [previewFetchResult?.previewSession?.id, setPreviewLiveActionsMutation, t], + ); + + const handlePreviewTestRef = useRef(handlePreviewTest); + handlePreviewTestRef.current = handlePreviewTest; + const getPreviewInputRef = useRef(getPreviewInput); + getPreviewInputRef.current = getPreviewInput; + const pendingRefreshRef = useRef(false); useEffect(() => { if (previewRefreshSignal > 0 && hasTestedRef.current) { - void handlePreviewTest(); + if (previewMutation.isPending) { + pendingRefreshRef.current = true; + return; + } + const input = getPreviewInputRef.current(); + if (input.method === "GET") { + void handlePreviewTestRef.current(); + } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only react to signal changes }, [previewRefreshSignal]); + useEffect(() => { + if (!previewMutation.isPending && pendingRefreshRef.current) { + pendingRefreshRef.current = false; + const input = getPreviewInputRef.current(); + if (input.method === "GET" && hasTestedRef.current) { + void handlePreviewTestRef.current(); + } + } + }, [previewMutation.isPending]); + const previewInput = getPreviewInput(); + const isPreviewStale = lastPreviewFingerprint !== null && lastPreviewFingerprint !== JSON.stringify(previewInput); + const handleInsertDataPath = useCallback( + (path: string) => { + if (form.values.displayType !== "customJsx") return; + const template = form.values.template.trimEnd(); + const snippet = `{${path}}`; + form.setFieldValue("template", template.length > 0 ? `${template}\n${snippet}` : snippet); + }, + [form], + ); + const handleSampleDataChange = useCallback((value: unknown) => { + const rawResponse = JSON.stringify(value, null, 2); + setPreviewJson(value); + setPreviewFetchResult({ + success: true, + responseInfo: null, + rawResponse, + previewSession: null, + }); + setLastPreviewFingerprint(null); + }, []); + + const NEXT_STEP_LABELS: Record = { + 0: t("steps.connection.label"), + 1: t("steps.configure.label"), + }; + + const STEP_FIELDS: Record = { + 0: ["name"], + 1: ["url"], + }; + + const handleNextStep = () => { + const fieldsToValidate = STEP_FIELDS[activeStep]; + if (!fieldsToValidate) { + setActiveStep((step) => Math.min(2, step + 1)); + return; + } + const hasErrors = fieldsToValidate.some((field) => form.validateField(field).hasError); + if (hasErrors) return; + setActiveStep((step) => Math.min(2, step + 1)); + }; return ( -
    - - -
    - - -