fix(custom-widgets): harden v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 22:16:18 +02:00
1 parent ee832ad3c6
commit 7c66c912e7
171 files changed
+20311 -50527

No files matched your search

@@ -105,7 +105,7 @@ External redis is currently not supported with Proxmox Community Scripts
The advanced deployments environment variables should only be used if you know what you are doing.
:::
| Environment Variable | Description |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
| `NEXT_PUBLIC_WORKSHOP_API_URL` | Build-time public origin for a separately hosted Workshop PocketBase service. Defaults to `https://homarr.dev`. |
| Environment Variable | Description |
| ------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
| `WORKSHOP_API_URL` | Runtime public origin of the separate Workshop PocketBase service. Homarr uses `https://homarr.dev` by default. |
+13 -3
View File
@@ -27,11 +27,21 @@ So make sure to manually whitelist this address.
### Can I add my own widgets? Do I need programming skills?
Yes. [Custom Widgets](/docs/management/custom-widgets) can read public or private HTTP APIs, render responsive Mantine JSX, expose per-board options, and run permission-checked actions. Custom JSX v2 is the only custom-widget format.
Yes. A [Custom Widget](/docs/management/custom-widgets) can read a public or private HTTP API.
You can author the JSON and JSX directly, use the one-click prompt with ChatGPT or another assistant, or let an MCP agent validate and preview a widget before saving it. The workbench parses a pasted AI response, shows request capabilities, and produces redacted diagnostics for another iteration.
It can show responsive Mantine JSX. It can also use widget options and actions with board permission checks.
The template is declarative: it cannot import packages, execute arbitrary JavaScript, or call browser `fetch`. Network operations are named, same-origin server requests protected by Homarr's board permissions and network policy. You should still review origins, methods, permissions, and actions before installing or publishing a widget.
Custom JSX v2 is the only Custom Widget format. Write the JSON and JSX in the workbench.
You can also use the AI copy prompt. An MCP agent can validate and preview a definition before it saves the definition.
The workbench reads a pasted AI response. It shows request capabilities and redacted diagnostics.
A template cannot import packages or run arbitrary JavaScript. It cannot call browser `fetch`.
Named requests use the Homarr server. The server applies board permissions and the network policy.
Before installation, examine the origins, methods, permissions, and actions. Use the same review before publication.
### Is it secure to expose Homarr to the web?
+7 -5
View File
@@ -84,11 +84,13 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
## Custom Widget Authoring Resources
## Get Custom Widget authoring resources
Custom JSX v2 has two stable HTTP discovery endpoints:
Custom JSX v2 provides two HTTP discovery endpoints:
- `GET /api/custom-widgets/authoring-prompt` returns the concise current authoring prompt and live resource identifiers.
- `GET /api/custom-widgets/skill` returns the Agent Skill name, version, live `SKILL.md`, skills.sh URL, repository source URL, and install command.
- `GET /api/custom-widgets/authoring-prompt` returns the current authoring prompt and live resource identifiers.
- `GET /api/custom-widgets/skill` returns information about the Agent Skill.
The short-lived credential entry route is generated by `customWidget_secretRequestUser`; do not construct or persist those URLs yourself. Definition CRUD, validation, previews, template patches, and secret writes are exposed through authenticated tRPC and MCP procedures. Exported definitions never include credentials.
Call `customWidget_secretRequestUser` to create a temporary credential entry URL. Do not construct or save this URL.
Authenticated tRPC and MCP procedures support definition management, validation, previews, template patches, and credential writes. An exported definition does not contain credentials.
+1 -1
View File
@@ -29,7 +29,7 @@ Click the **Export** button in the export card. You'll be prompted to type `I un
| `db.sqlite` | Full SQLite database with all entities |
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database)
The backup contains the complete database. It includes Custom Widgets and all other Homarr configuration.
## Restoring a Backup
@@ -1,28 +1,47 @@
# Agents and skills.sh
# Use agents and skills.sh
An external agent creates widgets one at a time through Homarr's MCP server. The harness owns any multi-widget task list; Homarr does not create packs or multi-widget drafts.
An external agent creates one widget at a time with the Homarr MCP server. The agent harness manages the list for multiple widgets.
Homarr does not create widget packs or drafts for multiple widgets.
<a className="button button--primary" href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget">
View the Custom Widget Skill on skills.sh
</a>
Install the Agent Skill directly from the verified Homarr repository:
Install the Agent Skill from the verified Homarr repository:
```sh
npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget
```
The portable skill is a tool-agnostic authoring reference. Connected workflow and tool instructions come from Homarr's native MCP prompt instead, so the same complete skill can also be embedded safely in the UI's offline copy/paste prompt.
The portable skill gives authoring instructions that do not require a tool. The Homarr MCP prompt gives instructions for a connected workflow.
The source lives at [`.agents/skills/homarr-custom-widget`](https://github.com/homarr-labs/homarr/tree/dev/.agents/skills/homarr-custom-widget). There is no ZIP archive. After the skill exists on the repository's default branch, run the command once to register installation activity, verify the skills.sh page, and request official maker attribution for `homarr-labs` through skills.sh. Repository code cannot grant that attribution itself.
The offline copy prompt includes the complete portable skill and all eight release-matched references. Those references contain the generated component APIs and runtime examples, so Homarr does not append duplicate raw catalog data. A model can author directly from this bundle without MCP tools or repository access.
## MCP discovery
The prompt starts with the user's widget request. It ends with:
```text
--- END HOMARR CUSTOM WIDGET OFFLINE BUNDLE ---
Characters: <exact clipboard character count>
```
Homarr computes the total after assembling the prompt, including the digits in the total itself. The sentinel and count make clipboard truncation detectable. A missing sentinel or mismatched length means the bundle is incomplete and should be copied again.
The source is in [`.agents/skills/homarr-custom-widget`](https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget). `HEAD` follows the repository default branch, so the link is not tied to an unreleased feature branch. Homarr does not provide a ZIP archive.
After publication, run the installation command once. This action registers installation activity.
Then, verify the skills.sh page. Request official maker attribution for `homarr-labs` from skills.sh.
Repository code cannot give this attribution.
## Get MCP resources
- Prompt: `homarr-custom-widget-author`
- Resources: `homarr://custom-widgets/schema`, `components`, `components/{name}`, `examples/{name}`, and `skill`
- HTTP: `GET /api/custom-widgets/authoring-prompt` and `GET /api/custom-widgets/skill`
The authoring-prompt tool description intentionally contains only one sentence. Workflow instructions live in the prompt and link to resources.
The authoring-prompt tool description contains one sentence. The prompt contains the workflow instructions and resource links.
`GET /api/custom-widgets/skill`, the MCP tool, and the MCP resource return the live `SKILL.md` with:
@@ -37,17 +56,24 @@ The authoring-prompt tool description intentionally contains only one sentence.
}
```
They do not return an archive URL or hash.
These interfaces do not return an archive URL or archive hash.
## Agent loop
## Create a Custom Widget with MCP
1. Fetch the prompt, live schema, and component catalog.
1. Get the prompt, live schema, and component catalog.
2. Read the external API documentation.
3. Build and validate one definition without saving.
4. Create a preview session and test its queries/actions.
5. Open the returned preview URL with browser tools.
6. Set a supplied credential with the dedicated secret-write permission, or ask the user through the ten-minute Homarr credential URL.
7. Inspect the redacted journal and patch until valid.
8. Create the widget, then repeat for the next requested widget.
3. Build one definition.
4. Validate the definition without saving it.
5. Create a preview session.
6. Test the queries and actions.
7. Open the preview URL with browser tools.
8. Set the credential if the user supplied it.
9. Otherwise, ask the user for the credential with the ten-minute Homarr credential URL.
10. Inspect the redacted journal.
11. Patch the definition until it is valid.
12. Create the widget.
13. Repeat the procedure for the next widget.
Credential URLs are scoped to one definition/preview, source, and required fields. The agent receives only pending/completed status. Plaintext is encrypted immediately and never returned.
Each credential URL applies to one definition or preview, one source, and the required fields. The agent receives only the request status.
Homarr encrypts the plaintext immediately. Homarr does not return the plaintext.
@@ -1,47 +1,125 @@
# Workbench and ChatGPT
# Create a Custom Widget
import { CanonicalCustomWidgetExample } from "@site/src/components/custom-widget-code";
The desktop workbench keeps code and preview side by side. Mobile uses Configure and Preview tabs. Save and preview actions stay available while the form scrolls.
The desktop workbench shows the code and preview next to each other. On a mobile device, use the Configure and Preview tabs.
CodeMirror edits requests, options, and JSX. The editor supplies installed Mantine components, request/source IDs, option names, temporary input names, runtime components, and icons. The request toolbar jumps to a declaration and atomically renames its invalidation, dynamic-option, and JSX references. Save validation errors appear as selectable plain text with a direct **Copy errors** action.
The save and preview actions remain available when the form scrolls.
The **Schema** action beside Undo, Redo, and Format expands the generated JSON Schema plus minimal and full examples. These references are generated from the same validators used by imports, MCP, Workshop, and saved definitions.
Use CodeMirror to edit requests, widget options, and JSX. The editor gives completions for the installed Mantine components.
## Manual workflow
The editor also gives completions for identifiers, widget options, temporary inputs, runtime components, and icons.
1. Add the API source and select its network scope and authentication mode.
2. Enter credentials. Existing values are shown only as configured; plaintext is never revealed.
3. Declare named load/manual queries and actions.
4. Define persisted item options.
5. Write JSX using `data`, `status`, `options`, and temporary `inputs`.
6. Select **Test and preview**. Its loading state shows while load requests run; the Data tab dot and result summary turn green or red when they finish. Open Data for responses and request errors, then preview compact/standard/wide layouts and inspect runtime and accessibility diagnostics. A preview that initially lacks request data retries rendering when that data arrives.
7. Save, then select the definition and its options in the board widget picker.
Use the request toolbar to go to a declaration. The rename action changes all related request references.
## ChatGPT copy/paste
The rename operation updates `invalidates`, dynamic widget option, and JSX references as one transaction.
Enter a short request and optional API documentation URL, then select **Copy AI prompt**. Its dropdown also offers a self-contained version with the complete Custom Widget authoring skill embedded. Both copied prompts are strictly offline authoring references: they contain no MCP resource names, tool calls, or connected-workflow instructions. Paste either prompt into ChatGPT or another model. The model must return exactly:
Save errors appear as selectable text. Use **Copy errors** to copy them.
Select **Schema** next to Undo, Redo, and Format. This action shows the generated JSON Schema and two examples.
Homarr generates these references from the canonical Zod schemas. Imports, MCP, Workshop, and saved definitions use the same schemas.
## Create a Custom Widget manually
1. Add the API source.
2. Select its network scope.
3. Select its authentication mode.
4. Enter the credentials.
5. Declare the named requests.
6. Define the widget options.
7. Write JSX with `data`, `status`, `options`, and temporary `inputs`.
8. Select **Test and preview**.
9. Wait for the load requests to stop.
10. Open the Data tab to examine responses and request errors.
11. Preview the compact, standard, and wide layouts.
12. Examine the runtime and accessibility diagnostics.
13. Save the definition.
14. Select the definition and its widget options in the board widget picker.
Homarr does not show existing plaintext credentials. It shows only the configuration status.
The preview action shows a loading state during load requests. The Data tab indicator shows green or red after completion.
The preview renders again when request data becomes available.
## Create a Custom Widget with ChatGPT
Enter a short request. You can also enter an API documentation URL.
Select **Copy AI prompt**. The menu also provides **Copy with embedded skill**, a complete release-matched offline prompt.
Both prompts support offline authoring. They do not require MCP resources, MCP tools, or file-system access.
Paste one prompt into ChatGPT or a different model. Tell the model to return exactly these two blocks:
1. A fenced `json` block with one complete widget and `template: "__HOMARR_TEMPLATE__"`.
2. A fenced `jsx` block with the readable template.
Use **Paste AI response** to fill the workbench. Enter credentials yourself, test, and preview. If saving fails, select or copy the validation errors shown by Homarr and give that plain text to the model. Homarr does not generate a separate fix prompt or assume the model can access local skill files.
Select **Paste AI response** to fill the workbench. Enter the credentials.
The normal prompt is under 8,000 characters. It includes the installed Mantine version and capability rules but not a complete prop list, MCP workflow, server URL, or credentials. The embedded-skill variant is longer because it includes the portable offline instructions.
Test the requests. Preview the widget.
## Options
If the save operation fails, copy the validation errors. Give the error text to the model.
Options use a restricted object JSON Schema. Supported controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, URL, nested groups, conditions, and advanced JSON.
Homarr does not make a separate correction prompt. Homarr does not assume that the model can read local skill files.
Credentials are not widget options. Credential-like option names and static default values are rejected; configure authentication under the widget's API source so values stay encrypted and out of exports and board configuration.
The standard prompt contains fewer than 8,000 characters. It includes the installed Mantine version and the capability rules.
Dynamic selects use `x-homarr.optionsSource` and a named query. Add `itemsPath` when the response wraps its result array, then use `valuePath` and `labelPath` relative to each item. These requests run only while configuring the item and use the same protected server executor as widget requests.
It does not include a complete prop list, MCP workflow, server URL, or credentials. The prompt with the embedded skill is intentionally longer. It starts with your widget request, then includes:
### Named requests and parameter bindings
- `SKILL.md` and all eight release-matched reference files
- The Custom Widget, Mantine, skill, and catalog-schema versions
Request parameters are a map from parameter name to `string`, `number`, or `boolean`. Query and body templates reference them with `{ "$param": "name" }`. Declaring a parameter does not give it a value, even when an option or temporary input has the same name.
The generated reference files already contain the exact component and prop APIs. Homarr does not append a second raw catalog or a duplicate examples payload.
For a load query, bind every parameter explicitly in `optionsBinding`. Each entry is either `{ "$option": "optionName" }` or a primitive literal matching the declared parameter type:
The embedded prompt ends with this sentinel and an exact total character count:
```text
--- END HOMARR CUSTOM WIDGET OFFLINE BUNDLE ---
Characters: <exact clipboard character count>
```
The count includes the whole prompt and the count itself. If the sentinel is missing or the number does not match the clipboard length, copy the prompt again before sending it to a model.
The embedded content explicitly tells a model to author directly from the offline bundle. It does not claim that MCP tools, Homarr resources, repository files, or file-system access exist in the chat.
## Configure widget options
Widget options use a restricted object JSON Schema. Homarr supports the following controls:
- Text
- Text area
- Number
- Switch
- Select
- Multi-select
- Slider
- Date
- Time
- Color
- URL
- Nested group
- Conditional field
- Advanced JSON
Credentials are not widget options. Homarr rejects credential names and static credential values in widget options.
Configure authentication in the API source. Homarr then keeps the values encrypted and excludes them from exports and board configuration.
Dynamic selects use `x-homarr.optionsSource` and a named query. Add `itemsPath` when the response contains the result array in an object.
Set `valuePath` and `labelPath` relative to each item. These requests run only during widget option configuration.
The requests use the same protected server executor as runtime named requests.
### Bind named request parameters
Request parameters map each parameter name to `string`, `number`, or `boolean`. Query and body templates use `{ "$param": "name" }` references.
A parameter declaration does not give a value to the parameter. A matching widget option or temporary input does not create a binding.
For a load query, bind each parameter in `optionsBinding`. Use an option reference or a type-compatible primitive literal:
```json
{
@@ -55,9 +133,11 @@ For a load query, bind every parameter explicitly in `optionsBinding`. Each entr
}
```
For manual queries and actions, `SubFetch`, `ActionButton`, or `ToggleSwitch` supplies the complete parameter object through its `params` prop. This is the only route from JSX values such as `inputs.search` or a loop variable into a request.
For a manual query or action, supply all parameters with the component `params` prop. Use `SubFetch`, `ActionButton`, or `ToggleSwitch`.
Dynamic option requests also run without a JSX invoker. If they declare parameters, give them explicit `optionsBinding` entries so the configuration form can resolve those values.
Only this prop can send JSX values to a request. These values can include temporary inputs and loop variables.
Dynamic widget option requests run without a JSX invoker. Add an `optionsBinding` entry for each declared parameter.
<CanonicalCustomWidgetExample
id="named-widget-requests"
@@ -66,9 +146,9 @@ Dynamic option requests also run without a JSX invoker. If they declare paramete
height="420px"
/>
### Options schema and defaults
### Define the widget options schema and defaults
Defaults must validate against the options schema. Keep secrets out of both documents.
Default values must conform to the widget options schema. Do not put secrets in these documents.
<CanonicalCustomWidgetExample
id="widget-options-schema"
@@ -1,8 +1,10 @@
# Custom JSX runtime
# Use the Custom JSX runtime
Templates are declarative JSX interpreted inside a widget-local error boundary. They cannot import modules or run arbitrary JavaScript.
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
## Data roots
Homarr interprets declarative JSX in the error boundary of the widget. A template cannot import a module or run arbitrary JavaScript.
## Use data roots
```jsx
<Stack>
@@ -20,37 +22,187 @@ Templates are declarative JSX interpreted inside a widget-local error boundary.
```
- `data.<requestId>` contains sanitized JSON or text results.
- `status.<requestId>` contains loading, success, HTTP, and error state.
- `options` contains the validated board-item configuration.
- `inputs` contains temporary values from controls using `bind`.
- `status.<requestId>` contains the request status.
- `options` contains the validated board item configuration.
- `inputs` contains temporary values from controls that use `bind`.
Load-triggered queries populate data automatically and follow the widget refresh interval. `SubFetch` runs a manual query. `ActionButton` and `ToggleSwitch` invoke actions.
Load queries get data automatically. They run at the widget refresh interval.
## Mantine capabilities
`SubFetch` runs a manual query. `ActionButton` and `ToggleSwitch` run actions.
Homarr discovers exports from the installed Mantine Core, Dates, and Charts packages and generates prop metadata from their installed TypeScript definitions. Ordinary serializable props pass through. Style props, `style`, and `styles` are sanitized and scoped to the tile. Safe new Mantine exports become available without a hand-maintained allowlist; provider, portal, app-shell, modal, drawer, file, and low-level callback families are classified by the capability denylist.
## Use Mantine components
Use safe Tabler icons through the resolver, for example `<TablerIcon name="server" size={18} />`. The editor completes installed icon names, and `homarr://custom-widgets/components/TablerIcon` exposes the same names to MCP agents.
Homarr discovers exports from the installed Mantine Core, Dates, and Charts packages. It generates prop metadata from the installed TypeScript definitions.
The runtime blocks imports, hooks, refs, callbacks, polymorphic roots, provider replacement, arbitrary portal targets, browser networking, npm packages, evaluation, prototype-pollution keys, unsafe URLs, global selectors, and escaping CSS.
Homarr passes ordinary serializable props to Mantine. It sanitizes style props, `style`, and `styles`.
## Temporary inputs
Homarr scopes these styles to the widget tile. Safe new Mantine exports become available without a hand-maintained allowlist.
Bind a control to a literal name and read the value from `inputs`:
The capability denylist blocks providers, portals, app shells, modals, drawers, file controls, and low-level callback props.
Use the safe Tabler icon resolver. For example, use `<TablerIcon name="server" size={18} />`.
The editor completes installed icon names. `homarr://custom-widgets/components/TablerIcon` gives the same names to MCP agents.
The runtime blocks these capabilities:
- Imports
- Hooks
- Refs
- Raw callback props
- Polymorphic roots
- Provider replacement
- Arbitrary portal targets
- Browser network requests
- npm packages
- Code evaluation
- Prototype-pollution keys
- Unsafe URLs
- Global selectors
- CSS that escapes the widget tile
### Browse the release-matched component API
Search the generated catalog before guessing a component or prop name. Expand a component to see its actual prop types, required props, literal values, temporary-input adapter, subcomponents, accessibility notes, and a JSX usage sketch.
The explorer fetches only the plain, versioned static catalog. It does not import or execute Mantine or Homarr component objects in this documentation page. Select **Open raw JSON** to give the exact same artifact to an offline tool.
<CustomJsxComponentReference />
## Derive values with immutable local bindings
A collection callback can use a restricted block when the returned JSX needs the same derived value more than once:
```jsx
{
data.pokemon.map((pokemon) => {
const segments = pokemon.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
const isSelected = inputs.selectedPokemon === pokemon.name;
return (
<Card key={dexId} withBorder={isSelected}>
<Text>#{dexId}</Text>
<Text tt="capitalize">{pokemon.name}</Text>
</Card>
);
});
}
```
The block must contain one or more `const` declarations followed by exactly one final `return`. A declaration uses one simple identifier and a required initializer. An earlier local can be used by later declarations and the final JSX.
For one page-level derived value, directly invoke a zero-argument inline arrow:
```jsx
{
(() => {
const visiblePokemon = data.pokemon.filter(
(pokemon) => options.types.length === 0 || options.types.includes(pokemon.type),
);
return (
<Stack>
{visiblePokemon.map((pokemon) => (
<Text>{pokemon.name}</Text>
))}
</Stack>
);
})();
}
```
This is not general JavaScript execution. The runtime rejects:
- `let`, `var`, assignment, update expressions, and destructuring
- Functions or classes stored in a local
- `if`, loops, `switch`, `try`, `throw`, labels, `new`, async code, and generators
- Early or multiple returns
- Duplicate locals or names that shadow `data`, `status`, `options`, or `inputs`
- IIFEs with parameters or arguments
- Calling functions obtained from API data
Local evaluation shares the widget's operation, depth, collection, string, and rendered-node limits. A block cannot create a new budget.
Relevant diagnostics include `INVALID_LOCAL_DECLARATION`, `LOCAL_BINDING_REQUIRES_INITIALIZER`, `DUPLICATE_LOCAL_BINDING`, `RESERVED_LOCAL_BINDING`, `UNSUPPORTED_BLOCK_STATEMENT`, `BLOCK_REQUIRES_FINAL_RETURN`, and `CALLBACK_VALUE_NOT_ALLOWED`.
## Render arbitrary-depth data with RecursiveList
Use the trusted `RecursiveList` special form for evolution chains, directory trees, nested comments, and other unknown-depth data. Do not write an authored recursive function or pass Mantine a render callback.
```jsx
<RecursiveList
data={data.evolution.chain}
childrenPath="evolves_to"
keyPath="species.name"
defaultExpandedDepth={4}
maxDepth={16}
showLines
>
{(node, meta) => {
const segments = node.species.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
return (
<Group gap="xs">
<Badge variant="light">#{dexId}</Badge>
<Text tt="capitalize" fw={600}>
{node.species.name}
</Text>
{meta.hasChildren && <Text c="dimmed">{meta.childCount} evolutions</Text>}
</Group>
);
}}
</RecursiveList>
```
The child template receives the current `node` and this metadata:
```ts
interface RecursiveListMetadata {
depth: number;
index: number;
path: number[];
key: string;
hasChildren: boolean;
childCount: number;
isLast: boolean;
}
```
`data` accepts one root object or an array of roots. `childrenPath` and `keyPath` are restricted dotted paths; prototype-related segments are blocked. Keys resolve to primitives. Missing or duplicate keys receive a stable path fallback.
The authorable presentation props are `data`, `childrenPath`, `keyPath`, `maxDepth`, `maxNodes`, `defaultExpandedDepth`, `indent`, `gap`, and `showLines`. Defaults are 16 levels and 500 nodes; hard limits are 32 levels and 2,000 nodes.
Homarr detects cycles per ancestor chain. A malformed branch, render failure, or reached limit stays contained in that branch and does not crash the widget tile. Traversal and child templates use the same parent interpreter budget.
## Use temporary inputs
Bind a control to a literal name. Read its value from `inputs`:
```jsx
<TextInput bind="search" defaultValue="" />
<Calendar bind="selectedDate" />
<DatePicker bind="selectedDate" />
<Switch bind="enabled" defaultChecked={false} />
<MultiSelect bind="selectedEntities" data={options.entities} />
<SubFetch requestId="search" params={{ query: inputs.search }} />
```
These values exist only in React memory while the widget is mounted. They are never written to the definition, board options, database, browser storage, or `localStorage`; reloading or remounting resets them. Use widget options for persisted user configuration.
These values exist in React memory while the widget is mounted. Homarr does not save them in any storage location.
Homarr infers the adapter type and handles ordinary `value/onChange`, `checked/onChange`, `active/onChange`, and `opened/onChange` patterns plus unusual Mantine signatures. `defaultValue` and `defaultChecked` initialize the temporary value and are not forwarded beside the controlled prop. Multiple components may share a name only when their adapter types match. Authored callbacks are not allowed.
A reload or remount resets the values. Use widget options for saved user configuration.
## Diagnostics
Homarr infers the adapter type. It supports `value/onChange`, `checked/onChange`, `active/onChange`, and `opened/onChange` patterns.
The type follows the component mode. Multiple accordions and chip groups, multi/checkbox tree selects, and multiple/range date pickers expose `string[]`. `DateTimePicker` and `InlineDateTimePicker` support ranges but not multiple selection. A component that does not advertise a temporary input adapter reports `BINDING_UNAVAILABLE`.
Explicit adapters support Mantine components that use different event signatures.
`defaultValue` and `defaultChecked` set the initial temporary value. Homarr does not send them with the controlled prop.
Multiple components can share a name when their adapter types agree. A template cannot pass an authored callback as a component prop. Only the runtime-owned collection callbacks, zero-argument IIFE, and `RecursiveList` child template use the restricted forms described above.
## Read diagnostics
- `BLOCKED_CAPABILITY`: unsafe behavior; saving fails.
- `UNKNOWN_COMPONENT`: the component does not exist; saving fails.
@@ -60,4 +212,4 @@ Homarr infers the adapter type and handles ordinary `value/onChange`, `checked/o
- `BINDING_TYPE_CONFLICT`: components sharing one input name require incompatible value types.
- `RUNTIME_RENDER_ERROR`: rendering failed inside this tile only.
Homarr never silently drops an ordinary prop.
Homarr does not silently remove an ordinary prop.
@@ -35,19 +35,29 @@ export const completeManifest = `{
# Custom Widgets
Custom JSX v2 is Homarr's only custom-widget format. A definition owns its metadata, inline API sources, named requests, options, and safe JSX template. Credentials belong to a source but are encrypted separately and never exported.
Custom JSX v2 is the only Custom Widget format in Homarr. A definition contains metadata, API sources, named requests, widget options, and JSX.
There are no legacy display types, reusable connections, packs, or compatibility modes.
Homarr encrypts source credentials separately. Homarr does not include credentials in an export.
## Start here
Homarr does not support old display types, reusable connections, widget packs, or compatibility modes.
- [Workbench and ChatGPT](./authoring): create widgets manually or use the copy/paste AI loop.
- [JSX runtime](./custom-jsx): data roots, Mantine components, bindings, and diagnostics.
- [Requests and security](./requests-and-security): sources, actions, permissions, and network protections.
- [Agents and skills.sh](./agent-authoring): MCP prompt/resources/tools and the portable Agent Skill.
- [Troubleshooting](./troubleshooting): fix validation, request, configuration, and runtime failures.
## Audience and prerequisites
## One definition
This documentation is for Homarr administrators and Custom Widget authors. You must understand HTTP, JSON, API authentication, and self-hosted network routing.
For a private API source, you must understand DNS, TLS, reverse proxies, and container networking.
To write a template manually, you must understand JSX and Mantine. An AI-generated definition requires the same security and capability review.
## Select a task
- [Create a Custom Widget](./authoring): Use the workbench or an AI copy procedure.
- [Use the JSX runtime](./custom-jsx): Use data, Mantine components, temporary inputs, and diagnostics.
- [Configure requests and security](./requests-and-security): Configure API sources, actions, permissions, and network protection.
- [Use agents and skills.sh](./agent-authoring): Use MCP resources, MCP tools, and the portable Agent Skill.
- [Correct Custom Widget problems](./troubleshooting): Correct validation, request, configuration, and runtime problems.
## Understand the definition
```ts
interface HomarrCustomWidgetV2 {
@@ -70,24 +80,44 @@ interface HomarrCustomWidgetV2 {
height="420px"
/>
Exports, imports, the UI, MCP, and Workshop all validate this schema. Old files are rejected. If a board references a missing definition, only that tile shows **Custom widget unavailable**; the board and sibling widgets continue rendering.
Exports, imports, the UI, MCP, and Workshop validate this schema. Homarr rejects files that use an old format.
## Create and configure
If a definition is missing, its tile shows **Custom widget unavailable**. The other board items continue to operate.
Open **Management → Custom Widgets → Add custom widget**. The continuous workbench has sections for general information, API sources and credentials, requests, options, JSX, and preview. Use the sticky navigation to jump between sections; there are no artificial “next” steps.
The browser renders the safe JSX template. The Homarr server executes named requests and injects encrypted credentials.
A board item selects a definition from a searchable picker and immediately renders the definition's options. Invalid options block insertion. If an updated definition makes existing configuration invalid, opening the item shows the normal repair form.
The browser does not receive API source credentials.
## Import, export, and Workshop
## Create and configure a Custom Widget
Export downloads one canonical JSON document without credentials. Import validates and shows the origins, auth modes, network scopes, methods, actions, and required board permissions before creating anything. The same review screen is used for file, clipboard, and Workshop installs. It provides optional credential fields for every authenticated source; entered values are encrypted locally and never added to the imported document.
Open **Management → Custom Widgets → Add custom widget**. The workbench contains sections for information, sources, requests, widget options, JSX, and preview.
Widgets that still need a bearer token, API key, username, or password show a **Credentials missing** indicator on the Custom Widgets management page. Importing without credentials remains supported, and the indicator disappears once every required source credential is configured.
Use the fixed navigation to go to a section.
The [Workshop](/docs/management/workshop) publishes valid widgets immediately. Users can vote or report a submission. Workshop administrators may dismiss reports or delete any post; deletion does not remove installed local copies.
Select a definition from the searchable board widget picker. The picker immediately shows the widget options.
## Bundled examples
Invalid widget options prevent board insertion. If an update invalidates existing options, the item opens the configuration repair form.
Homarr seeds four disabled examples. They use the same canonical schema as user widgets and are never overwritten after a user edits them. Select one to explore its manifest and JSX.
## Import, export, and publish
An export contains one canonical JSON document without credentials. An import validates the document before Homarr creates the definition.
The import review shows origins, authentication modes, network scopes, methods, actions, and required board permissions. File, clipboard, and Workshop imports use this review.
The review gives credential fields for each authenticated source. Homarr encrypts entered values and excludes them from the imported document.
A definition shows **Credentials missing** when a source needs credentials. You can import a definition without credentials.
The indicator disappears after you configure all required source credentials.
The [Workshop](/docs/management/workshop) publishes valid widgets immediately. Users can vote on a submission or report it.
Workshop administrators can inspect reported posts and delete any submission. This deletion does not remove installed copies.
## Examine bundled examples
Homarr adds four disabled examples during database setup. These examples use the same schema as user definitions.
Homarr does not overwrite an example after a user changes it. Select an example to examine its manifest and JSX.
<BundledCustomWidgetGallery />
@@ -1,24 +1,64 @@
# Requests and security
# Configure requests and security
Each widget owns one or more API sources. Most use a single `default` source. Add another only when one widget genuinely combines APIs, such as a Sonarr/Radarr calendar.
Each Custom Widget has one or more API sources. Most definitions use one `default` source.
Sources support no authentication, bearer tokens, basic authentication, API-key headers, and API-key query parameters. Credentials are encrypted per definition, source, and kind. Duplicating or exporting a widget does not copy credentials.
Add another API source when the widget must combine APIs. For example, a calendar can combine Sonarr and Radarr.
## Named requests
An API source supports these authentication modes:
Queries and actions support GET, POST, PUT, PATCH, and DELETE. `kind`, not method, controls behavior:
- No authentication
- Bearer token
- Basic authentication
- API-key header
- API-key query parameter
- Queries may load automatically or run through `SubFetch`; they may include a body.
- Actions only run after a user gesture; they may require confirmation and invalidate named queries.
Homarr encrypts credentials for each definition, API source, and credential type. A duplicate or export does not contain credentials.
Successful actions evict the declared queries from both the browser and server response cache before refetching them.
## Configure named requests
Path parameters use `{name}`. Structured query/body templates use `{ "$param": "name" }`. Every declared parameter has an explicit source: load queries use `optionsBinding` entries containing `{ "$option": "optionName" }` or a primitive literal, while manual queries and actions receive the full parameter object from the invoking component's `params` prop. Homarr never matches parameter names to option or input names implicitly.
Named requests support GET, POST, PUT, PATCH, and DELETE. The `kind` value controls runtime behavior.
## Protections
The HTTP method does not determine whether a named request is a query or action.
All requests use Homarr's server executor. It enforces source origin, network scope, DNS and redirect validation, SSRF blocking, permissions, rate limits, request/parameter/body limits, timeouts, response-size limits, and secret injection. Static headers cannot override auth, routing, proxy, cookie, or hop-by-hop headers. Credential-like static values are rejected.
- A load query runs automatically.
- A manual query runs with `SubFetch`.
- A query can include a body.
- An action runs after a user action.
- An action can require confirmation.
- An action can invalidate named queries.
`public` allows public destinations, `private` additionally allows private LAN destinations, and `loopback` additionally allows loopback. Sensitive infrastructure ranges remain blocked according to the executor policy.
A successful action removes each declared query from the browser cache and server cache. Homarr then runs the query again.
Every request declares view, modify, or full board access and the server enforces it before contacting the source. DELETE always needs full access and confirmation. Actions are never cached, retried, or triggered on load.
Path parameters use `{name}`. Structured query and body templates use `{ "$param": "name" }`.
Give each declared parameter an explicit value source. For a load query, use `optionsBinding` with an option reference or a primitive value.
For a manual query or action, send all parameters with the component `params` prop. Homarr does not bind matching names automatically.
## Understand request protection
All named requests use the Homarr server executor. The executor applies these security controls:
- Source origin
- Network scope
- DNS validation
- Redirect validation
- SSRF protection
- Board permissions
- Rate limits
- Request limits
- Parameter limits
- Body limits
- Timeouts
- Response size limits
- Encrypted credential injection
Static headers cannot replace authentication, routing, proxy, cookie, or hop-by-hop headers. Homarr rejects static values that look like credentials.
The `public` network scope permits public destinations. The `private` network scope also permits private LAN destinations.
The `loopback` network scope also permits loopback destinations. The SSRF policy blocks sensitive infrastructure ranges for all network scopes.
Each named request requires view, modify, or full board access. The server checks this permission before it contacts the API source.
A DELETE request always requires full access and confirmation. Homarr does not cache actions, retry actions, or run actions on load.
@@ -1,29 +1,61 @@
# Troubleshooting
# Correct Custom Widget problems
## Widget unavailable
## Correct an unavailable widget
The definition was deleted or reset. Remove the broken tile from the board and install or create a current Custom JSX v2 definition. There is no legacy recovery or migration wizard.
The definition does not exist. Remove the unavailable tile from the board.
## Invalid stored definition
Then, install or create a Custom JSX v2 definition. Homarr does not provide recovery or migration for old formats.
The Custom Widgets management page isolates definitions that no longer match the current schema instead of failing the entire page. Invalid definitions show their validation issues and can be deleted, but cannot be edited, enabled, exported, duplicated, published, or selected for a board. Recreate or import a valid definition; Homarr does not silently migrate unreleased Custom JSX formats.
## Correct an invalid saved definition
## Validation fails
The Custom Widgets page isolates a definition that does not conform to the current schema. The other definitions remain available.
Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are unique, every request references a source, parameters are declared, defaults match the options schema, and network components reference the correct request kind.
An invalid definition shows its validation errors. You can delete it.
Incomplete API source and icon URLs are normal form validation errors. The workbench keeps the current draft mounted and highlights the invalid field while you type; malformed URLs must never replace the form with the error boundary.
You cannot edit, enable, export, duplicate, publish, or select an invalid definition. Recreate the definition or import a valid definition.
An invalid definition disables only the rendered Widget canvas. The preview shell and its Data, Options, Actions, and Diagnostics tabs remain available, and Diagnostics lists the current schema issues.
Homarr does not migrate old Custom JSX formats.
## Request fails
## Correct validation errors
Check the source network scope, base URL, credentials, request method/path/body, runtime parameters, and board permission. For “invalid placeholder” or an unresolved parameter on a load query, first verify that every declared parameter has an explicit `optionsBinding` option reference or literal. Matching parameter and option names are not a binding. The preview journal shows redacted method, request ID, path template, status, and duration without credentials.
Read the first schema or JSX diagnostic. Use its path to find the invalid value.
## Mantine prop warning
Make sure that each API source ID is unique. Make sure that each request ID is unique.
`UNKNOWN_MANTINE_PROP` is advisory and the prop is passed through. Confirm the prop against the installed Mantine version. `BLOCKED_CAPABILITY` is deliberate and must be expressed through safe options, temporary bindings, or named requests instead.
Make sure that each named request references an API source. Declare each request parameter.
## Existing item needs repair
Make sure that default values conform to the widget options schema. Use the correct request kind for each runtime component.
The definition's options schema changed incompatibly. Open the item settings and repair the highlighted configuration. Homarr does not guess option migrations.
An incomplete API source URL or icon URL causes a form validation error. The workbench keeps the draft open.
The workbench marks the invalid field while you type. An invalid URL does not replace the form with the error boundary.
An invalid definition disables only the widget preview. The Data, Options, Actions, and Diagnostics tabs remain available.
The Diagnostics tab shows the current schema errors.
## Correct request errors
Check the API source network scope. Check the base URL and credentials.
Check the request method, path, body, parameters, and board permission.
For “invalid placeholder,” examine each declared load query parameter. Add an `optionsBinding` option reference or primitive value for each parameter.
A matching parameter name and widget option name do not create a binding. The preview journal does not show credentials.
The journal shows the method, request ID, path template, status, and duration.
## Correct a Mantine prop warning
`UNKNOWN_MANTINE_PROP` is a warning. Homarr passes the prop to Mantine.
Check the prop in the documentation for the installed Mantine version. `BLOCKED_CAPABILITY` identifies an unsafe capability.
Use safe widget options, temporary inputs, or named requests instead.
## Repair an existing item
The widget options schema changed and the saved values are invalid. Open the item settings.
Correct the marked configuration values. Homarr does not infer widget option migrations.
+40 -11
View File
@@ -162,18 +162,28 @@ 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
- **"Build a Custom Widget for this API"** - AI validates and previews one definition before it saves the definition
## Custom Widget Workflow for AI Agents
## Create a Custom Widget with an AI agent
Agents create one `homarr-custom-widget-v2` definition at a time. Start with the native prompt `homarr-custom-widget-author`, then read the live schema and component resources rather than guessing the installed Mantine surface.
An agent creates one `homarr-custom-widget-v2` definition at a time. Start with the native prompt `homarr-custom-widget-author`.
Then, read the live schema and component resources. These resources describe the installed Mantine components.
1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`.
2. Build one widget and call `customWidget_validate` without saving.
3. Call `customWidget_previewCreate`, then test named requests with `customWidget_previewQuery` and `customWidget_previewAction`.
4. Open the returned preview URL, inspect `customWidget_previewJournal`, and iterate with `customWidget_templatePatch` or `customWidget_update`.
5. Set a supplied credential with `customWidget_secretSet`, or use `customWidget_secretRequestUser` to issue a ten-minute Homarr entry URL. The agent receives completion status, never the plaintext.
6. Call `customWidget_create`, then repeat for the next widget.
2. Build one Custom Widget definition.
3. Call `customWidget_validate` without saving the definition.
4. Call `customWidget_previewCreate`.
5. Test named requests with `customWidget_previewQuery` and `customWidget_previewAction`.
6. Open the returned preview URL.
7. Examine `customWidget_previewJournal`.
8. Change the definition with `customWidget_templatePatch` or `customWidget_update`.
9. Set a supplied credential with `customWidget_secretSet`.
10. Otherwise, call `customWidget_secretRequestUser` to create a ten-minute Homarr entry URL.
11. Call `customWidget_create`.
12. Repeat this procedure for the next Custom Widget.
The agent receives the credential request status. The agent does not receive the plaintext credential.
Discovery resources:
@@ -183,8 +193,27 @@ Discovery resources:
- `homarr://custom-widgets/examples/{name}`
- `homarr://custom-widgets/skill`
The complete tool surface is `customWidget_schema`, `customWidget_validate`, `customWidget_list`, `customWidget_get`, `customWidget_create`, `customWidget_update`, `customWidget_delete`, `customWidget_templatePatch`, `customWidget_previewCreate`, `customWidget_previewQuery`, `customWidget_previewAction`, `customWidget_previewJournal`, `customWidget_secretSet`, `customWidget_secretRequestUser`, `customWidget_getAuthoringPrompt`, and `customWidget_getSkill`.
The Custom Widget tools are:
`custom-widget-manage` permits authoring. Supplying a secret directly requires the separate `custom-widget-secret-write` permission. Request execution retains Homarr's SSRF, DNS, redirect, response-size, timeout, rate, network-scope, and board-permission protections.
- `customWidget_schema`
- `customWidget_validate`
- `customWidget_list`
- `customWidget_get`
- `customWidget_create`
- `customWidget_update`
- `customWidget_delete`
- `customWidget_templatePatch`
- `customWidget_previewCreate`
- `customWidget_previewQuery`
- `customWidget_previewAction`
- `customWidget_previewJournal`
- `customWidget_secretSet`
- `customWidget_secretRequestUser`
- `customWidget_getAuthoringPrompt`
- `customWidget_getSkill`
See [Agents and skills.sh](./custom-widgets/agent-authoring) for the full loop and portable Agent Skill.
The `custom-widget-manage` permission permits authoring. A direct credential write also requires the `custom-widget-secret-write` permission.
Homarr applies its request security controls during request execution. These controls include SSRF, DNS, redirect, size, timeout, rate, and permission controls.
Read [Use agents and skills.sh](./custom-widgets/agent-authoring) for the complete procedure and the portable Agent Skill.
+35 -11
View File
@@ -1,25 +1,49 @@
# Workshop
# Use Workshop
[Open Workshop](/workshop) to discover, inspect, vote on, report, download, and publish Custom JSX v2 widgets.
[Open Workshop](/workshop) to browse, inspect, vote on, report, download, and publish Custom JSX v2 widgets.
Valid submissions publish immediately. Authors can edit or delete their posts, and content changes increment the revision. Widget credentials are rejected and never belong in a submission.
Submissions publish immediately. Authors can edit or delete their own submissions, while Workshop administrators can
review reports and delete any submission. Deleting a Workshop submission never removes widgets already installed in a
self-hosted Homarr instance.
Votes are unique per user and submission. Public listings show upvotes, downvotes, and score without exposing voter identities. Reports are also unique per reporter and submission and contain a category plus explanation.
Workshop validates widget JSON in the browser before publishing. Homarr validates it again before installation. Widget
exports must not contain credentials; users configure required credentials only after importing a widget.
Workshop administrators use a protected administration page to review reports grouped by submission, see reporter identities and explanations, inspect vote totals, dismiss reports, delete any submission, browse unreported posts, and view the audit trail. Workshop has no banning, account-state, moderator, approval, quarantine, badge, or remote-deletion system.
Votes and reports are unique per user and submission. Listings expose vote totals without exposing voter identities.
Reports include a category and explanation. Administrators can see the reporter, dismiss the report, or delete the
reported submission.
Deleting a Workshop post never deletes widgets already installed in a self-hosted Homarr instance.
Workshop deliberately has no comments, email notifications, approval queue, moderator roles, user bans, or audit-history
system.
## GitHub sign-in deployment
## Access control
Workshop authentication is hosted by the central Workshop PocketBase service. Register this single callback in the GitHub OAuth application:
Workshop uses PocketBase collection API rules for access control:
- Anyone can browse submissions and vote totals.
- Signed-in users can publish, vote, and report.
- An author can update or delete only their own submission.
- Workshop administrators can read and dismiss reports and delete any submission.
The frontend uses PocketBase's normal record create, update, and delete APIs. There are no custom deletion endpoints or
deletion hooks. PocketBase superusers bypass collection API rules, as documented in
[API rules and filters](https://pocketbase.io/docs/api-rules-and-filters/).
Set `users.isAdmin` from the PocketBase dashboard to grant Workshop administration access. The users collection update
rule prevents ordinary users from granting this flag to themselves.
## Configure GitHub sign-in
The central Workshop PocketBase service controls authentication. Register this callback in the GitHub OAuth application:
```text
https://<workshop-host>/api/oauth2-redirect
```
Self-hosted Homarr origins such as `http://localhost:3000`, `https://home.example.com`, or another custom domain do not need separate GitHub callback registrations. The popup starts directly from the user's click, authenticates against the Workshop host, and returns the result to the calling Homarr page.
Self-hosted Homarr origins, including localhost and custom domains, do not need separate GitHub callbacks. The Workshop
host completes authentication in a popup and returns the result to the calling Homarr page.
PocketBase permits all origins by default. If the Workshop deployment uses PocketBase's `--origins` option or a restrictive reverse-proxy CORS policy, it must allow arbitrary self-hosted Homarr origins. Restricting it to `homarr.dev` breaks sign-in from private installations. See [PocketBase routing and CORS](https://pocketbase.io/docs/go-routing/).
PocketBase permits all origins by default. If the deployment uses `--origins` or a restrictive reverse-proxy CORS policy,
it must permit self-hosted Homarr origins. See [PocketBase routing and CORS](https://pocketbase.io/docs/go-routing/).
Homarr reports provider misconfiguration, popup blocking, cancellation, and unavailable OAuth as visible notifications. Keep the Workshop host and GitHub provider configuration centralized; do not register every Homarr installation hostname.
Homarr shows a visible error when the provider is misconfigured or the popup is blocked, cancelled, or times out.
+28 -8
View File
@@ -1,6 +1,6 @@
---
title: "Custom API"
description: "Renders an administrator-defined Custom JSX v2 widget with server-side API requests, options, and actions."
description: "The Custom API widget shows a Custom JSX v2 definition with server-side API requests, widget options, and actions."
hide_title: true
---
@@ -10,28 +10,48 @@ import { customApiWidget } from ".";
<WidgetHeader widget={customApiWidget} categories={["Custom", "API"]} />
The Custom API widget displays a Custom JSX v2 definition created in [Management → Custom Widgets](/docs/management/custom-widgets). A definition owns one or more inline API sources, named requests, an options schema, and one safe JSX template. Temporary bound inputs exist only while the tile is mounted. Source credentials are encrypted separately and never reach the browser or an export.
The Custom API widget shows a Custom JSX v2 definition. Create the definition in [Management → Custom Widgets](/docs/management/custom-widgets).
A definition contains API sources, named requests, a widget options schema, and one safe JSX template.
Temporary inputs exist only while the tile is mounted. Homarr encrypts source credentials separately.
Homarr does not send these credentials to the browser. Homarr does not include them in an export.
### Add the widget
<AddingWidget />
Choose a definition from the searchable picker, configure its generated options, and select a refresh interval. The board item stores only the selected definition ID and validated option values; credentials remain server-side.
Select a definition from the searchable picker. Configure its generated widget options.
Select a refresh interval. The board item stores the definition ID and the validated widget option values.
Credentials remain on the server.
:::note
A definition must be enabled and available to the board before it appears in the widget picker.
Enable the definition before you use it. The definition must also be available to the board.
:::
### Permissions and actions
Each request declares its minimum board permission independently of its HTTP method. Queries and actions may use GET, POST, PUT, PATCH, or DELETE; `kind` controls runtime behavior. DELETE always requires full board permission and confirmation. Anonymous viewers cannot execute actions.
Each named request declares its minimum board permission. This permission does not depend on the HTTP method.
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.
Queries and actions can use GET, POST, PUT, PATCH, or DELETE. The `kind` value controls runtime behavior.
A DELETE request requires full board permission and confirmation. An anonymous viewer cannot run an action.
Homarr disables interactive controls in board edit mode. An action runs only after a user action.
Mounting, refresh polling, and automatic retries do not run actions. An authoring preview simulates actions by default.
An administrator can enable live actions for the short preview session.
### Refresh behavior
The refresh interval reruns load-triggered queries. Manual queries run through `SubFetch`, and each query may declare its own cache duration. Successful actions can invalidate only their declared load or manual queries.
The refresh interval runs load queries again. `SubFetch` runs manual queries.
See [Custom Widgets management](/docs/management/custom-widgets) for the workbench, options, named-request manifest, Mantine JSX surface, network scopes, and security limits.
Each query can declare a cache duration. A successful action can invalidate only its declared queries.
Read [Custom Widgets management](/docs/management/custom-widgets) for authoring, widget options, named requests, Mantine, network scopes, and security limits.
+1
View File
@@ -18,6 +18,7 @@
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"api-dump-contributors": "node ./scripts/dump-contributors.mjs",
"check:custom-widgets-ste": "node ./scripts/check-custom-widgets-ste.mjs",
"verify:search": "node ./scripts/verify-algolia-search.mjs"
},
"dependencies": {
@@ -0,0 +1,217 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { britishSpellings, imperativeVerbs, nonSteTerms, technicalTerms } from "./custom-widgets-ste-terms.mjs";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const docsDirectory = resolve(scriptDirectory, "../docs");
const files = [
{ path: "management/custom-widgets/agent-authoring.mdx" },
{ path: "management/custom-widgets/authoring.mdx" },
{ path: "management/custom-widgets/custom-jsx.mdx" },
{ path: "management/custom-widgets/index.mdx" },
{ path: "management/custom-widgets/requests-and-security.mdx" },
{ path: "management/custom-widgets/troubleshooting.mdx" },
{ path: "management/workshop/index.mdx" },
{ path: "widgets/custom-api/index.mdx" },
{ path: "management/api/index.mdx", section: "Get Custom Widget authoring resources" },
{ path: "management/mcp.mdx", section: "Create a Custom Widget with an AI agent", includeMatching: /Custom Widget/ },
{ path: "advanced/environment-variables/index.mdx", includeMatching: /WORKSHOP_API_URL/ },
{ path: "community/faq.mdx", section: "Can I add my own widgets? Do I need programming skills?" },
{ path: "management/backup.mdx", includeMatching: /Custom Widget/ },
];
const imperativeSet = new Set(imperativeVerbs);
const issues = [];
const overview = await readFile(resolve(docsDirectory, "management/custom-widgets/index.mdx"), "utf8");
if (!overview.includes("## Audience and prerequisites")) {
addIssue("management/custom-widgets/index.mdx", 1, "Add the required audience and prerequisites section.");
}
for (const file of files) {
const absolutePath = resolve(docsDirectory, file.path);
const source = await readFile(absolutePath, "utf8");
const selectedLines = selectLines(source, file);
const paragraphs = getProseParagraphs(selectedLines);
for (const paragraph of paragraphs) {
const sentences = splitSentences(paragraph.text);
if (sentences.length > 6) {
addIssue(file.path, paragraph.line, `Paragraph has ${sentences.length} sentences. The limit is 6.`);
}
for (const sentence of sentences) {
const wordCount = countWords(sentence);
const isProcedure = paragraph.isList || startsWithImperative(sentence);
const limit = isProcedure ? 20 : 25;
if (wordCount > limit) {
const type = isProcedure ? "Procedural" : "Descriptive";
addIssue(file.path, paragraph.line, `${type} sentence has ${wordCount} words. The limit is ${limit}.`);
}
}
checkTerms(file.path, paragraph.line, paragraph.text);
}
}
if (issues.length > 0) {
console.error("Custom Widget STE check failed:\n");
for (const issue of issues) console.error(`- ${issue}`);
process.exitCode = 1;
} else {
console.log(`Custom Widget STE check passed. ${technicalTerms.length} technical terms are registered.`);
}
function selectLines(source, file) {
const lines = source.split("\n").map((text, index) => ({ text, line: index + 1 }));
if (!file.section && !file.includeMatching) return lines;
const selected = [];
if (file.section) {
const start = lines.findIndex(({ text }) => text.trim().replace(/^#{2,6}\s+/, "") === file.section);
if (start === -1) throw new Error(`Section '${file.section}' was not found in ${file.path}`);
const headingLevel = lines[start].text.trim().match(/^#+/)?.[0].length ?? 2;
for (let index = start; index < lines.length; index += 1) {
const nextHeadingLevel = lines[index].text.trim().match(/^#+(?=\s)/)?.[0].length;
if (index > start && nextHeadingLevel && nextHeadingLevel <= headingLevel) break;
selected.push(lines[index]);
}
}
if (file.includeMatching) {
for (const line of lines) {
if (file.includeMatching.test(line.text) && !selected.some((item) => item.line === line.line))
selected.push(line);
}
}
return selected.toSorted((left, right) => left.line - right.line);
}
function getProseParagraphs(lines) {
const paragraphs = [];
let paragraph = null;
let inFence = false;
let inFrontmatter = false;
let inTemplateExport = false;
let inJsxTag = false;
const flush = () => {
if (paragraph?.text.trim()) paragraphs.push({ ...paragraph, text: paragraph.text.trim() });
paragraph = null;
};
for (const { text: originalText, line } of lines) {
const trimmed = originalText.trim();
if (line === 1 && trimmed === "---") {
inFrontmatter = true;
continue;
}
if (inFrontmatter && trimmed === "---") {
inFrontmatter = false;
continue;
}
if (trimmed.startsWith("```")) {
flush();
inFence = !inFence;
continue;
}
if (inFence) continue;
if (/^export const .*?=\s*`/.test(trimmed)) inTemplateExport = true;
if (inTemplateExport) {
if (/`;\s*$/.test(trimmed)) inTemplateExport = false;
continue;
}
if (/^(import|export)\s/.test(trimmed)) continue;
if (trimmed.startsWith("<")) inJsxTag = true;
if (inJsxTag) {
if (trimmed.includes(">")) inJsxTag = false;
continue;
}
let text = trimmed;
if (inFrontmatter) {
const match = text.match(/^(title|description|sidebar_label):\s*["']?(.*?)["']?$/);
if (!match) continue;
text = match[2];
}
if (!text || /^\|?\s*:?-+:?/.test(text)) {
flush();
continue;
}
const isList = /^\s*(?:[-*]|\d+\.)\s+/.test(text);
text = cleanMarkdown(text);
if (!text) {
flush();
continue;
}
if (isList || text.startsWith("#") || text.startsWith("|")) flush();
text = text
.replace(/^#{1,6}\s+/, "")
.replace(/^\s*(?:[-*]|\d+\.)\s+/, "")
.replace(/^\|\s*/, "")
.replace(/\s*\|\s*$/, "");
if (!paragraph) paragraph = { text, line, isList };
else paragraph.text += ` ${text}`;
if (isList || trimmed.startsWith("#") || trimmed.startsWith("|")) flush();
}
flush();
return paragraphs;
}
function cleanMarkdown(text) {
return text
.replace(/\[([^\]]+)]\([^\s)]+(?:\s+"[^"]*")?\)/g, "$1")
.replace(/`[^`]*`/g, " ")
.replace(/<[^>]+>/g, " ")
.replace(/[*_~]/g, "")
.replace(/https?:\/\/\S+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function splitSentences(text) {
const normalized = text.replace(/\b(?:for example|such as)\b/gi, "example");
const matches = normalized.match(/[^.!?]+[.!?]+|[^.!?]+$/g) ?? [];
return matches.map((sentence) => sentence.trim()).filter(Boolean);
}
function countWords(sentence) {
return sentence.match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g)?.length ?? 0;
}
function startsWithImperative(sentence) {
const firstWord = sentence.toLowerCase().match(/[a-z]+/)?.[0];
return firstWord ? imperativeSet.has(firstWord) : false;
}
function checkTerms(path, line, text) {
const lowerText = text.toLowerCase();
for (const spelling of britishSpellings) {
if (new RegExp(`\\b${spelling}\\b`, "i").test(text)) {
addIssue(path, line, `Use American English instead of '${spelling}'.`);
}
}
for (const term of nonSteTerms) {
if (new RegExp(`\\b${term}\\b`, "i").test(lowerText)) {
addIssue(path, line, `Replace the non-STE term '${term}'.`);
}
}
}
function addIssue(path, line, message) {
issues.push(`${path}:${line} ${message}`);
}
@@ -0,0 +1,125 @@
export const technicalTerms = [
"Homarr",
"Custom Widget",
"Custom JSX v2",
"Workshop",
"API source",
"named request",
"load query",
"manual query",
"action",
"widget option",
"temporary input",
"CodeMirror",
"Mantine",
"PocketBase",
"MCP",
"JSON Schema",
"API key",
"network scope",
"preview session",
"error boundary",
"HTTP",
"JSON",
"JSX",
"React",
"TypeScript",
"Zod",
"TLS",
"DNS",
"CORS",
"OAuth",
"SSRF",
"tRPC",
"GitHub",
"Docker",
"reverse proxy",
"container networking",
"API authentication",
"bearer token",
"basic authentication",
"primitive literal",
"prop metadata",
"allowlist",
"denylist",
];
export const imperativeVerbs = [
"add",
"bind",
"build",
"call",
"check",
"configure",
"correct",
"create",
"declare",
"define",
"delete",
"disable",
"download",
"enable",
"enter",
"examine",
"export",
"get",
"give",
"import",
"inspect",
"install",
"keep",
"open",
"paste",
"read",
"register",
"remove",
"repeat",
"request",
"run",
"save",
"select",
"set",
"start",
"supply",
"test",
"use",
"verify",
"wait",
"write",
];
export const britishSpellings = [
"authorise",
"authorised",
"behaviour",
"cancelled",
"centre",
"colour",
"customise",
"customised",
"initialise",
"labelling",
"licence",
"organisation",
"recognise",
"serialise",
"synchronise",
"utilise",
"visualise",
];
export const nonSteTerms = [
"easy",
"easily",
"genuinely",
"leverage",
"magic",
"obviously",
"seamless",
"seamlessly",
"simply",
"straightforward",
"utilize",
"utilized",
"wizard",
];
@@ -0,0 +1,459 @@
.explorer {
--catalog-border: color-mix(in srgb, var(--ifm-color-emphasis-500) 42%, transparent);
--catalog-surface: color-mix(in srgb, var(--ifm-background-surface-color) 90%, var(--ifm-color-primary) 10%);
border: 1px solid var(--catalog-border);
border-radius: 18px;
margin: 1.5rem 0 2rem;
overflow: hidden;
}
.header {
align-items: flex-start;
background: linear-gradient(135deg, color-mix(in srgb, var(--ifm-color-primary) 13%, transparent), transparent 65%);
display: flex;
gap: 1.5rem;
justify-content: space-between;
padding: 1.4rem;
}
.eyebrow {
color: var(--ifm-color-primary);
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.09em;
margin: 0 0 0.35rem;
text-transform: uppercase;
}
.title {
font-size: clamp(1.25rem, 3vw, 1.65rem);
margin: 0;
}
.summary {
color: var(--ifm-color-emphasis-700);
margin: 0.45rem 0 0;
max-width: 68ch;
}
.rawLink,
.expandButton,
.showMoreButton,
.resetButton {
align-items: center;
border-radius: 9px;
cursor: pointer;
display: inline-flex;
font: inherit;
font-weight: 650;
justify-content: center;
}
.rawLink,
.expandButton,
.showMoreButton {
background: var(--ifm-color-primary);
border: 1px solid var(--ifm-color-primary);
color: var(--ifm-color-white);
padding: 0.58rem 0.85rem;
text-decoration: none;
}
.rawLink {
flex: 0 0 auto;
white-space: nowrap;
}
.rawLink:hover,
.expandButton:hover,
.showMoreButton:hover {
background: var(--ifm-color-primary-dark);
color: var(--ifm-color-white);
text-decoration: none;
}
.versions {
border-bottom: 1px solid var(--catalog-border);
border-top: 1px solid var(--catalog-border);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
}
.globalProps {
align-items: center;
border-bottom: 1px solid var(--catalog-border);
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr) auto;
padding: 1rem 1.4rem;
scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem);
}
.globalProps h4,
.globalProps p {
margin: 0;
}
.globalProps p {
color: var(--ifm-color-emphasis-700);
font-size: 0.86rem;
margin-top: 0.2rem;
}
.globalPropsTable {
grid-column: 1 / -1;
}
.versions div {
padding: 0.75rem 1.4rem;
}
.versions div + div {
border-left: 1px solid var(--catalog-border);
}
.versions dt {
color: var(--ifm-color-emphasis-700);
font-size: 0.72rem;
font-weight: 650;
text-transform: uppercase;
}
.versions dd {
font-family: var(--ifm-font-family-monospace);
font-size: 0.86rem;
margin: 0.12rem 0 0;
}
.filters {
align-items: end;
background: var(--catalog-surface);
display: grid;
gap: 0.8rem;
grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(130px, 1fr));
padding: 1.15rem 1.4rem;
}
.filters label:not(.checkboxField) {
display: grid;
gap: 0.3rem;
}
.filters label > span {
color: var(--ifm-color-emphasis-700);
font-size: 0.76rem;
font-weight: 700;
}
.filters input[type="search"],
.filters select {
background: var(--ifm-background-color);
border: 1px solid var(--catalog-border);
border-radius: 9px;
color: var(--ifm-font-color-base);
font: inherit;
min-height: 42px;
padding: 0.55rem 0.68rem;
width: 100%;
}
.filters input:focus-visible,
.filters select:focus-visible,
.expandButton:focus-visible,
.showMoreButton:focus-visible,
.resetButton:focus-visible,
.anchorLink:focus-visible {
outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
outline-offset: 2px;
}
.checkboxField {
align-items: center;
display: flex;
gap: 0.5rem;
grid-column: 1 / -1;
}
.checkboxField input {
height: 1rem;
margin: 0;
width: 1rem;
}
.resultSummary {
align-items: center;
border-bottom: 1px solid var(--catalog-border);
display: flex;
gap: 0.6rem;
min-height: 48px;
padding: 0.7rem 1.4rem;
}
.resetButton {
background: transparent;
border: 0;
color: var(--ifm-color-primary);
margin-left: auto;
padding: 0.35rem;
}
.componentList {
display: grid;
}
.componentCard {
scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem);
}
.componentCard + .componentCard {
border-top: 1px solid var(--catalog-border);
}
.componentHeading {
align-items: center;
display: flex;
gap: 1rem;
justify-content: space-between;
padding: 1rem 1.4rem;
}
.componentNameRow {
align-items: baseline;
display: flex;
gap: 0.45rem;
}
.componentNameRow h4 {
font-family: var(--ifm-font-family-monospace);
font-size: 1.05rem;
margin: 0;
}
.anchorLink {
font-weight: 700;
opacity: 0;
text-decoration: none;
}
.componentCard:hover .anchorLink,
.anchorLink:focus-visible {
opacity: 1;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
margin-top: 0.45rem;
}
.badges span {
background: var(--ifm-color-emphasis-200);
border-radius: 999px;
color: var(--ifm-color-emphasis-800);
font-size: 0.7rem;
padding: 0.2rem 0.48rem;
}
.badges span[data-safety="allowed"] {
background: color-mix(in srgb, #2f9e44 18%, transparent);
color: color-mix(in srgb, #2f9e44 78%, var(--ifm-font-color-base));
}
.badges span[data-safety="wrapped"] {
background: color-mix(in srgb, var(--ifm-color-primary) 18%, transparent);
color: var(--ifm-color-primary-darkest);
}
.badges span[data-safety="denied"] {
background: color-mix(in srgb, #e03131 18%, transparent);
color: color-mix(in srgb, #e03131 78%, var(--ifm-font-color-base));
}
.expandButton {
background: transparent;
color: var(--ifm-color-primary);
flex: 0 0 auto;
padding: 0.42rem 0.7rem;
}
.expandButton:hover {
background: color-mix(in srgb, var(--ifm-color-primary) 12%, transparent);
color: var(--ifm-color-primary-dark);
}
.description,
.deniedReason {
color: var(--ifm-color-emphasis-700);
margin: -0.45rem 1.4rem 1rem;
}
.deniedReason {
color: #c92a2a;
}
.details {
background: var(--catalog-surface);
border-top: 1px solid var(--catalog-border);
padding: 1.25rem 1.4rem;
}
.details h5 {
font-size: 0.9rem;
margin: 0 0 0.6rem;
}
.detailsGrid {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
margin-bottom: 1.2rem;
}
.detailsGrid > section {
background: var(--ifm-background-color);
border: 1px solid var(--catalog-border);
border-radius: 12px;
min-width: 0;
padding: 0.9rem;
}
.detailsGrid p:last-of-type {
margin-bottom: 0.6rem;
}
.codeBlock {
font-size: 0.75rem;
margin: 0;
max-height: 320px;
overflow: auto;
white-space: pre;
}
.a11yList {
font-size: 0.86rem;
margin: 0 0 0.7rem;
padding-left: 1.1rem;
}
.tableScroll {
border: 1px solid var(--catalog-border);
border-radius: 10px;
max-height: 480px;
overflow: auto;
}
.propsTable {
display: table;
font-size: 0.78rem;
margin: 0;
width: 100%;
}
.propsTable th {
background: var(--ifm-background-color);
position: sticky;
top: 0;
z-index: 1;
}
.propsTable th,
.propsTable td {
min-width: 96px;
padding: 0.58rem;
vertical-align: top;
}
.propsTable td:nth-child(2) {
max-width: 340px;
min-width: 190px;
}
.type {
overflow-wrap: anywhere;
white-space: normal;
}
.muted {
color: var(--ifm-color-emphasis-600);
}
.rawComponent {
margin-top: 1rem;
}
.rawComponent summary {
color: var(--ifm-color-primary);
cursor: pointer;
font-weight: 650;
}
.rawComponent .codeBlock {
margin-top: 0.6rem;
}
.showMoreButton {
display: flex;
margin: 1.1rem auto;
}
.loading,
.error,
.empty {
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
margin: 1rem 0;
padding: 1rem;
}
.empty {
border: 0;
margin: 0;
text-align: center;
}
.error {
border-color: color-mix(in srgb, #e03131 58%, transparent);
}
@media (max-width: 996px) {
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.searchField {
grid-column: 1 / -1;
}
}
@media (max-width: 650px) {
.header,
.componentHeading {
align-items: stretch;
flex-direction: column;
}
.rawLink,
.expandButton {
align-self: flex-start;
}
.versions,
.filters,
.detailsGrid,
.globalProps {
grid-template-columns: 1fr;
}
.versions div + div {
border-left: 0;
border-top: 1px solid var(--catalog-border);
}
.searchField,
.checkboxField {
grid-column: auto;
}
}
@@ -1,66 +1,564 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import useBaseUrl from "@docusaurus/useBaseUrl";
import { useEffect, useMemo, useState } from "react";
import customJsxComponentRegistry from "../generated/custom-jsx-components.json";
import styles from "./custom-jsx-component-reference.module.css";
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))];
type ComponentSafety = "allowed" | "wrapped" | "denied";
type ComponentPackage = "@mantine/core" | "@mantine/dates" | "@mantine/charts" | "@homarr/widgets";
type ComponentBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
interface CatalogProp {
name: string;
typeRef: number;
required: boolean;
source: "global" | "component";
literalValues?: Array<string | number | boolean | null>;
description?: string;
}
interface CatalogComponent {
name: string;
package: ComponentPackage;
category: string;
safety: ComponentSafety;
description?: string;
documentationUrl: string;
props: CatalogProp[];
blockedProps: Array<{ name: string; reason: string }>;
subcomponents: string[];
bind?: {
type: ComponentBindingType;
initialProp: "defaultValue" | "defaultChecked";
};
accessibilityRequirements: string[];
deniedReason?: string;
}
interface ComponentCatalog {
schemaVersion: number;
mantineVersion: string;
customWidgetVersion: string;
types: string[];
globalProps: CatalogProp[];
blockedCapabilities: Array<{ kind: string; name: string; reason: string }>;
components: CatalogComponent[];
}
const PAGE_SIZE = 40;
export function CustomJsxComponentReference() {
return (
<>
<h3>Enabled and wrapped components</h3>
<table>
<thead>
<tr>
<th>Category</th>
<th>Components</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<tr key={category}>
<td>
<code>{category}</code>
</td>
<td>
{enabledComponents
.filter((component) => component.category === category)
.map((component) => (
<span key={component.name} style={{ display: "inline-block", margin: "0 0.5rem 0.35rem 0" }}>
<a href={component.documentationUrl}>
<code>{component.name}</code>
</a>
{component.safety === "wrapped" && <small> (wrapped)</small>}
</span>
))}
</td>
</tr>
))}
</tbody>
</table>
<h3>Denied components</h3>
<table>
<thead>
<tr>
<th>Component</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{deniedComponents.map((component) => (
<tr key={component.name}>
<td>
<a href={component.documentationUrl} aria-label={component.name}>
<code>{component.name}</code>
</a>
</td>
<td>{component.reason}</td>
</tr>
))}
</tbody>
</table>
</>
<BrowserOnly fallback={<ComponentReferenceFallback />}>{() => <CustomJsxComponentReferenceClient />}</BrowserOnly>
);
}
function ComponentReferenceFallback() {
return (
<div className={styles.loading} aria-busy="true">
Loading the Custom JSX component catalog…
</div>
);
}
function CustomJsxComponentReferenceClient() {
const catalogUrl = useBaseUrl("/custom-widgets/component-catalog-v1.json");
const [catalog, setCatalog] = useState<ComponentCatalog | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [packageFilter, setPackageFilter] = useState("all");
const [categoryFilter, setCategoryFilter] = useState("all");
const [safetyFilter, setSafetyFilter] = useState("available");
const [bindableOnly, setBindableOnly] = useState(false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
const [showGlobalProps, setShowGlobalProps] = useState(false);
useEffect(() => {
const controller = new AbortController();
setLoadError(null);
void fetch(catalogUrl, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`The catalog request returned HTTP ${response.status}.`);
return (await response.json()) as ComponentCatalog;
})
.then((value) => {
if (!Array.isArray(value.components) || !Array.isArray(value.types) || !Array.isArray(value.globalProps)) {
throw new Error("The catalog response has an unsupported shape.");
}
setCatalog(value);
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setLoadError(error instanceof Error ? error.message : "The component catalog could not be loaded.");
});
return () => controller.abort();
}, [catalogUrl]);
const categories = useMemo(
() => [...new Set(catalog?.components.map((component) => component.category) ?? [])].sort(),
[catalog],
);
const packages = useMemo(
() => [...new Set(catalog?.components.map((component) => component.package) ?? [])].sort(),
[catalog],
);
const filteredComponents = useMemo(() => {
if (!catalog) return [];
const normalizedQuery = query.trim().toLocaleLowerCase();
return catalog.components.filter((component) => {
if (packageFilter !== "all" && component.package !== packageFilter) return false;
if (categoryFilter !== "all" && component.category !== categoryFilter) return false;
if (safetyFilter === "available" && component.safety === "denied") return false;
if (safetyFilter !== "all" && safetyFilter !== "available" && component.safety !== safetyFilter) return false;
if (bindableOnly && !component.bind) return false;
if (!normalizedQuery) return true;
return [
component.name,
component.package,
component.category,
component.description,
component.deniedReason,
...component.subcomponents,
...component.props.map((prop) => prop.name),
].some((value) => value?.toLocaleLowerCase().includes(normalizedQuery));
});
}, [bindableOnly, catalog, categoryFilter, packageFilter, query, safetyFilter]);
const visibleComponents = filteredComponents.slice(0, visibleCount);
useEffect(() => {
setVisibleCount(PAGE_SIZE);
}, [bindableOnly, categoryFilter, packageFilter, query, safetyFilter]);
useEffect(() => {
if (!catalog) return;
const revealHashTarget = () => {
const hash = decodeLocationHash(window.location.hash);
const component = catalog.components.find((entry) => componentAnchor(entry.name) === hash);
if (!component) return;
if (!document.getElementById(hash)) {
setQuery(component.name);
setPackageFilter("all");
setCategoryFilter("all");
setSafetyFilter(component.safety === "denied" ? "all" : "available");
setBindableOnly(false);
setVisibleCount(PAGE_SIZE);
}
setExpanded((current) => new Set([...current, component.name]));
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => document.getElementById(hash)?.scrollIntoView({ block: "start" }));
});
};
revealHashTarget();
window.addEventListener("hashchange", revealHashTarget);
return () => window.removeEventListener("hashchange", revealHashTarget);
}, [catalog]);
if (loadError) {
return (
<div className={styles.error} role="alert">
<strong>Component catalog unavailable.</strong> {loadError} <a href={catalogUrl}>Open the raw catalog</a> to
inspect the generated data directly.
</div>
);
}
if (!catalog) return <ComponentReferenceFallback />;
return (
<section className={styles.explorer} aria-label="Custom JSX component catalog">
<header className={styles.header}>
<div>
<p className={styles.eyebrow}>Release-matched static catalog</p>
<h3 className={styles.title}>Explore {catalog.components.length} Custom JSX components</h3>
<p className={styles.summary}>
This browser reads the same versioned JSON artifact used by offline authoring. It never imports or executes
the listed component objects.
</p>
</div>
<a className={styles.rawLink} href={catalogUrl} target="_blank" rel="noreferrer">
Open raw JSON
</a>
</header>
<dl className={styles.versions}>
<div>
<dt>Catalog schema</dt>
<dd>v{catalog.schemaVersion}</dd>
</div>
<div>
<dt>Custom Widget</dt>
<dd>{catalog.customWidgetVersion}</dd>
</div>
<div>
<dt>Mantine</dt>
<dd>{catalog.mantineVersion}</dd>
</div>
<div>
<dt>Blocked capabilities</dt>
<dd>{catalog.blockedCapabilities.length}</dd>
</div>
</dl>
<section className={styles.globalProps} id="custom-jsx-global-props">
<div>
<h4>Shared serializable props</h4>
<p>
Every available component also receives these {catalog.globalProps.length} generated global, ARIA, data, and
safe style props. Component cards below list only their component-specific additions.
</p>
</div>
<button
type="button"
className={styles.expandButton}
aria-expanded={showGlobalProps}
aria-controls="custom-jsx-global-props-table"
onClick={() => setShowGlobalProps((current) => !current)}
>
{showGlobalProps ? "Hide global props" : "View global props"}
</button>
{showGlobalProps && (
<div className={styles.globalPropsTable} id="custom-jsx-global-props-table">
<PropTable props={catalog.globalProps} types={catalog.types} />
</div>
)}
</section>
<div className={styles.filters}>
<label className={styles.searchField}>
<span>Search names, props, and subcomponents</span>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="For example: TextInput, bind, chart…"
/>
</label>
<label>
<span>Package</span>
<select value={packageFilter} onChange={(event) => setPackageFilter(event.currentTarget.value)}>
<option value="all">All packages</option>
{packages.map((packageName) => (
<option key={packageName} value={packageName}>
{packageName}
</option>
))}
</select>
</label>
<label>
<span>Category</span>
<select value={categoryFilter} onChange={(event) => setCategoryFilter(event.currentTarget.value)}>
<option value="all">All categories</option>
{categories.map((category) => (
<option key={category} value={category}>
{humanize(category)}
</option>
))}
</select>
</label>
<label>
<span>Availability</span>
<select value={safetyFilter} onChange={(event) => setSafetyFilter(event.currentTarget.value)}>
<option value="available">Available only</option>
<option value="all">Available and denied</option>
<option value="allowed">Allowed only</option>
<option value="wrapped">Wrapped only</option>
<option value="denied">Denied only</option>
</select>
</label>
<label className={styles.checkboxField}>
<input
type="checkbox"
checked={bindableOnly}
onChange={(event) => setBindableOnly(event.currentTarget.checked)}
/>
Supports temporary <code>bind</code>
</label>
</div>
<div className={styles.resultSummary} aria-live="polite">
<strong>{filteredComponents.length}</strong> matching component{filteredComponents.length === 1 ? "" : "s"}
{(query ||
packageFilter !== "all" ||
categoryFilter !== "all" ||
safetyFilter !== "available" ||
bindableOnly) && (
<button
type="button"
className={styles.resetButton}
onClick={() => {
setQuery("");
setPackageFilter("all");
setCategoryFilter("all");
setSafetyFilter("available");
setBindableOnly(false);
}}
>
Reset filters
</button>
)}
</div>
{filteredComponents.length === 0 ? (
<div className={styles.empty}>No component matches these filters.</div>
) : (
<div className={styles.componentList}>
{visibleComponents.map((component) => {
const isExpanded = expanded.has(component.name);
return (
<article className={styles.componentCard} id={componentAnchor(component.name)} key={component.name}>
<div className={styles.componentHeading}>
<div>
<div className={styles.componentNameRow}>
<h4>{component.name}</h4>
<a
className={styles.anchorLink}
href={`#${componentAnchor(component.name)}`}
aria-label={`Link to ${component.name}`}
>
#
</a>
</div>
<div className={styles.badges}>
<span>{component.package}</span>
<span>{humanize(component.category)}</span>
<span data-safety={component.safety}>{component.safety}</span>
{component.bind && <span>bind: {component.bind.type}</span>}
</div>
</div>
<button
type="button"
className={styles.expandButton}
aria-expanded={isExpanded}
aria-controls={`${componentAnchor(component.name)}-details`}
onClick={() => {
setExpanded((current) => {
const next = new Set(current);
if (next.has(component.name)) next.delete(component.name);
else next.add(component.name);
return next;
});
}}
>
{isExpanded ? "Hide API" : "View API"}
</button>
</div>
{(component.description || component.deniedReason) && (
<p className={component.safety === "denied" ? styles.deniedReason : styles.description}>
{component.deniedReason ?? component.description}
</p>
)}
{isExpanded && (
<ComponentDetails
id={`${componentAnchor(component.name)}-details`}
component={component}
types={catalog.types}
/>
)}
</article>
);
})}
</div>
)}
{visibleCount < filteredComponents.length && (
<button
type="button"
className={styles.showMoreButton}
onClick={() => setVisibleCount((current) => current + PAGE_SIZE)}
>
Show {Math.min(PAGE_SIZE, filteredComponents.length - visibleCount)} more
</button>
)}
</section>
);
}
function decodeLocationHash(hash: string) {
const value = hash.slice(1);
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function ComponentDetails({ id, component, types }: { id: string; component: CatalogComponent; types: string[] }) {
return (
<div className={styles.details} id={id}>
<div className={styles.detailsGrid}>
<section>
<h5>Usage example</h5>
<pre className={styles.codeBlock}>
<code>{buildUsageExample(component, types)}</code>
</pre>
</section>
<section>
<h5>Component contract</h5>
{component.bind ? (
<p>
<code>bind</code> stores a temporary <code>{component.bind.type}</code> input. Initialize it with{" "}
<code>{component.bind.initialProp}</code>.
</p>
) : (
<p>This component does not expose a temporary-input adapter.</p>
)}
{component.subcomponents.length > 0 && (
<p>
<strong>Subcomponents:</strong>{" "}
{component.subcomponents.map((name, index) => (
<span key={name}>
{index > 0 && ", "}
<code>{name}</code>
</span>
))}
</p>
)}
{component.blockedProps.length > 0 && (
<ul className={styles.a11yList}>
{component.blockedProps.map((prop) => (
<li key={prop.name}>
<code>{prop.name}</code> is blocked: {prop.reason}
</li>
))}
</ul>
)}
{component.accessibilityRequirements.length > 0 && (
<ul className={styles.a11yList}>
{component.accessibilityRequirements.map((requirement) => (
<li key={requirement}>{requirement}</li>
))}
</ul>
)}
<a href={component.documentationUrl} target="_blank" rel="noreferrer">
Open upstream documentation
</a>
</section>
</div>
<section>
<h5>
Component props <span className={styles.muted}>({component.props.length})</span>
</h5>
{component.props.length === 0 ? (
<p>No component-specific props are exposed.</p>
) : (
<PropTable props={component.props} types={types} />
)}
</section>
<details className={styles.rawComponent}>
<summary>Raw component JSON</summary>
<pre className={styles.codeBlock}>
<code>{JSON.stringify(component, null, 2)}</code>
</pre>
</details>
</div>
);
}
function PropTable({ props, types }: { props: CatalogProp[]; types: string[] }) {
return (
<div className={styles.tableScroll}>
<table className={styles.propsTable}>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Required</th>
<th>Known values</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{props.map((prop) => (
<tr key={prop.name}>
<td>
<code>{prop.name}</code>
</td>
<td>
<code className={styles.type}>{types[prop.typeRef] ?? "unknown"}</code>
</td>
<td>{prop.required ? <strong>Yes</strong> : "No"}</td>
<td>
{prop.literalValues?.length ? (
<code>{prop.literalValues.map(formatLiteral).join(" | ")}</code>
) : (
<span className={styles.muted}>—</span>
)}
</td>
<td>{prop.description ?? <span className={styles.muted}>—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function componentAnchor(name: string) {
return `custom-jsx-component-${name
.replaceAll(".", "-")
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.toLowerCase()}`;
}
function humanize(value: string) {
return value.replaceAll("-", " ").replace(/\b\w/g, (character) => character.toUpperCase());
}
function formatLiteral(value: string | number | boolean | null) {
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
function buildUsageExample(component: CatalogComponent, types: string[]) {
if (component.name === "RecursiveList") {
return `<RecursiveList data={data.tree} childrenPath="children" keyPath="id" showLines>
{(node, meta) => (
<Group gap="xs">
<Text>{node.label}</Text>
{meta.hasChildren && <Badge>{meta.childCount}</Badge>}
</Group>
)}
</RecursiveList>`;
}
const props = component.props
.filter((prop) => prop.required)
.slice(0, 4)
.map((prop) => `${prop.name}=${examplePropValue(prop, types[prop.typeRef] ?? "unknown")}`);
if (component.bind) {
props.unshift(
`bind="value"`,
`${component.bind.initialProp}=${bindingInitialValue(component.bind.type, component.bind.initialProp)}`,
);
}
const propText = props.length > 0 ? ` ${props.join(" ")}` : "";
return `<${component.name}${propText} />`;
}
function bindingInitialValue(type: ComponentBindingType, initialProp: "defaultValue" | "defaultChecked") {
if (initialProp === "defaultChecked") return "{false}";
if (type === "string") return '""';
if (type === "number") return "{0}";
if (type === "boolean") return "{false}";
return "{[]}";
}
function examplePropValue(prop: CatalogProp, type: string) {
const literal = prop.literalValues?.[0];
if (literal !== undefined) return `{${formatLiteral(literal)}}`;
if (type.includes("string")) return `"${prop.name}"`;
if (type.includes("boolean")) return "{true}";
if (type.includes("number")) return "{0}";
if (type.includes("[]") || type.includes("Array<")) return "{[]}";
return `{options.${prop.name}}`;
}
@@ -0,0 +1,125 @@
import { useState } from "react";
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
import "@mantine/core/styles/Badge.css";
import "@mantine/core/styles/Button.css";
import "@mantine/core/styles/Combobox.css";
import "@mantine/core/styles/Group.css";
import "@mantine/core/styles/Input.css";
import "@mantine/core/styles/Loader.css";
import "@mantine/core/styles/Pill.css";
import "@mantine/core/styles/PillsInput.css";
import "@mantine/core/styles/Popover.css";
import "@mantine/core/styles/ScrollArea.css";
import "@mantine/core/styles/Stack.css";
import "@mantine/core/styles/Text.css";
import "@mantine/core/styles/UnstyledButton.css";
/* oxlint-enable import/no-unassigned-import */
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import type { CanonicalCustomWidgetExampleProps, CustomWidgetCodeExampleProps } from "./custom-widget-code";
const messages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No matching components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal example",
fullTab: "Full example",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Read-only example",
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
export function CanonicalCustomWidgetExampleClient({ id, label, example, height }: CanonicalCustomWidgetExampleProps) {
const value =
example === "requests"
? CUSTOM_WIDGET_REQUEST_EXAMPLES.full
: example === "optionsSchema"
? CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema
: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults;
return <CustomWidgetCodeExampleClient id={id} label={label} code={JSON.stringify(value, null, 2)} height={height} />;
}
export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProps) {
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<ReadOnlyCustomWidgetCode
id={props.id}
label={props.label}
language={props.language ?? "json"}
value={props.code}
messages={messages}
height={props.height}
/>
</MantineProvider>
);
}
export function BundledCustomWidgetGalleryClient() {
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<Stack gap="md">
<Select
label="Bundled widget"
data={BUNDLED_CUSTOM_WIDGETS.map(({ id, widget }) => ({ value: id, label: widget.name }))}
value={selected.id}
onChange={(value) => {
if (value) setSelectedId(value);
}}
allowDeselect={false}
/>
<Text size="sm" c="dimmed">
{selected.widget.description}
</Text>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-manifest`}
label="widget.json"
language="json"
value={JSON.stringify(manifest, null, 2)}
messages={messages}
height="340px"
/>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-template`}
label="widget.jsx"
language="jsx"
value={selected.widget.template}
messages={messages}
height="340px"
/>
</Stack>
</MantineProvider>
);
}
function getDocumentationColorScheme() {
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
}
+42 -122
View File
@@ -1,57 +1,7 @@
import BrowserOnly from "@docusaurus/BrowserOnly";
import { useState } from "react";
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
import "@mantine/core/styles/Badge.css";
import "@mantine/core/styles/Button.css";
import "@mantine/core/styles/Combobox.css";
import "@mantine/core/styles/Group.css";
import "@mantine/core/styles/Input.css";
import "@mantine/core/styles/Loader.css";
import "@mantine/core/styles/Pill.css";
import "@mantine/core/styles/PillsInput.css";
import "@mantine/core/styles/Popover.css";
import "@mantine/core/styles/ScrollArea.css";
import "@mantine/core/styles/Stack.css";
import "@mantine/core/styles/Text.css";
import "@mantine/core/styles/UnstyledButton.css";
/* oxlint-enable import/no-unassigned-import */
import { lazy, Suspense } from "react";
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
const messages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No matching components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal example",
fullTab: "Full example",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Read-only example",
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
interface CustomWidgetCodeExampleProps {
export interface CustomWidgetCodeExampleProps {
id: string;
label: string;
code: string;
@@ -59,40 +9,47 @@ interface CustomWidgetCodeExampleProps {
height?: string;
}
export function CanonicalCustomWidgetExample({
id,
label,
example,
height,
}: {
export interface CanonicalCustomWidgetExampleProps {
id: string;
label: string;
example: "requests" | "optionsSchema" | "defaultOptions";
height?: string;
}) {
const value =
example === "requests"
? CUSTOM_WIDGET_REQUEST_EXAMPLES.full
: example === "optionsSchema"
? CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema
: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults;
return <CustomWidgetCodeExample id={id} label={label} code={JSON.stringify(value, null, 2)} height={height} />;
}
const LazyCanonicalCustomWidgetExample = lazy(async () => {
const module = await import("./custom-widget-code-client");
return { default: module.CanonicalCustomWidgetExampleClient };
});
const LazyCustomWidgetCodeExample = lazy(async () => {
const module = await import("./custom-widget-code-client");
return { default: module.CustomWidgetCodeExampleClient };
});
const LazyBundledCustomWidgetGallery = lazy(async () => {
const module = await import("./custom-widget-code-client");
return { default: module.BundledCustomWidgetGalleryClient };
});
export function CanonicalCustomWidgetExample(props: CanonicalCustomWidgetExampleProps) {
return (
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => (
<Suspense fallback={<CodeExampleFallback height={props.height} />}>
<LazyCanonicalCustomWidgetExample {...props} />
</Suspense>
)}
</BrowserOnly>
);
}
export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
return (
<BrowserOnly fallback={<div style={{ minHeight: props.height ?? "220px" }} />}>
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<ReadOnlyCustomWidgetCode
id={props.id}
label={props.label}
language={props.language ?? "json"}
value={props.code}
messages={messages}
height={props.height}
/>
</MantineProvider>
<Suspense fallback={<CodeExampleFallback height={props.height} />}>
<LazyCustomWidgetCodeExample {...props} />
</Suspense>
)}
</BrowserOnly>
);
@@ -100,53 +57,16 @@ export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
export function BundledCustomWidgetGallery() {
return (
<BrowserOnly fallback={<div style={{ minHeight: "520px" }} />}>
{() => <BundledCustomWidgetGalleryClient />}
<BrowserOnly fallback={<CodeExampleFallback height="520px" />}>
{() => (
<Suspense fallback={<CodeExampleFallback height="520px" />}>
<LazyBundledCustomWidgetGallery />
</Suspense>
)}
</BrowserOnly>
);
}
function BundledCustomWidgetGalleryClient() {
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<Stack gap="md">
<Select
label="Bundled widget"
data={BUNDLED_CUSTOM_WIDGETS.map(({ id, widget }) => ({ value: id, label: widget.name }))}
value={selected.id}
onChange={(value) => {
if (value) setSelectedId(value);
}}
allowDeselect={false}
/>
<Text size="sm" c="dimmed">
{selected.widget.description}
</Text>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-manifest`}
label="widget.json"
language="json"
value={JSON.stringify(manifest, null, 2)}
messages={messages}
height="340px"
/>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-template`}
label="widget.jsx"
language="jsx"
value={selected.widget.template}
messages={messages}
height="340px"
/>
</Stack>
</MantineProvider>
);
}
function getDocumentationColorScheme() {
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
function CodeExampleFallback({ height }: { height?: string }) {
return <div style={{ minHeight: height ?? "220px" }} aria-busy="true" />;
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { WorkshopAdminAction, WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
import { WorkshopClient } from "@homarr/workshop";
import styles from "./workshop.module.css";
@@ -12,34 +12,61 @@ export function WorkshopAdmin() {
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
const [actions, setActions] = useState<WorkshopAdminAction[]>([]);
const [busyId, setBusyId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
const [nextReports, nextSubmissions, nextActions] = await Promise.all([
const [nextReports, nextSubmissions] = await Promise.all([
client.listReports(),
client.listAll({ sort: "newest" }),
client.listAdminActions(),
]);
setReports(nextReports);
setSubmissions(nextSubmissions);
setActions(nextActions);
setError("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to load Workshop administration");
} finally {
setLoading(false);
}
}, [client]);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then((next) => {
setUser(next);
if (next?.isAdmin) void load();
void client.refreshAuth().then((nextUser) => {
setUser(nextUser);
if (nextUser?.isAdmin) void load();
});
return unsubscribe;
}, [client, load]);
const dismiss = async (reportId: string) => {
setBusyId(reportId);
try {
await client.dismissReport(reportId);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to dismiss report");
} finally {
setBusyId(null);
}
};
const remove = async (submission: WorkshopSubmissionSummary) => {
if (!window.confirm(`Delete ${submission.title}? Installed local copies will remain.`)) return;
setBusyId(submission.id);
try {
await client.delete(submission.id);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to delete submission");
} finally {
setBusyId(null);
}
};
const grouped = submissions.map((submission) => ({
submission,
reports: reports.filter((report) => report.submission === submission.id),
@@ -49,51 +76,74 @@ export function WorkshopAdmin() {
<main className={styles.shell}>
<section className={styles.hero}>
<h1>Workshop administration</h1>
<p>
Review reports, see each reporter and vote totals, dismiss reports, or delete any submission. This page does
not manage user accounts.
</p>
{!user && (
<button
className={styles.button}
onClick={() =>
void client.signInWithGitHub().then((next) => {
setUser(next);
if (next?.isAdmin) void load();
})
}
>
Sign in with GitHub
</button>
)}
<p>Review community reports and remove submissions. PocketBase rules enforce every moderation action.</p>
<div className={styles.actions}>
<a className={styles.secondary} href="/workshop">
Back to Workshop
</a>
{!user && (
<button
className={styles.button}
onClick={() =>
void client
.signInWithGitHub()
.then((nextUser) => {
setUser(nextUser);
if (nextUser?.isAdmin) void load();
})
.catch((cause: Error) => setError(cause.message))
}
>
Sign in with GitHub
</button>
)}
{user?.isAdmin && (
<button className={styles.secondary} disabled={loading} onClick={() => void load()}>
{loading ? "Refreshing…" : "Refresh"}
</button>
)}
</div>
</section>
{user && !user.isAdmin && <div className={styles.error}>This Workshop account is not an administrator.</div>}
{error && <div className={styles.error}>{error}</div>}
{user?.isAdmin && (
<div className={styles.stack} style={{ marginTop: "2rem" }}>
<h2>Submissions and open reports</h2>
<section className={styles.adminList} aria-busy={loading}>
<div className={styles.adminSummary}>
<strong>{reports.length} open reports</strong>
<span>{submissions.length} published widgets</span>
</div>
{grouped.map(({ submission, reports: submissionReports }) => (
<section key={submission.id} className={styles.adminGroup}>
<div className={styles.meta}>
<strong>{submission.title}</strong>
<span>
▲ {submission.upvotes} · ▼ {submission.downvotes} · score {submission.score} ·{" "}
{submissionReports.length} reports
<article key={submission.id} className={styles.adminGroup}>
<div className={styles.adminHeading}>
<div>
<h2>{submission.title}</h2>
<span>by {submission.authorName}</span>
</div>
<span className={submissionReports.length > 0 ? styles.reportCount : undefined}>
{submissionReports.length} {submissionReports.length === 1 ? "report" : "reports"}
</span>
</div>
<p>{submission.description}</p>
<p>{submission.description || "No description"}</p>
<div className={styles.meta}>
<span>
▲ {submission.upvotes} · ▼ {submission.downvotes} · score {submission.score}
</span>
<span>Updated {new Date(submission.updated).toLocaleDateString()}</span>
</div>
{submissionReports.map((report) => (
<div key={report.id} className={styles.report}>
<strong>{report.category}</strong> by {report.reporterName}
<div key={report.id} className={styles.moderationReport}>
<div>
<strong>{report.category}</strong>
<span>Reported by {report.reporterName}</span>
</div>
<p>{report.explanation}</p>
<button
className={styles.secondary}
onClick={() => {
const reason = window.prompt("Why is this report being dismissed?")?.trim();
if (reason) void client.dismissReport(report.id, reason).then(load);
}}
disabled={busyId === report.id}
onClick={() => void dismiss(report.id)}
>
Dismiss report
{busyId === report.id ? "Dismissing…" : "Dismiss report"}
</button>
</div>
))}
@@ -103,26 +153,16 @@ export function WorkshopAdmin() {
</a>
<button
className={styles.danger}
onClick={() => {
if (window.confirm(`Delete ${submission.title}? Installed local copies will remain.`))
void client.delete(submission.id, "Deleted after administrator review").then(load);
}}
disabled={busyId === submission.id}
onClick={() => void remove(submission)}
>
Delete submission
{busyId === submission.id ? "Deleting…" : "Delete submission"}
</button>
</div>
</section>
</article>
))}
<h2>Administrator audit trail</h2>
{actions.map((action) => (
<div key={action.id} className={`${styles.adminGroup} ${styles.audit}`}>
{new Date(action.created).toLocaleString()} · {action.actorName} · {action.action} · submission{" "}
{action.submissionId || "—"} · report {action.reportId || "—"}
<br />
{action.reason}
</div>
))}
</div>
{!loading && grouped.length === 0 && <p className={styles.empty}>No Workshop submissions.</p>}
</section>
)}
</main>
);
+230 -113
View File
@@ -6,16 +6,12 @@ import type {
WorkshopSubmissionSummary,
WorkshopUser,
} from "@homarr/workshop";
import { validateWorkshopWidget, WorkshopClient } from "@homarr/workshop";
import { validateWorkshopWidget, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { CustomWidgetCodeExample } from "../custom-widget-code";
import styles from "./workshop.module.css";
const emptyDraft: WorkshopSubmissionInput = {
title: "",
description: "",
content: "",
changelog: "Initial publication",
};
const emptyDraft: WorkshopSubmissionInput = { title: "", description: "", content: "" };
export function WorkshopPage() {
const client = useMemo(
@@ -24,9 +20,10 @@ export function WorkshopPage() {
);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
const [search, setSearch] = useState("");
const [sort, setSort] = useState<"top" | "newest">("top");
const [sort, setSort] = useState<"top" | "newest" | "reported">("top");
const [draft, setDraft] = useState<WorkshopSubmissionInput>(emptyDraft);
const [screenshots, setScreenshots] = useState<File[]>([]);
const [editorOpen, setEditorOpen] = useState(false);
@@ -35,15 +32,34 @@ export function WorkshopPage() {
const [reportText, setReportText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const draftValidation = useMemo(() => validateWorkshopWidget(draft.content), [draft.content]);
const reportCounts = useMemo(() => {
const counts = new Map<string, number>();
reports.forEach((report) => counts.set(report.submission, (counts.get(report.submission) ?? 0) + 1));
return counts;
}, [reports]);
const selectedReports = selected ? reports.filter((report) => report.submission === selected.id) : [];
const load = useCallback(async () => {
try {
setItems((await client.list({ search, sort, perPage: 60 })).items);
const [nextReports, nextItems] = await Promise.all([
user?.isAdmin ? client.listReports() : Promise.resolve([]),
sort === "reported" && user?.isAdmin
? client.listAll({ search, sort: "newest" })
: client.list({ search, sort: sort === "newest" ? "newest" : "top", perPage: 60 }).then((page) => page.items),
]);
if (sort === "reported") {
const counts = new Map<string, number>();
nextReports.forEach((report) => counts.set(report.submission, (counts.get(report.submission) ?? 0) + 1));
nextItems.sort((a, b) => (counts.get(b.id) ?? 0) - (counts.get(a.id) ?? 0));
}
setReports(nextReports);
setItems(nextItems);
setError("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Workshop is unavailable");
}
}, [client, search, sort]);
}, [client, search, sort, user?.isAdmin]);
const openDetail = useCallback(
async (id: string) => {
@@ -73,7 +89,15 @@ export function WorkshopPage() {
return () => window.clearTimeout(timer);
}, [load]);
useEffect(() => {
if (!user?.isAdmin && sort === "reported") setSort("top");
}, [sort, user?.isAdmin]);
const save = async () => {
if (!draftValidation.success) {
setError(draftValidation.error);
return;
}
setBusy(true);
setError("");
try {
@@ -82,6 +106,7 @@ export function WorkshopPage() {
setEditorOpen(false);
setDraft(emptyDraft);
setScreenshots([]);
setSelected(null);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to publish");
@@ -90,30 +115,72 @@ export function WorkshopPage() {
}
};
const edit = () => {
const vote = async (value: 1 | -1) => {
if (!selected) return;
setDraft({
title: selected.title,
description: selected.description,
content: selected.content,
changelog: selected.changelog,
});
setEditorOpen(true);
setError("");
try {
await client.vote(selected.id, value);
await Promise.all([openDetail(selected.id), load()]);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to save vote");
}
};
const dismissReport = async (id: string) => {
setBusy(true);
setError("");
try {
await client.dismissReport(id);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to dismiss report");
} finally {
setBusy(false);
}
};
const deleteSubmission = async () => {
if (!selected || !window.confirm(`Delete ${selected.title}? Installed copies will remain.`)) return;
setBusy(true);
setError("");
try {
await client.delete(selected.id);
setSelected(null);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to delete submission");
} finally {
setBusy(false);
}
};
const submitReport = async () => {
if (!selected || reportText.trim().length < 3) return;
setBusy(true);
setError("");
try {
await client.report(selected.id, reportCategory, reportText);
setReportOpen(false);
setReportText("");
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to report submission");
} finally {
setBusy(false);
}
};
return (
<main className={styles.shell}>
<section className={styles.hero}>
<h1>Workshop</h1>
<p>
Discover and publish Custom JSX v2 widgets. Every submission is public immediately; the community can vote and
report unsafe content.
</p>
<p>Discover, inspect, and publish Custom JSX v2 widgets. Submissions are public immediately.</p>
<div className={styles.actions}>
{user ? (
<>
<span>
Signed in as <strong>{user.displayName}</strong>
{user.isAdmin && <span className={styles.adminBadge}>Admin</span>}
</span>
<button
className={styles.secondary}
@@ -129,11 +196,18 @@ export function WorkshopPage() {
onClick={() => {
setSelected(null);
setDraft(emptyDraft);
setScreenshots([]);
setError("");
setEditorOpen(true);
}}
>
Publish widget
</button>
{user.isAdmin && (
<a className={styles.secondary} href="/workshop/admin">
Review reports
</a>
)}
</>
) : (
<button
@@ -150,8 +224,10 @@ export function WorkshopPage() {
)}
</div>
</section>
<div className={styles.notice}>
Inspect API origins, methods, permissions, and JSX before importing. Workshop never stores widget credentials.
Inspect API origins, methods, permissions, and JSX before importing. Widget exports must not contain
credentials.
</div>
<div className={styles.toolbar}>
<input
@@ -165,42 +241,54 @@ export function WorkshopPage() {
className={styles.select}
style={{ width: 180 }}
value={sort}
onChange={(event) => setSort(event.currentTarget.value as "top" | "newest")}
onChange={(event) => {
const value = event.currentTarget.value;
setSort(value === "reported" && user?.isAdmin ? "reported" : value === "newest" ? "newest" : "top");
}}
>
<option value="top">Top rated</option>
<option value="newest">Newest</option>
{user?.isAdmin && <option value="reported">Most reported</option>}
</select>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.grid}>
{items.map((item) => (
<article key={item.id} className={styles.card}>
{item.screenshots[0] ? (
<img
className={styles.previewImage}
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
alt=""
/>
) : (
<div className={styles.preview}>JSX</div>
)}
<div className={styles.cardBody}>
<div className={styles.meta}>
<span>Revision {item.revision}</span>
<span>
▲ {item.upvotes} · ▼ {item.downvotes}
</span>
{items.map((item) => {
const reportCount = reportCounts.get(item.id) ?? 0;
return (
<article key={item.id} className={`${styles.card} ${reportCount ? styles.reportedCard : ""}`}>
{item.screenshots[0] ? (
<img
className={styles.previewImage}
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
alt=""
/>
) : (
<div className={styles.preview}>JSX</div>
)}
<div className={styles.cardBody}>
<div className={styles.meta}>
<span>by {item.authorName}</span>
<span>
▲ {item.upvotes} · ▼ {item.downvotes}
</span>
</div>
{user?.isAdmin && reportCount > 0 && (
<span className={styles.reportCount}>
{reportCount} {reportCount === 1 ? "report" : "reports"}
</span>
)}
<h2>{item.title}</h2>
<p>{item.description || "No description"}</p>
<button className={styles.secondary} disabled={busy} onClick={() => void openDetail(item.id)}>
Inspect widget
</button>
</div>
<h2>{item.title}</h2>
<p>{item.description || "No description"}</p>
<small>by {item.authorName}</small>
<button className={styles.secondary} disabled={busy} onClick={() => void openDetail(item.id)}>
Inspect widget
</button>
</div>
</article>
))}
</article>
);
})}
</div>
{items.length === 0 && <p className={styles.empty}>No widgets match this search.</p>}
{selected && (
<div
@@ -209,14 +297,41 @@ export function WorkshopPage() {
onMouseDown={(event) => event.target === event.currentTarget && setSelected(null)}
>
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label={selected.title}>
<button type="button" className={styles.dialogClose} aria-label="Close" onClick={() => setSelected(null)}>
×
</button>
<h2>{selected.title}</h2>
<p>{selected.description}</p>
<div className={styles.meta}>
<span>by {selected.authorName}</span>
<span>
by {selected.authorName} · revision {selected.revision}
▲ {selected.upvotes} · ▼ {selected.downvotes}
</span>
<span>SHA-256 {selected.contentHash.slice(0, 12)}…</span>
</div>
{user?.isAdmin && selectedReports.length > 0 && (
<section className={styles.moderation} aria-labelledby="submission-reports-title">
<h3 id="submission-reports-title">
{selectedReports.length} {selectedReports.length === 1 ? "report" : "reports"}
</h3>
{selectedReports.map((report) => (
<div key={report.id} className={styles.moderationReport}>
<div>
<strong>{report.category}</strong>
<span>Reported by {report.reporterName}</span>
</div>
<p>{report.explanation}</p>
<button
type="button"
className={styles.secondary}
disabled={busy}
onClick={() => void dismissReport(report.id)}
>
Dismiss report
</button>
</div>
))}
</section>
)}
{selected.screenshots.length > 0 && (
<div className={styles.screenshots}>
{selected.screenshots.map((file, index) => (
@@ -229,52 +344,47 @@ export function WorkshopPage() {
</div>
)}
<WidgetCapabilitySummary content={selected.content} />
<pre className={styles.code}>{selected.content}</pre>
<CustomWidgetCodeExample
id={`workshop-submission-${selected.id}`}
label="widget.json"
code={selected.content}
height="340px"
/>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
className={styles.secondary}
disabled={!user}
onClick={() =>
void client
.vote(selected.id, 1)
.then(() => openDetail(selected.id))
.then(load)
}
>
<button className={styles.secondary} disabled={!user} onClick={() => void vote(1)}>
▲ {selected.upvotes}
</button>
<button
className={styles.secondary}
disabled={!user}
onClick={() =>
void client
.vote(selected.id, -1)
.then(() => openDetail(selected.id))
.then(load)
}
>
<button className={styles.secondary} disabled={!user} onClick={() => void vote(-1)}>
▼ {selected.downvotes}
</button>
<button className={styles.danger} disabled={!user} onClick={() => setReportOpen(true)}>
<button
className={styles.danger}
disabled={!user}
onClick={() => {
setError("");
setReportOpen(true);
}}
>
Report
</button>
{selected.author === user?.id && (
<>
<button className={styles.secondary} onClick={edit}>
Edit
</button>
<button
className={styles.danger}
onClick={() =>
void client.delete(selected.id).then(() => {
setSelected(null);
return load();
})
}
>
Delete
</button>
</>
<button
className={styles.secondary}
onClick={() => {
setDraft({ title: selected.title, description: selected.description, content: selected.content });
setScreenshots([]);
setError("");
setEditorOpen(true);
}}
>
Edit
</button>
)}
{(selected.author === user?.id || user?.isAdmin) && (
<button className={styles.danger} disabled={busy} onClick={() => void deleteSubmission()}>
Delete
</button>
)}
<button
className={styles.button}
@@ -282,16 +392,13 @@ export function WorkshopPage() {
const url = URL.createObjectURL(new Blob([selected.content], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = `${selected.title}.json`;
link.download = workshopExportFilename(selected.title);
link.click();
URL.revokeObjectURL(url);
}}
>
Download
</button>
<button className={styles.secondary} onClick={() => setSelected(null)}>
Close
</button>
</div>
</dialog>
</div>
@@ -325,14 +432,13 @@ export function WorkshopPage() {
value={draft.content}
onChange={(event) => setDraft({ ...draft, content: event.currentTarget.value })}
/>
</label>
<label className={styles.field}>
Changelog
<textarea
className={styles.textarea}
value={draft.changelog}
onChange={(event) => setDraft({ ...draft, changelog: event.currentTarget.value })}
/>
{!draft.content.trim() ? (
<span className={styles.hint}>Paste a complete Custom Widget manifest.</span>
) : draftValidation.success ? (
<span className={styles.success}>Valid {draftValidation.data.$schema} manifest.</span>
) : (
<span className={styles.error}>{draftValidation.error}</span>
)}
</label>
<label className={styles.field}>
Screenshots
@@ -343,14 +449,29 @@ export function WorkshopPage() {
multiple
onChange={(event) => setScreenshots(Array.from(event.currentTarget.files ?? []))}
/>
<span className={styles.hint}>Up to five images, 5 MiB each.</span>
<span className={styles.hint}>
{selected?.author === user?.id
? "Choose files to replace the current screenshots, or leave this empty to keep them."
: "Up to five images, 5 MiB each."}
</span>
</label>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button className={styles.button} disabled={busy || !user} onClick={() => void save()}>
{busy ? "Publishing…" : "Publish immediately"}
<button
className={styles.button}
disabled={busy || !user || !draftValidation.success}
onClick={() => void save()}
>
{busy ? "Saving…" : selected?.author === user?.id ? "Save changes" : "Publish immediately"}
</button>
<button className={styles.secondary} onClick={() => setEditorOpen(false)}>
<button
className={styles.secondary}
onClick={() => {
setEditorOpen(false);
setScreenshots([]);
setError("");
}}
>
Cancel
</button>
</div>
@@ -382,16 +503,12 @@ export function WorkshopPage() {
onChange={(event) => setReportText(event.currentTarget.value)}
/>
</label>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
className={styles.danger}
disabled={reportText.trim().length < 3}
onClick={() =>
void client.report(selected.id, reportCategory, reportText).then(() => {
setReportOpen(false);
setReportText("");
})
}
disabled={busy || reportText.trim().length < 3}
onClick={() => void submitReport()}
>
Send report
</button>
@@ -60,6 +60,20 @@
color: white;
background: var(--ifm-color-danger-dark);
}
.button:hover:not(:disabled),
.secondary:hover:not(:disabled),
.danger:hover:not(:disabled) {
filter: brightness(0.94);
}
.button:focus-visible,
.secondary:focus-visible,
.danger:focus-visible,
.input:focus-visible,
.textarea:focus-visible,
.select:focus-visible {
outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
outline-offset: 2px;
}
.button:disabled,
.secondary:disabled,
.danger:disabled {
@@ -98,6 +112,11 @@
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 1rem;
}
.empty {
margin: 3rem 0;
color: var(--ifm-color-emphasis-700);
text-align: center;
}
.card {
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-200);
@@ -135,6 +154,29 @@
color: var(--ifm-color-emphasis-600);
font-size: 0.82rem;
}
.adminBadge,
.reportCount {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
.adminBadge {
margin-left: 0.45rem;
color: var(--ifm-color-primary-darkest);
background: color-mix(in srgb, var(--ifm-color-primary) 16%, transparent);
}
.reportCount {
color: var(--ifm-color-warning-darkest);
background: color-mix(in srgb, var(--ifm-color-warning) 24%, transparent);
}
.reportedCard {
border-color: var(--ifm-color-warning-dark);
background: color-mix(in srgb, var(--ifm-color-warning) 5%, var(--ifm-card-background-color));
}
.overlay {
position: fixed;
@@ -161,20 +203,37 @@
}
.dialog h2 {
margin-top: 0;
padding-right: 2.5rem;
}
.dialogClose {
position: absolute;
top: 1rem;
right: 1rem;
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: 0;
border-radius: 0.5rem;
color: var(--ifm-font-color-base);
background: var(--ifm-color-emphasis-200);
font: inherit;
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
}
.dialogClose:hover {
background: var(--ifm-color-emphasis-300);
}
.dialogClose:focus-visible {
outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
outline-offset: 2px;
}
.stack {
display: grid;
gap: 1rem;
}
.code {
max-height: 340px;
overflow: auto;
padding: 1rem;
border-radius: 0.65rem;
background: var(--ifm-color-emphasis-100);
white-space: pre;
font-size: 0.78rem;
}
.notice,
.error,
.success {
@@ -186,6 +245,7 @@
}
.error {
background: color-mix(in srgb, var(--ifm-color-danger) 20%, transparent);
white-space: pre-wrap;
}
.success {
background: color-mix(in srgb, var(--ifm-color-success) 20%, transparent);
@@ -214,22 +274,77 @@
color: var(--ifm-color-emphasis-700);
font-size: 0.82rem;
}
.adminGroup {
.moderation {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.8rem;
border: 1px solid var(--ifm-color-warning-dark);
border-radius: 0.75rem;
background: color-mix(in srgb, var(--ifm-color-warning) 10%, var(--ifm-background-color));
}
.report {
padding-left: 0.75rem;
border-left: 3px solid var(--ifm-color-warning);
.moderation h3,
.moderationReport p {
margin: 0;
}
.audit {
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
.moderationReport {
padding-top: 0.75rem;
border-top: 1px solid color-mix(in srgb, var(--ifm-color-warning) 35%, transparent);
}
.moderationReport > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.moderationReport span {
color: var(--ifm-color-emphasis-700);
font-size: 0.8rem;
}
.moderationReport p {
margin-top: 0.35rem;
line-height: 1.55;
white-space: pre-wrap;
}
.moderationReport > button {
margin-top: 0.75rem;
}
.adminList {
display: grid;
gap: 1rem;
margin-top: 2rem;
}
.adminSummary,
.adminHeading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.adminSummary {
padding: 1rem;
border-radius: 0.75rem;
background: var(--ifm-color-emphasis-100);
}
.adminGroup {
display: grid;
gap: 1rem;
padding: 1.25rem;
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 0.9rem;
background: var(--ifm-card-background-color);
}
.adminGroup h2,
.adminGroup p {
margin: 0;
}
.adminHeading > div {
display: grid;
gap: 0.2rem;
}
.adminHeading span {
color: var(--ifm-color-emphasis-600);
font-size: 0.82rem;
}
@media (max-width: 600px) {
.shell {
width: min(100% - 1rem, 1180px);
@@ -241,4 +356,14 @@
.actions > * {
flex: 1 1 auto;
}
.moderationReport > div {
align-items: flex-start;
flex-direction: column;
gap: 0.2rem;
}
.adminSummary,
.adminHeading {
align-items: flex-start;
flex-direction: column;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"side\" | \"merge\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"responsive\"","\"div\" | \"label\"","\"end\" | \"start\"","\"fixed\" | \"absolute\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"month\" | \"year\" | \"decade\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previous\" | \"level\")[]","(ActionIconProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(CloseButtonProps & ElementProps<\"button\"> & DataAttributes)","(CloseButtonProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(ElementProps<\"button\"> & DataAttributes)","(InputDescriptionProps & DataAttributes)","(InputErrorProps & DataAttributes)","(InputLabelProps & DataAttributes)","(InputSuccessProps & DataAttributes)","(OverlayProps & ElementProps<\"div\">)","(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement> & DataAttributes)","(React.ClassAttributes<HTMLDivElement> &Line truncated