Compare commits

..
Author SHA1 Message Date
ajnart fbde111f01 fix: avoid ESM lexer imports in migration bundles 2026-09-12 23:56:54 +02:00
ajnart a6bdbbb4ea feat: add custom widget flow workbench 2026-09-12 22:51:46 +02:00
Thomas c32a5317ca fix: remove apps multi-select toggle (#6834) 2026-09-12 16:34:03 +02:00
Thomas f18033ddaa fix: sync dev fixes into v2 and improve regression reports (#6821)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: homarr-update-contributors[bot] <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
Co-authored-by: homarr-crowdin[bot] <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Closes #6604
2026-09-12 15:31:04 +02:00
Thomas ff2b589380 fix(board): refine container spacing and collapse layout (#6833) 2026-09-12 15:23:50 +02:00
Thomas 9bc3802b3b fix(wud): authenticate HTTP requests and validate container access (#6831) 2026-09-12 10:57:36 +02:00
Thomas 2049c6ade0 fix: tighten v2 setup and release safeguards (#6824) 2026-09-12 10:47:35 +02:00
ajnart e21a0bb6da fix: refresh OpenAPI widget kinds 2026-09-12 10:46:39 +02:00
Thomas 61f22c8e64 fix: show board removal confirmation in a dialog (#6829) 2026-09-12 10:36:36 +02:00
leafyelin 6cca757493 feat(plugins): add llama.cpp server integration and widget (#6765) 2026-09-10 20:06:13 +02:00
Thomas 1102ad8342 fix: protect demo users from deletion and password changes (#6819) 2026-09-10 19:52:47 +02:00
Thomas 7c00c18a42 docs(mcp): restore custom widget authoring guidance (#6817) 2026-09-10 19:24:55 +02:00
Thomas ea3dac752f fix(mcp): harden tool schemas and dispatch (#6815) 2026-09-10 19:19:47 +02:00
Thomas f994d665d2 ci: remove automatic test gates and trim test suites (#6816)
* ci: remove automatic test gates and trim test suites

* docs: remove CI audit report
2026-09-10 19:14:31 +02:00
Thomas 3358b6337f fix(mcp): use JSON-compatible invitation dates (#6814) 2026-09-10 17:53:38 +02:00
Thomas 61d3fd5f08 fix(board): restore container grid geometry (#6808) 2026-09-09 15:04:27 +02:00
Thomas 3aac106156 docs: correct v2 beta installation guidance (#6809) 2026-09-09 12:09:14 +02:00
455 changed files with 44999 additions and 27977 deletions

No files matched your search

+1 -1
View File
@@ -37,4 +37,4 @@ Orient from the current checkout before editing. Treat package manifests, export
- Cron jobs: `packages/cron-jobs/src/jobs/`
- App routes: `apps/nextjs/src/app/[locale]/`
When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
Invoke `documentation-sync` when hidden capabilities or information not evident to an advanced user change, or existing guidance becomes inaccurate; follow the Documentation Sync criteria in `AGENTS.md`. When exposing tRPC through MCP, invoke `mcp-integration`.
+9 -5
View File
@@ -1,13 +1,17 @@
---
name: documentation-sync
description: Keep Homarr's Docusaurus documentation aligned with user-facing code. Use when adding or changing integrations, widgets, APIs, environment variables, CLI commands, authentication, permissions, cron jobs, settings, or UI behavior that users must understand.
description: Write concise Homarr docs for advanced users, covering only hidden capabilities and information not evident from the UI. Use when that information changes or existing guidance becomes inaccurate; skip obvious controls and routine fixes.
---
# Documentation Sync
Update `apps/docs/` in the same change as user-facing behavior. Inspect adjacent pages and the docs types before copying a pattern; the source and docs evolve together.
Apply the audience and scope rules in `AGENTS.md` first. Before adding a passage, identify what an advanced user could not learn directly from the interface. If there is no such information, omit it.
## Map the change
For hidden capabilities such as the advanced widget feature, explain discovery, activation, and non-obvious behavior. For visible standard controls such as Delete, omit narration of what the label already conveys. Use the shortest explanation that preserves necessary technical detail; remove redundant prose in the passage being edited.
When an update is warranted, inspect adjacent pages for structure and types, not as a verbosity target. The mappings below locate needed information; they do not require a page for every feature or code change.
## Locate a needed update
| Code change | Documentation target |
| ----------------------------------- | -------------------------------------------------------------------- |
@@ -40,11 +44,11 @@ For a widget:
- Add or update `apps/docs/docs/widgets/<slug>/index.ts` with the local `WidgetDefinition` pattern.
- Add or update `index.mdx` with the established `WidgetHeader`, `WidgetConfig`, and `AddingWidget` components as applicable.
- Document options, required integrations, permissions, empty states, and changed behavior that affect setup or use.
- Explain only non-obvious option semantics, integration prerequisites, permission constraints, or behavior that the interface does not make clear.
## Completion criteria
1. Describe the shipped behavior, not an implementation plan.
1. Every passage adds information an advanced user cannot reasonably infer from the interface. Remove obvious, repetitive, or unnecessary prose.
2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code.
3. Update every affected page and remove superseded guidance.
4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files.
+6 -10
View File
@@ -1,20 +1,20 @@
---
name: homarr-custom-widget
description: Author, validate, preview, test, install, or configure API-backed Homarr Custom JSX v2 widgets.
description: Author, validate, preview, install, or configure Homarr Custom JSX v2/v3 widgets, including static and native integration widgets.
---
# Homarr Custom Widget
Author one widget or a coordinated set of widgets. Load only needed release-matched context. Run lifecycle tools alone; independent reads may run together. For a set, research once and finish each widget's validation, evidence, and persistence before the next.
Load release-matched context. Run lifecycle tools alone; independent reads may run together. For a set, research once, then validate, preview, and persist each widget before the next.
1. Read primary API documentation. Use web search when documentation is not supplied or may have changed.
1. For HTTP widgets, read primary API documentation. For native capabilities, discover installed schemas. Static widgets need no service.
2. Create credential-free definitions with keyed `sources`, `requests`, optional `options`, and safe JSX `template`.
3. While drafting, use `customWidget_validateTemplate` for focused JSX diagnostics without resending the manifest.
4. Send the definition once to `customWidget_previewCreate` and test its queries/actions. For a JSX-only fix, validate, call `customWidget_previewReviseTemplate` with its session, and retest; it inherits the manifest and resets evidence. Create a preview only for source/request/option changes.
4. Send the definition once to `customWidget_previewCreate` and test its HTTP/native queries and simulated actions. For a JSX-only fix, validate, call `customWidget_previewReviseTemplate` with its session, and retest; it inherits the manifest and resets evidence. Other definition changes require a fresh preview.
5. Configure deployment-specific source URLs and credentials through Homarr; never repeat plaintext.
6. Persist each exact final tested preview with `customWidget_createFromPreview`. Do not resend a large definition through `customWidget_create` when a preview session is available.
Treat a supplied sample or successful preview response as the binding contract. Render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Humanize numeric enums with indexed literal label arrays, omit absent numeric values instead of inventing zero, and label timestamp timezones. Give recoverable load errors and empty states a clear refresh or retry path.
Treat sample and preview responses as binding contracts. Render requested fields, guard optional values, humanize enums, and label timezones. Give errors and empty states a retry path.
Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$param` for values supplied by `SubFetch`, `ActionButton`, or `ToggleSwitch`. Load queries cannot use invocation parameters. Render load queries from `data` and `status` with `RefreshButton`; reserve `SubFetch` for manual parameterized queries. Templates read `data`, `status`, `options`, and temporary `inputs`.
@@ -24,11 +24,7 @@ For a bound control that depends on another input, declare its default and set `
Plan capabilities. Use one `customWidget_findComponents` search per job. Batch selected non-obvious binding or interaction documentation with `customWidget_getComponents`; reserve `customWidget_getComponent` for one unknown-prop repair. Failed validation reopens discovery; otherwise search only for a missing capability. The full catalog is for broad exploration; load at most one example. Context never limits composition. Prefer clear hierarchy, responsive grids, divided lists, and aligned actions over nested row cards.
Do not simplify a useful workflow merely because the template is interpreted. Freely compose any supported installed components with multiple sources, requests, options, `choicesFrom`, bound filters, charts, responsive detail areas, manual queries, and safe actions when they improve the user's job. Complexity must remain purposeful rather than decorative.
Polish with a divided list or responsive media grid. Make one metric/action asymmetrically primary; keep headers compact, identity/state clear, metadata quiet, and one badge. At base artwork fills its row and caps above xs; never combine full-width media and nowrap.
Make initial states actionable and wrap variable labels/values on narrow tiles. Do not use an unlabeled decorative icon as an empty state.
Compose installed components freely around the requested workflow. Use responsive layouts, clear hierarchy, quiet metadata, and actionable initial states. Wrap variable labels on narrow tiles and label interactive icons.
Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session.
@@ -12,7 +12,7 @@ Templates read `data.requestId`, `status.requestId`, `options.name`, and tempora
</SubFetch>
```
Manual queries require `trigger: "manual"` on request and `SubFetch`; otherwise they run automatically. `triggerContent` with `triggerAriaLabel` makes custom content the launcher. `SubFetch` owns loading/error/retry; its child receives success plus `{ ok, status, statusText, loading: false }`. Never author `onClick` or fetch callbacks.
Manual queries require `trigger: "manual"` on request and `SubFetch`. Use `triggerContent` and `triggerAriaLabel` for a custom launcher. `SubFetch` owns loading/error/retry; its child receives success plus `{ ok, status, statusText, loading: false }`.
`SubFetch`, `ActionButton`, and `ToggleSwitch` need literal `requestId`; validation rejects missing/computed IDs.
@@ -35,3 +35,5 @@ Every stateful control must use `bind`, and its `inputs.<name>` value must feed
Callback parameters must not shadow the reserved roots `data`, `status`, `options`, or `inputs`. Use `<Icon name="refresh" />` or `<TablerIcon name="refresh" />`; never invent components such as `<IconFoo />`.
Use expression callbacks for supported collections and trusted slots. No callback blocks, IIFEs, authored recursion, or raw events. Regex is limited to safe string operations.
V3 adds scoped `className` styles, `View` fragments, `WidgetModal`, `WidgetDrawer`, and `AppEmbed`. Bound controls use `persist` for declared browser preferences or `content` for shared drafts saved with `ContentSaveButton`. `NativeQuery` and `NativeActionButton` invoke declared native IDs; actions retain preview simulation and integration permissions. Fetch component contracts before using these helpers.
@@ -13,7 +13,7 @@ interface HomarrCustomWidgetV2 {
}
```
The object key `default` is the required source ID; `default` is not a property on a source. Source properties are `name?`, `baseUrl`, `networkScope`, and `auth?`:
Sources are keyed by ID. Empty `sources` and `requests` support static widgets. Each HTTP request must reference an existing source; omitting its source selects `default`. Source properties are `name?`, `baseUrl`, `networkScope`, and `auth?`:
```json
{
@@ -47,3 +47,5 @@ Use stable real URLs for public APIs and clear suggested URLs for self-hosted se
Paths use `{option:name}` and `{param:name}`; query/body references use `{ "$option": "name" }` and `{ "$param": "name" }`. Constants stay primitive (`take: 10`); `$param` is only for manual helpers, never load queries. Names and types are inferred.
Every option has `label`, `control`, and `default`. Optional fields are `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, and `group`.
V3 uses `$schema: "homarr-custom-widget-v3"` and optional `extensions`: `stylesheet`, `fragments`, `preferences`, `content`, and `native`. Read `customWidget_schema` for their exact shapes. Integration options use `control: "integration"`, `default: ""`, and optional `integrationKinds`; installations choose local integrations. Discover native input schemas through `customWidget_nativeCapabilities`. Editor layout is separate from the manifest.
@@ -4,4 +4,4 @@ All requests use Homarr's protected server executor. Source origin, network scop
The JSX interpreter blocks imports, hooks, refs, raw event callbacks, browser requests, eval, arbitrary functions, prototype access, unsafe URLs, global CSS escape, arbitrary portals, bigint, statement blocks, IIFEs, and recursion. Regex literals must be bounded and reject backreferences, lookbehind, nested quantifiers, excessive length, and unsupported flags.
Credentials are stored separately and never exported or returned to an agent. A published self-hosted source URL is only a suggestion: installers must confirm or replace private and loopback URLs for their own Homarr deployment. Source origins cannot be controlled through widget options.
Credentials stay local and are never exported. Installers confirm or replace suggested private and loopback URLs for their deployment. Source origins are fixed by configuration.
+3 -2
View File
@@ -16,9 +16,10 @@ LOG_LEVEL='info'
# This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3'
# Safe local source-development path. SQLite files are ignored by Git.
# Set an absolute writable SQLite path so migrations and the app use the same file.
# Their working directories differ; do not use a relative path here.
# Container images use /appdata/db/db.sqlite by default instead.
DB_URL='./db.sqlite'
DB_URL=
# Those are the two ways to use the mysql2 driver:
# 1. Using the URL format:
+13
View File
@@ -25,6 +25,18 @@ body:
attributes:
label: Additional information
description: Add any other information related to the bug here, screenshots if applicable.
- type: input
id: exactBuild
attributes:
label: Exact build or image
description: For preview or development builds, include the commit SHA or image digest. A moving tag such as v2 is not enough to identify the build.
placeholder: "Commit SHA, image digest, or release version"
- type: input
id: lastWorkingVersion
attributes:
label: Last working version
description: If this worked before, include the last version or build where you tried the same action. Leave blank if unknown.
placeholder: "Version or commit SHA"
- type: dropdown
id: version
attributes:
@@ -33,6 +45,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.77.0
- 1.76.2
- 1.76.1
- 1.76.0
+10 -57
View File
@@ -97,17 +97,16 @@ jobs:
- name: Validate Custom Widget bundle budget
run: |
pnpm --filter nextjs build
pnpm turbo run build --filter=@homarr/nextjs
pnpm check:custom-widget-bundle
- name: Run unit tests
run: pnpm test
container:
name: Container and E2E
name: Container build
# Same-repository previews already build amd64; only build a fallback when no digest is available.
needs: [fast-gate, preview-amd64]
if: |
!cancelled() && needs.fast-gate.result == 'success' &&
needs.preview-amd64.result != 'failure' && needs.preview-amd64.outputs.digest == '' &&
(
github.event_name == 'merge_group' ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
@@ -115,10 +114,9 @@ jobs:
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
)
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 20
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
@@ -126,23 +124,10 @@ jobs:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: "!env.ACT"
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
uses: docker/setup-buildx-action@v4
- name: Reuse amd64 preview image
if: needs.preview-amd64.outputs.digest != '' && !env.ACT
env:
DIGEST: ${{ needs.preview-amd64.outputs.digest }}
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker pull "${PREVIEW_IMAGE}@${DIGEST}"
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e
- name: Build container once
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
uses: docker/build-push-action@v7
@@ -152,9 +137,7 @@ jobs:
platforms: linux/amd64
load: true
push: false
tags: |
homarr-ci
homarr-e2e
tags: homarr-ci
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
@@ -182,37 +165,7 @@ jobs:
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: "!env.ACT"
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run E2E tests
env:
NODE_ENV: development
CI: "true"
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
--tag homarr-ci "${secret_args[@]}" .
preview-amd64:
name: Build amd64 preview image
@@ -320,10 +273,10 @@ jobs:
publish-preview:
name: Publish multi-platform preview image
needs: [container, preview-amd64, preview-arm64]
needs: [fast-gate, preview-amd64, preview-arm64]
if: |
!cancelled() &&
needs.container.result == 'success' &&
needs.fast-gate.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
(
+19 -8
View File
@@ -16,10 +16,10 @@ permissions:
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
timeout-minutes: 2
timeout-minutes: 10
steps:
- name: Discord notification
if: ${{ github.event.inputs.send-notifications }}
if: ${{ inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
@@ -37,11 +37,22 @@ jobs:
branch: next
- name: Create pull request
id: create-pull-request
run: 'gh pr create --title "chore(release): release ${{ steps.semver.outputs.next }}-beta" --body "Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}-beta``" --base beta --head next --label automerge'
run: |
pr_url="$(gh pr list --base beta --head next --state open --json url --jq '.[0].url // empty')"
if [[ -z "$pr_url" ]]; then
pr_url="$(gh pr create \
--title "chore(release): release ${{ steps.semver.outputs.next }}-beta" \
--body "Detected change to be ${{ steps.semver.outputs.bump }}<br/>Bump version from ${{ steps.semver.outputs.current }} to ${{ steps.semver.outputs.next }}-beta" \
--base beta \
--head next \
--label automerge)"
fi
echo "url=$pr_url" >> "$GITHUB_OUTPUT"
echo "pr_number=${pr_url##*/}" >> "$GITHUB_OUTPUT"
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ github.event.inputs.send-notifications }}
if: ${{ inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
@@ -58,7 +69,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
run: |
gh pr review --approve --body "Automatically approved by GitHub Action"
gh pr review "${{ steps.create-pull-request.outputs.url }}" --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: actions/create-github-app-token@v3
@@ -82,21 +93,21 @@ jobs:
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
if: ${{ steps.semver.outputs.bump == 'major' && inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Discord Fail Notification
if: failure() && github.event.inputs.send-notifications
if: ${{ failure() && inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
+29 -12
View File
@@ -2,8 +2,36 @@ name: Workshop
on:
pull_request:
paths:
- ".github/workflows/workshop.yml"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/custom-widgets/**"
- "packages/definitions/**"
- "packages/workshop/**"
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".npmrc"
push:
branches: [dev, v2, release/v2, main]
paths:
- ".github/workflows/workshop.yml"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/custom-widgets/**"
- "packages/definitions/**"
- "packages/workshop/**"
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".npmrc"
permissions:
contents: read
@@ -15,21 +43,10 @@ concurrency:
jobs:
validate:
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24.18.0
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @homarr/workshop typecheck
- run: pnpm --filter @homarr/docs typecheck
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
- run: pnpm test:workshop
- run: pnpm test:workshop-image
publish-amd64:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
+13 -3
View File
@@ -59,7 +59,13 @@ homarr/
The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`).
When modifying user-facing code, you MUST also update the corresponding documentation:
Write for smart, curious, advanced users. Documentation should contain only information they cannot reasonably discover or understand directly from the interface: hidden capabilities, non-obvious behavior, prerequisites, constraints, configuration contracts, and migration requirements.
Keep docs concise. Every paragraph must add knowledge beyond visible labels and standard UI conventions. Explain how to discover and use a hidden advanced widget feature; omit explanations that the Delete button deletes an item in edit mode. Avoid UI walkthroughs, exhaustive control descriptions, and repetition. When editing a passage, remove obvious or redundant prose rather than preserving verbosity as a precedent.
Update docs only when a change introduces or alters that non-obvious information, or makes existing guidance inaccurate. Routine fixes and self-explanatory UI changes need no docs update. A new feature alone does not require a page; apply the same reader-value test.
When documentation is needed, use these locations:
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
@@ -86,7 +92,7 @@ When modifying user-facing code, you MUST also update the corresponding document
- Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Mantine: use the project-scoped MCP server in `.mcp.json` for current v9 APIs, and check `packages/ui/` before creating a new primitive.
- Mantine: use the `mantine` MCP server in `.mcp.json` for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox`, the polymorphic `component` prop, `@mantine/hooks`) and check `packages/ui/` for existing conventions first.
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
@@ -94,11 +100,15 @@ When modifying user-facing code, you MUST also update the corresponding document
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
## MCP servers
`.mcp.json` declares project-scoped MCP servers. Keep only keyless definitions there; credentials belong in local dotfiles. See [Mantine LLM tooling](https://mantine.dev/guides/llms/) for client-specific setup.
## Agent Skills
Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`.
- `codebase-context` — architecture, package boundaries, and shared utilities
- `documentation-sync` — required user-facing documentation updates
- `documentation-sync` — documentation for changes users need explained
- `mcp-integration` — safe tRPC-to-MCP exposure
- `homarr-custom-widget` — safe Custom JSX v2 authoring
+4
View File
@@ -1,3 +1,7 @@
## [1.77.1](https://github.com/homarr-labs/homarr/compare/v1.77.0...v1.77.1) (2026-09-11)
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
+6 -14
View File
@@ -15,19 +15,13 @@ Many of you used the v2 demo when it was still rough, found bugs and came back t
We are also planning a small batch of **free, limited-edition Homarr swag** for Workshop contributors and a few randomly picked community members. It is not for sale and it is not for profit. No Homarr merch empire, sorry.
Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is what actually changed.
Homarr 2.0 builds on the dashboard you already use. Here is what changed.
![A populated Homarr 2.0 dashboard with compact widgets, an Assistant, and an application rail](./img/homarr-v2-dashboard.webp)
<!-- truncate -->
:::warning Back up before upgrading
Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2) and create a
[Homarr backup](/docs/management/backup) first. Do not test a prerelease image on your only production volume.
Backups are boring. Restores are worse.
:::
Update your Homarr image as usual. Homarr runs database migrations automatically when the updated container starts; no manual database migration or separate v2 upgrade procedure is needed. Workshop uses the production service by default, with no preview URL configuration required.
## Custom Widgets v2 and Workshop
@@ -150,9 +144,8 @@ Groups use a new Viewer, Editor and Admin matrix. The API, Assistant and MCP fol
Board startup work runs in parallel, heavy screens load when opened, and identical integration requests share work instead of doing it twice.
## Before upgrading
## Compatibility notes
- Keep a backup and use the final release image, not a prerelease tag.
- Legacy Custom JSX definitions enter a migration state. Homarr keeps encrypted credentials and an archived copy until the v2 definition validates.
- Mobile layouts place rail content in the main flow instead of displaying rails.
- Custom Widgets remain Beta.
@@ -160,8 +153,7 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
## Links
- Try the [Homarr 2.0 preview](https://app-v2.preview.homarr.dev).
- Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2).
- Follow the [v2 release pull request](https://github.com/homarr-labs/homarr/pull/6545) and [public beta thread](https://github.com/homarr-labs/homarr/issues/6600).
- Compare [v1.76.2 with the release/v2 branch](https://github.com/homarr-labs/homarr/compare/v1.76.2...release/v2).
- Try the [live demo](https://demo.homarr.dev/).
- See [installation and update instructions](/docs/getting-started/installation/docker).
- Browse [Workshop](https://homarr.dev/workshop).
- Read the guides for [Boards](/docs/management/boards), [Custom Widgets](/docs/management/custom-widgets), [Assistant](/docs/management/assistant), [MCP](/docs/management/mcp) and [Docker labels](/docs/integrations/docker-labels).
@@ -90,7 +90,7 @@ homarr run dev
homarr run --detach dev
homarr run --pr 6441
homarr run --pr 6441 --demo
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://workshop.example.com/workshop
```
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
@@ -31,9 +31,13 @@ pnpm install --frozen-lockfile
### Run the Homarr application
1. Copy `.env.example` to `.env`.
2. Set `DB_URL` to an absolute writable SQLite file path.
3. Keep Redis and the optional local databases running in one terminal.
4. Apply migrations and start Next.js in another terminal.
2. Run `openssl rand -hex 32` twice. Set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
values in `.env`; the example leaves both empty. Keep this file private and preserve `SECRET_ENCRYPTION_KEY`
when reusing a database.
3. Set `DB_URL` to an absolute writable SQLite file path. Migration and application commands run from different
workspace directories, so a relative path can point them at different databases.
4. Keep Redis and the optional local databases running in one terminal.
5. Apply migrations and start Next.js in another terminal.
```sh
cp .env.example .env
@@ -57,6 +61,20 @@ Homarr becomes the instance administrator.
Use `pnpm docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the
default data; use `pnpm db:seed` to seed it explicitly later.
`pnpm dev` starts only Next.js. When your work needs scheduled tasks or live subscriptions, run the relevant service
in a separate terminal alongside it:
```sh
pnpm --filter @homarr/tasks dev
```
```sh
pnpm --filter @homarr/websocket dev
```
The WebSocket service listens on port 3001. Production startup embeds both services, so these extra commands are
only needed for development.
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
[Workshop development guide](./workshop).
@@ -80,5 +98,27 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
- `pnpm typecheck` — typecheck all workspaces.
- `pnpm test` — unit and contract suites.
- `pnpm build` — production monorepo build.
- `docker build -t homarr .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
- `docker build -t homarr:local .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:local` — run it.
## CI and focused validation
The main CI workflow runs lint, typechecking, affected workspace builds, workspace configuration checks, OpenAPI validation, and Custom Widget architecture and bundle checks. Unit and E2E suites do not run in CI. Preview images publish after the Fast gate and both architecture builds succeed; browser tests do not block publication. The container job builds an amd64 image when no preview image was built.
Workshop CI runs only when its workflow or Docker build inputs change. It validates Compose configuration and publishes production images on its configured release branches. Its Docker integration and image tests are manual; workspace typechecks run in the main Fast gate.
Choose the smallest relevant suite when changing behavior:
| Command | Scope | Requirements |
| ------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
| `pnpm test <file>` | Focused unit or contract tests | Workspace dependencies |
| `pnpm test` | All unit and contract tests, without Docker integration suites | Workspace dependencies |
| `pnpm test:coverage` | Unit and contract tests with coverage | Workspace dependencies |
| `pnpm test:integration <file>` | Docker-backed service and database compatibility tests | Docker |
| `pnpm test:e2e <file>` | Browser and container scenarios | Docker; Playwright Chromium and system dependencies for browser tests |
| `pnpm test:docs-screenshots` | Regenerate Assistant documentation screenshots | Same browser prerequisites |
| `pnpm test:workshop` / `pnpm test:workshop-image` | Workshop integration / production-image validation | Docker |
Omit `<file>` to run all tests in that command's scope. E2E and screenshot commands use a local image tagged `homarr-e2e`, or an image selected with `HOMARR_E2E_IMAGE`. The default commands run once without automatic retries; coverage is opt-in. The screenshot generator is excluded from both unit and E2E commands.
Keep behavioral coverage for authorization, migrations, persistence, and widget execution. Avoid tests that only search source code for particular component names, JSX, CSS, or documentation wording.
@@ -13,7 +13,8 @@ You can change the user and group by using the `PUID` and `PGID` environment var
Important to note is, that you'll also need to change the permissions for the mounted directories accordingly.
:::info
When starting up an updated version of the container it will have about **10 seconds longer to start** as it has to change the permissions of the app directories
At startup, Homarr updates ownership of its data directories and runtime caches for the selected user and group.
Startup time depends on the amount of persistent data; application code and dependencies do not need ownership changes.
:::
## Without mounted Docker socket
@@ -24,6 +24,10 @@ see [Managing integrations](/docs/management/integrations#configuration).
### Update
Update the image and recreate the container with the same configuration. Homarr runs database migrations automatically
at startup, including when moving from v1 to v2; no separate upgrade procedure is needed. Keep the `/appdata` mount and
`SECRET_ENCRYPTION_KEY` unchanged. Workshop connects to the production service by default.
```sh
docker compose pull
docker compose up -d
@@ -21,8 +21,8 @@ This method is only useful, when you want to develop on Homarr or extend it with
## Prerequisites
- [Node.js](https://nodejs.org/en/download/)
- [pnpm](https://pnpm.io/installation)
- [Node.js](https://nodejs.org/en/download/) 24.18 or newer
- [pnpm](https://pnpm.io/installation) at the version pinned in the repository's `package.json`
- [Redis](https://redis.io/download)
## Steps
@@ -38,9 +38,9 @@ This method is only useful, when you want to develop on Homarr or extend it with
Install all dependencies using <code>pnpm install</code>
</li>
<li>
Copy the `.env.example` file to `.env`. Its `DB_URL='./db.sqlite'` value is ready for local use and stores the
database in the cloned repository. Replace it with an absolute writable path such as
`DB_URL='/home/username/homarr/db.sqlite'` if you want to store the database elsewhere.
Copy `.env.example` to `.env` and set `DB_URL` to an absolute writable path, such as
`DB_URL='/home/username/homarr/db.sqlite'`. Migration and application commands run from different workspace
directories, so a relative path can point them at different databases.
</li>
<li>
Run `openssl rand -hex 32` twice and set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
@@ -1,74 +0,0 @@
---
title: Upgrade from v1 to v2
sidebar_position: 2
tags:
- Getting started
- Upgrade
- Migration
- Backup
---
Homarr v2 upgrades the existing v1 database in place. Keep the same data storage, database configuration, and
`SECRET_ENCRYPTION_KEY`; only change the Homarr image or package version for the first start.
:::danger Back up before starting v2
In Homarr v1, go to **Management → Tools → Backup**, export a backup, and keep the downloaded file somewhere safe
before starting v2. See [Backup and restore](/docs/management/backup) for details.
:::
## Upgrade
1. Pin the Homarr image to `ghcr.io/homarr-labs/homarr:v2.0.0` for the first v2 start.
2. Keep the existing `/appdata` mount, database variables, Redis configuration, and `SECRET_ENCRYPTION_KEY` unchanged.
3. Start one Homarr replica and follow its logs. Startup must reach `Migration complete`; Homarr aborts startup when a
database migration fails.
4. Open Homarr and sign in. Confirm **Management → About** reports v2 before starting additional replicas.
5. After validation, keep the version pinned or return to your normal stable release policy.
For Docker Compose, the change is limited to the image tag before the usual update:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr:v2.0.0
```
```sh
docker compose pull
docker compose up -d
docker compose logs --follow homarr
```
Do not add `-v` when stopping the Compose deployment; that can remove named volumes.
## What v2 migrates
The startup migration:
- creates the Custom Widget v2, Assistant, header preference, and branding storage;
- adds Base/Mobile layout roles and left/right board gutters;
- converts legacy Categories and dynamic sections into Containers and rails;
- preserves legacy Custom Widget definitions, encrypted secrets, and board references for explicit migration.
Legacy Custom Widgets are not silently rewritten. In **Management → Custom Widgets**, entries marked
**Migration required** provide a credential-free migration prompt. Review and test the resulting v2 definition before
removing the archived v1 version. See [Custom Widgets: Import and Workshop](/docs/management/custom-widgets#import-and-workshop).
## Verify the upgraded instance
- Sign in with each enabled authentication method and verify administrator access.
- Open every important board on desktop and mobile. Check Containers, former Categories, rails, item placement, and
home-board assignments.
- Test at least one read and one permitted action for each critical integration. Recheck integration and board access
for representative Viewer, Editor, and Admin groups.
- Check Docker or Podman endpoints, search engines, uploaded media, trusted certificates, scheduled tasks, WebSockets,
and external Redis when used.
- Review every legacy Custom Widget and migrate it only after its v2 preview and requests work.
- Restart Homarr once more and confirm the logs contain no migration, database, Redis, authentication, or task errors.
## Roll back
Use the backup exported before the upgrade if you need to restore your v1 installation. Do not reuse a database that
v2 has already migrated with v1.
@@ -28,6 +28,8 @@ import { smartHomeExecuteAutomationWidget } from "@site/docs/widgets/smart-home-
]}
/>
Entity toggles and automation triggers send JSON requests to Home Assistant. A rejected action is shown as a failure.
### Adding the integration
<AddingIntegration />
@@ -0,0 +1,55 @@
---
title: "llama.cpp"
description: "Connect Homarr to a llama.cpp llama-server to monitor your local LLM: health, loaded model, generation speed and request status."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { llamacppIntegration } from ".";
import { llamacppWidget } from "@site/docs/widgets/llama-cpp";
<IntegrationHeader integration={llamacppIntegration} categories={["System Monitoring"]} />
The llama.cpp integration lets Homarr connect to a [llama-server](https://github.com/ggml-org/llama.cpp) instance so you can see the health of your local LLM, which model is loaded, and how fast it is generating — all from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: llamacppWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your `llama-server`, for example `http://192.168.1.50:8080`. Homarr calls the `/health`, `/v1/models`, `/metrics` and `/slots` endpoints under that URL automatically.
:::
:::tip Metrics
Generation speed and token counters are read from the Prometheus endpoint at `/metrics`. Start `llama-server` with the `--metrics` flag so these values are available:
```shell
llama-server --model /path/to/model.gguf --metrics
```
Without `--metrics` the widget still shows health, model information, and active-request speed from `/slots`; metrics-backed counters and aggregate speed stay empty.
:::
:::info Context and per-request speed
Context (KV cache) usage and the in-flight request data are read from the `/slots` endpoint, which is available on every recent llama-server build and requires no extra flags. The per-request generation speed shown while a request is running is derived by tracking the request's decoded token count across polls, so it reflects the average speed of the request that is currently being generated.
:::
### Secrets
llama-server does not require authentication by default. The integration only needs the URL of your server.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
]}
/>
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const llamacppIntegration: IntegrationDefinition = {
name: "llama.cpp",
description:
"Connect to a local llama.cpp llama-server to show model, throughput and request status on your dashboard.",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
path: "../../integrations/llama-cpp",
};
@@ -30,6 +30,8 @@ import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
]}
/>
Disk temperatures are matched to filesystem partitions, including device names without the `/dev/` prefix.
### Adding the integration
<AddingIntegration />
@@ -19,6 +19,9 @@ import Alert from "@theme/Admonition";
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
Disk space uses each pool's root dataset to account for RAIDZ parity and dataset limits. If dataset space is unavailable,
Homarr falls back to physical pool space, which reads higher on RAIDZ, and keeps reporting pool health.
### Adding the integration
<AddingIntegration />
@@ -23,6 +23,8 @@ import imgSelectApikeyPreset from "./imgs/unraid-select-api-key-preset.png";
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
CPU usage is averaged across logical processors, including SMT threads.
### Adding the integration
<AddingIntegration />
@@ -24,12 +24,14 @@ The What's Up Docker integration lets Homarr connect to your [WUD](https://githu
<AddingIntegration />
:::info URL configuration
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr uses `/api/containers` under that URL for both connection testing and widget data.
:::
### Secrets
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
WUD 9 requires authentication. Enter your WUD username and password in Homarr; the initial admin credentials configured with `WUD_AUTH_ADMIN_USER` and `WUD_AUTH_ADMIN_PASSWORD` work with Basic authentication. Older WUD versions without authentication can leave both fields empty.
Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside a trusted internal network.
<IntegrationSecrets
secrets={[
@@ -40,7 +42,7 @@ WUD does not require authentication by default. If your instance is protected wi
{
credentials: ["username", "password"],
steps: [
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Use your WUD account credentials; older versions can use a configured WUD_AUTH_BASIC_* user",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
],
},
+31 -5
View File
@@ -27,6 +27,12 @@ session cookie.
API keys do not expire automatically and act as the user that created them. Every request uses that user's group and
resource permissions.
## Invitation dates
`POST /api/invites` and the tRPC `invite.createInvite` procedure accept `expirationDate` as an ISO 8601 timestamp with a
timezone, for example `2026-12-01T18:00:00Z`. Homarr validates the timestamp and stores it as a date. tRPC callers should
send the ISO string rather than a JavaScript `Date` object.
## Health probes
The liveness endpoint does not require authentication:
@@ -62,11 +68,6 @@ Each board has exactly one Mobile and one Base layout. Mobile uses breakpoint `0
layouts can use any other breakpoint, and Homarr selects the highest breakpoint that fits the viewport. Preserve layout
roles and use the canonical layouts returned by save operations, including generated IDs.
Board settings expose `fixedScaling` and `fixedItemSize`. The default fitted mode uses `fixedScaling: false`; setting it
to `true` keeps each `1 × 1` tile at `fixedItemSize` pixels, which must be an integer from `40` through `200`. The
configured size is retained while fixed scaling is disabled. Layout `columnCount` remains the maximum total column count,
including fixed sidebars.
Creating or changing an integration-backed widget requires use access to each newly selected integration.
## Docker targets
@@ -97,3 +98,28 @@ and focused references under `/api/custom-widgets/`.
Use [Custom Widget agent authoring](../custom-widgets/agent-authoring) for the lifecycle and
[MCP](../mcp) when the client supports Model Context Protocol.
Workshop installation through `customWidget.workshopInstall` and the in-app catalog uses the same server path. Supply
`expectedRevision` from `customWidget.workshopGet` to install only the package you reviewed. Source URL overrides and
credentials are local; the installed definition retains its Workshop provenance.
For native capabilities, pass local integration selections as `integrations: { "optionId": "integrationId" }`; portable
defaults do not carry another Homarr instance's integration IDs.
When renaming a saved source through `customWidget.update` or `customWidget.previewCreate`, include
`sourceRenames: { "previousId": "newId" }` to preserve its credentials. The server checks the original and resulting
source bindings; changed origin, network scope, or authentication requires re-entered credentials. A rename moves
encrypted credentials transactionally and does not copy them into the portable definition.
For a linked installation, `customWidget.workshopUpdateReview` returns the current and incoming definitions, source-binding
changes, local-modification status, and review tokens. Pass its `expectedFingerprint`, `expectedState`,
`upstreamFingerprint`, and `revision` unchanged to `customWidget.workshopUpdateInstall`. Both procedures require admin
access and are exposed through MCP with an underscore replacing the dot. An intervening edit or upstream revision
returns `CONFLICT`; fetch a fresh review instead of retrying the old update. Source/authentication changes and local
customizations require a separate installation.
Shared widget content uses `customWidget.contentRead({ itemId })` and
`customWidget.contentWrite({ itemId, name, expectedRevision, value })`. Reads require access to the board; writes require
authentication and the declaration's `modify` or `full` board permission. Each value has an independent revision; `0`
means the declared default has not been saved. A stale revision returns `CONFLICT`; read again before saving. Content is
scoped to the placed item and its selected definition. Workbench previews keep content changes in memory and cannot
persist them through these procedures. These procedures are not exposed as MCP tools.
+17 -38
View File
@@ -27,27 +27,27 @@ Mobile and Base are protected layouts. New boards use three Mobile columns; Base
creation. Additional layouts use unique breakpoints. Homarr selects the layout with the highest breakpoint that fits
the current viewport.
By default, Homarr fits the complete fixed-proportion tile grid to the available width. Enable **Fixed scaling** in the
board's Layout settings to render each top-level `1 × 1` tile at the configured CSS-pixel size instead. A fixed dashboard is
centred when it fits and scrolls both horizontally and vertically when it does not, without adding extra space above
the board.
The column count is the maximum total for that layout, including any fixed sidebars. For example, a Base layout with
five columns and no sidebars remains a centred five-column dashboard on an ultrawide screen. **Reset from Base**
regenerates a Mobile or custom layout from the current Base layout.
Tiles use fixed `200 × 200` logical-pixel cells with a `24px` gap. Homarr scales the complete layout to the available
width instead of changing each widget's proportions. **Reset from Base** regenerates a Mobile or custom layout from the
current Base layout.
### Containers and rails
A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action,
border color, and CSS classes.
Items can occupy the outermost rows and columns of a Container. The drop preview shows the position where the item
will remain when released.
Dragging an item onto another item of the same size swaps their positions. Displaced items snap directly to their
previewed positions without a movement animation and stay there when you release the pointer.
A **rail** reserves one to three columns at the left or right of a non-Mobile layout. Rails stay fixed while the page
scrolls and store their contents separately for each layout.
## Edit a board
Enter edit mode from the board header or account menu to add, move, resize, configure, or remove content. The account
menu also works when the header is hidden, using the floating user button. Choose **Save board** to persist changes.
Enter edit mode from the board header to add, move, resize, configure, or remove content. Save the board to persist the
layout.
- Drag from an item's inactive content area; Containers use their grip.
- Hold `Ctrl` on Windows/Linux or `Cmd` on macOS to select several items.
@@ -56,40 +56,19 @@ menu also works when the header is hidden, using the floating user button. Choos
- Press Escape during a pointer or touch interaction to restore the item to its previous position.
The add-content menu can create an app, integration, widget, or Container without leaving the board.
Right-click empty board space to open this menu at the cursor. Selecting an action enters edit mode automatically.
These actions require board editing permission; creating integrations also requires integration creation permission.
Right-clicking existing content keeps its own menu. Hold Shift while right-clicking to use the browser menu.
When adding apps, click one app to add it immediately, or choose **Select multiple** to add several at once. `Ctrl`/`Cmd`
click remains available as a shortcut.
## Board settings
Settings are organized into tabs, with one **Save changes** action for the whole board. Switching tabs retains your
draft; **Discard** restores the saved settings.
Board settings cover:
- **Layout:** tile sizing, responsive layouts, breakpoints, maximum columns, and rails.
- **Appearance:** colors, opacity, icon color, item radius, and background image settings beside a live layout preview.
- **General:** page title, browser title, logo, favicon, and app status.
- **Advanced:** custom CSS applied only to this board.
- **Access control:** permissions, visibility, renaming, and deletion (requires full access).
### Try a layout before saving
Choose **Fixed scaling** or **Fit to screen** under **Tile sizing**. Fixed sizing accepts **40–200 px**; its square
sample is shown at half size with the configured dimensions underneath. The size is retained when you switch modes.
Select a layout to edit its columns, breakpoint, and side rails beside the compact **Board preview**. It uses the
familiar Boards thumbnail, with the board's actual grid proportions and rails aligned beside the main canvas.
The indicated screen width represents the selected layout. The miniature stays contained, centres fixed grids that
fit, and scrolls when needed. Changes to sizing and appearance appear immediately, before saving.
Choose **Item radius** using the same visual corner-style picker as server branding. Selecting a card updates the
board preview immediately; save the board settings to apply it.
In fixed mode, browser `Cmd`/`Ctrl` + `+` or `-` zoom works naturally: a 200 × 200 CSS-pixel tile keeps those dimensions
while its physical screen size changes with zoom. Homarr does not shrink the tile to cancel the browser's zoom.
Your live board can still select a different saved layout when its viewport crosses a breakpoint. Nested Containers
fit their inner grids into their own frames; the configured tile size describes the top-level board grid.
- **General:** page title, browser title, logo, and favicon.
- **Layout:** responsive layouts, breakpoints, columns, and rails.
- **Background:** image, attachment, size, and repeat behavior.
- **Appearance:** colors, opacity, icon color, and item radius.
- **Custom CSS:** styles applied only to the current board.
- **Behaviour:** app status and widget context-menu behavior.
See [Styling](/docs/advanced/styling) for board and global CSS.
@@ -64,3 +64,8 @@ Below you can find instructions for some of the integrations.
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
### Proxmox
For the default Proxmox VE cluster certificates, trust the cluster CA from `/etc/pve/pve-root-ca.pem` on any node.
See [Proxmox certificate management](https://pve.proxmox.com/wiki/Certificate_Management).
@@ -26,7 +26,7 @@ Equivalent HTTP resources are available under `/api/custom-widgets/` for authent
## Authoring lifecycle
1. Load the skill and only the references needed for the design.
2. Read the external API documentation and build the definition.
2. Read the external API documentation or discover Homarr capabilities with `customWidget_nativeCapabilities`, then build the definition.
3. Validate the JSX, then create a preview from the complete definition.
4. Run every returned query and simulate every relevant action against the current preview revision.
5. Inspect the preview and redacted journal. Revise the existing preview for JSX-only changes.
@@ -34,7 +34,9 @@ Equivalent HTTP resources are available under `/api/custom-widgets/` for authent
7. Configure deployment-specific source URLs and credentials, then preview the saved definition again.
Use `templateLines` for multiline JSX tool input. A preview must have current evidence for every query and action before
it can be saved. Simulation counts as action evidence without performing the mutation.
it can be saved. Simulation counts as action evidence without performing the mutation. V3 native queries and actions use
`customWidget_nativeQuery` and `customWidget_nativeAction` with the owned preview session ID; their evidence is recorded
under `native:<id>`. Native capabilities must also have current evidence before preview-based persistence.
If the agent does not have a source URL or credential, it can request a short-lived Homarr configuration URL. An
authenticated administrator completes it in Homarr; the agent receives the status, never the plaintext credential.
@@ -43,3 +45,10 @@ Agents can also search, inspect, install, and configure existing Workshop widget
See [MCP](/docs/management/mcp) for client configuration and [requests and security](./requests-and-security) for the
runtime limits.
## Editing an open workbench
Homarr's built-in Assistant can use `workbench_read`, `workbench_patch`, and `workbench_preview` to change the visible draft.
Read before patching, supply the returned draft ID and revision, and describe one coherent change. Credentials are excluded from these
tools. The workbench validates the resulting definition and rejects stale patches; one Undo reverses an accepted patch.
Browser draft tools require an open workbench and do not grant permission to save, publish, or run live actions.
@@ -1,29 +1,67 @@
# Create a Custom Widget
The workbench provides editors for general metadata, API sources, requests, widget options, JSX, and preview data. Raw
JSON is available when direct editing is faster.
The definition remains the source of truth in the workbench. Existing widgets open without converting or rewriting their
JSX. Visual changes use targeted source edits; complex expressions remain editable in code.
## Workflow
## Definition and layout
1. Configure each API source, network scope, authentication method, and credential.
2. Add named queries and actions. Values can reference widget options or invocation parameters.
3. Define widget options and their defaults.
4. Write JSX using `data`, `status`, `options`, and temporary `inputs`.
5. Run **Test and preview**, inspect the response data and redacted request journal, then save.
A static widget needs only a JSX template: set `sources` and `requests` to `{}`. It makes no service request.
Changing the definition, options, or credentials invalidates the previous preview. Preview actions are simulated unless
**Run actions against the configured API** is enabled for that preview session.
HTTP requests run on the Homarr server. Native capabilities use Homarr's installed integrations and existing
permissions. Embeds open in the viewer's browser and need a URL reachable from that browser.
Widget options support text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, URL,
duration, time zone, and JSON controls. Dynamic choices can come from a load query.
Node positions and groups are stored separately from the runtime definition. Groups do not change execution. Source
associations, data references, action invocations, and cache invalidations are distinct relationships; an invalidation edge
does not create an execution dependency. Referenced objects cannot be removed until their dependants are resolved.
See the [safe JSX runtime](./custom-jsx), [component reference](./component-reference), and
[request reference](./requests-and-security) for the authoring contract.
## Visual bindings
## Create with another AI service
Connecting a query, option, or action to the Widget opens a binding configuration. Queries can insert text, JSON, lists,
or tables; request parameters can use options, temporary controls, or fixed values. Connecting options to a request changes
its parameter bindings. These edits do not run queries or actions.
**Create or fix with any AI** copies a self-contained prompt for an ordinary ChatGPT or Claude conversation. The model
must return one fenced `json` block containing the complete definition. Paste that block into the workbench, add
credentials separately, and validate it against a real preview.
Generated JSX includes ownership comments. The canvas can disconnect or replace unchanged generated bindings as one Undo
operation. Editing a generated block or mixing it with authored references makes the relationship code-owned; resolve it
in JSX to preserve your changes. Removing ownership comments also removes visual ownership, without changing rendering.
For an MCP-connected agent, use the [Homarr Custom Widget skill](./agent-authoring) instead.
## Preview evidence
Moving nodes, zooming, and opening inspectors do not execute requests or invalidate the preview. Editing the definition,
options, or credentials marks execution evidence stale. Valid JSX renders against the last data; invalid JSX leaves the
last valid preview visible with a stale label. Queries run deliberately, not on every keystroke.
Preview actions simulate by default. Enabling **Run actions against the configured API** applies only to that preview
session. Native actions also require declared confirmation and their normal integration permissions. Undo changes the draft;
it never undoes or repeats a service action.
## Undo and draft recovery
Undo/Redo covers code, structured edits, renames, node movement, and Assistant patches. Typing is grouped and a drag or
coherent Assistant change is one operation. The bounded history excludes credentials, responses, selection, and viewport
movement. **Reset to saved** restores the saved definition and layout. Saving from an outdated workbench tab reports a
conflict rather than overwriting a newer save.
Temporary test parameters and unfinished request-body JSON keep local input Undo, so editing them does not undo an unrelated
definition change. Credential fields also retain their own input history.
Optional browser draft recovery keeps a credential-free draft and editor layout. Recovery is local to the browser and
requires a valid recoverable configuration. It is not a revision archive or a backup of service data.
## Assistant changes
The Assistant reads the visible draft and selection, edits through shared draft commands, and can run preview queries. A stale patch is
rejected when the draft has changed. Saving, live service actions, and Workshop publication remain separate operations.
Form view retains **Create or fix with any AI** for external AI services. Configure credentials locally after importing
the returned definition and validate the result.
## Advanced capabilities
Native capabilities, scoped CSS, reusable view fragments, typed preferences, and shared content use
`homarr-custom-widget-v3`; ordinary HTTP and static widgets remain valid v2 definitions. Native input bindings must match
the installed capability's schema. See the [runtime](./custom-jsx) for the expression language and extension contracts.
V3 adds an integration picker with optional `integrationKinds`. The integration list respects the
current user's access. Portable exports clear local integration selections so installers choose their own services.
See the [runtime](./custom-jsx), [component reference](./component-reference),
[native capabilities](./native-capabilities), and [request reference](./requests-and-security).
@@ -7,7 +7,7 @@ Templates are JSX expressions interpreted inside a widget-local safety and rende
- `options.name` — saved widget configuration
- `inputs.name` — temporary values from declarative `bind`
The v2 manifest requires a `default` source and supports up to eight sources, 64 requests, and 64 options. Source IDs are
Manifests support up to eight sources, 64 requests, and 64 options. A static widget can use empty `sources` and `requests`; every HTTP request must name an existing source. Source IDs are
case-insensitively unique. Each request allows up to 32 static headers and 32 invalidation targets; a widget can contain
up to 24 secrets, each up to 8,192 characters, and templates are limited to 50,000 characters. Validation reports the
exact field when a limit is exceeded.
@@ -41,7 +41,7 @@ before it reaches `data`, so treat it as untrusted input even when the source is
</Stack>
```
Bound values live only in React memory while the widget is mounted. They are not saved to the database, board options, exports, browser storage, or `localStorage`. Remounting resets them. Components can share a binding when their adapter types agree.
Without an explicit v3 `persist` binding, bound values live only in React memory while the widget is mounted. They are not saved to the database, board options, exports, browser storage, or `localStorage`. Remounting resets them. Components can share a binding when their adapter types agree.
Bindable controls accept a scalar `resetKey`. When it changes, Homarr restores that control to its declared `defaultValue` or `defaultChecked` without running a request. This keeps dependent pagination correct while a manual `SubFetch` waits for the user to submit the new query.
@@ -74,3 +74,38 @@ than one variable quantifier are rejected.
- `Icon`/`TablerIcon` resolves installed safe icons.
Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile.
## Version 3 capabilities
Use `$schema: "homarr-custom-widget-v3"` for `extensions`, integration options, scoped classes, and the components below. Version 2 definitions continue to render; opening them in the workbench does not upgrade their runtime schema. Editor positions are Homarr metadata and are excluded from the portable definition.
`extensions.stylesheet` accepts up to 30,000 characters. `className` references local classes; selectors and keyframe names are scoped per widget instance, including content opened through `WidgetModal` and `WidgetDrawer`. Use flat rules, `@media`, `@supports`, `@container`, and local `@keyframes`. Resource-loading CSS (`url()`, imports, fonts), global registration rules, and unresolved animation names are rejected. Theme CSS variables remain available. Inline style restrictions still apply.
Templates can read `theme.colorScheme`, `theme.primaryColor`, `theme.colors`, `theme.spacing`, `theme.radius`, and `theme.fontFamily`. `container.width` and `container.height` report the widget content size in CSS pixels, so responsive composition can follow its tile rather than the browser viewport.
Declare reusable JSX in `extensions.fragments` and render it with `<View name="metric" values={{ label: "CPU" }} />`. The fragment reads `props.label` and inherits the normal data bindings. Fragment names are literal, recursion is rejected, and all expansions share the interpreter's existing operation budget. Up to 32 fragments and 100,000 combined JSX characters are supported.
```json
{
"preferences": {
"filter": { "type": "string", "defaultValue": "" }
}
}
```
The fragment above belongs under `extensions`. A matching `<TextInput bind="filter" persist="filter" label="Filter" />` exposes the value through both `inputs.filter` and `preferences.filter`. Preferences are typed, scoped to the board item, and saved only in that browser; another account or device gets its own values. Preview preferences remain in memory. Exports include the declarations and defaults, never the user's saved values. Storage-disabled browsers retain an in-memory fallback. `resetKey` resets a persisted control to its declared preference default.
`WidgetModal` and `WidgetDrawer` require `title` and `triggerLabel`; Homarr owns their open/close behavior, focus handling, and portal boundary. They allow detailed views without authored callbacks.
Supported selects, menus, tooltips, and popovers use Homarr-owned dropdown targets to escape tile clipping while preserving
widget styles and the preview theme. Nested dropdowns stay within their parent overlay. Authored event handlers, refs, and
portal targets remain blocked; Mantine's own trigger handlers work through the component adapters.
`AppEmbed` requires an absolute HTTP(S) `src` and an accessible `title`. Its URL is reached by the browser, whereas HTTP sources are reached by the Homarr server; a Docker service hostname usually cannot be used for both. The embedded service must permit framing. The iframe uses an isolated origin and restricts navigation; apps requiring same-origin storage or unrestricted browser capabilities need their normal external link instead. `allowForms` explicitly enables embedded form submission.
`extensions.native` maps local IDs to curated Homarr capabilities. Integration-backed entries reference a saved `integration` option through `integrationOption`; the server applies the existing integration and board permissions. `<NativeQuery nativeId="systems">{(systems, meta) => <Text>{systems.length}</Text>}</NativeQuery>` uses the same loading/error behavior as `SubFetch`. Use `trigger="manual"` for invocation parameters and `live` for a supported subscription. Automatic results populate `data` and `status` under their native ID; manual results remain local to the component.
Native action entries require `kind: "action"`, `trigger: "manual"`, a `modify` or `full` permission, and a `confirmation` object containing `title` and `message`. Invoke them through `NativeActionButton`. Preview actions use the existing simulation mode; declaring an action does not authorize a live mutation. Native capability IDs and HTTP request IDs must be distinct.
The [Beszel systems studio definition](/custom-widgets/beszel-systems-studio-v3.json) combines systems, configurable columns, a saved system selection, historical charts, and live detail views. Select a local Beszel integration after importing it. The example uses `preferences` directly for values needed before a control mounts; `inputs` appears only after its bound control mounts. Live failures keep the last received event and expose `meta.stale` alongside Retry.
Shared editable content uses a separate `extensions.content` declaration with the same types/defaults as preferences plus `permission: "modify"` or `"full"`. `<Textarea bind="note" content="note" />` edits a local draft. `<ContentSaveButton name="note">Save note</ContentSaveButton>` commits it to the board item; `<ContentResetButton name="note">Reload note</ContentResetButton>` discards the draft and reloads the current shared value. Templates read `content.note` and `contentStatus.note` (`loading`, `dirty`, `saving`, `error`). Saves use the last read revision, so a stale draft cannot silently replace another saved value. Preview saves stay in memory; exports include declarations/defaults but exclude saved board content.
@@ -15,8 +15,9 @@ Custom Widgets may change during the v2 release cycle. Share feedback in the
:::
A **Custom Widget** is a validated `homarr-custom-widget-v2` definition containing API sources, queries and actions,
optional settings, and a safe JSX template.
A **Custom Widget** is a validated definition with a safe JSX template, optional settings, and optional HTTP sources,
queries and actions. Existing definitions use `homarr-custom-widget-v2`; advanced composition and native capabilities use
`homarr-custom-widget-v3`. Static widgets need no API source.
Homarr executes requests on the server and renders the template in a restricted runtime. Source credentials are
encrypted separately and are not included in exports, prompts, browser data, or Workshop submissions.
@@ -49,7 +50,7 @@ and simulate relevant actions before saving. Live preview actions are opt-in bec
## Import and Workshop
Use **Import from Workshop** while adding board content or import a v2 JSON definition directly. Review the source,
Use **Import from Workshop** while adding board content or import a supported v2 or v3 JSON definition directly. Review the source,
requested permissions, API hosts, and actions before installation, then configure deployment-specific URLs and
credentials in Homarr.
@@ -57,5 +58,5 @@ Legacy definitions show **Migration required**. The migration prompt excludes cr
describe the visible behavior separately, import the returned v2 definition, and verify it before removing the archived
version.
Back up the database and `SECRET_ENCRYPTION_KEY` before upgrading or rolling back. Multi-replica installations must use
the same Redis service so cached responses invalidate consistently.
Keep `SECRET_ENCRYPTION_KEY` unchanged when updating Homarr so existing credentials remain readable. Multi-replica
installations must use the same Redis service so cached responses invalidate consistently.
@@ -0,0 +1,98 @@
# Native capabilities
V3 Custom Widgets can call a curated set of Homarr capabilities through `NativeQuery` and `NativeActionButton`.
These use the same integration procedures, caches, partial-failure responses, and permissions as their native counterparts.
A definition declares each capability explicitly; JSX cannot call arbitrary tRPC procedures.
## Available capabilities
| Capability | Behavior | Required input |
| ----------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `beszel.systems` | Systems overview, status, CPU, memory, disk, network and other reported metrics | None |
| `beszel.history` | Historical system and optional container statistics | `systemId`; optional `timePeriod`, `includeDocker` |
| `beszel.alerts` | Alerts and optional history | Optional `includeHistory`, `maxHistoryItems` |
| `beszel.live` | Initial short history or a live statistics stream | `systemId` |
| `calendar.events` | Calendar events from supported integrations | `year`, `month`; optional `releaseType`, `showUnmonitored` |
| `media.requests` | Media requests and partial integration errors | Optional `statuses`, `recentDays` |
| `media.stats` | Request totals and users | None |
| `media.respond` | Approve or decline a media request | `requestId`, `answer` |
| `smartHome.state` | Home Assistant entity state | `entityId` |
| `smartHome.details` | Entity state, attributes and timestamps | `entityId` |
| `smartHome.switch` | Toggle an entity | `entityId` |
| `smartHome.automation` | Trigger an automation | `automationId` |
| `docker.containers` | Container inventory and resource use | Optional `endpointIds` |
| `docker.start`, `docker.stop`, `docker.restart` | Control a container | `endpointId`, `containerId` |
Use `customWidget_nativeCapabilities` to discover the exact input schema for the installed Homarr version.
Docker capabilities retain the existing administrator requirement and endpoint capability checks.
## Declare and select an integration
```json
{
"$schema": "homarr-custom-widget-v3",
"name": "My systems",
"sources": {},
"requests": {},
"options": {
"beszel": {
"label": "Beszel integration",
"control": "integration",
"integrationKinds": ["beszel"],
"default": ""
}
},
"extensions": {
"native": {
"systems": {
"capability": "beszel.systems",
"integrationOption": "beszel",
"kind": "query",
"trigger": "load",
"permission": "view",
"input": {}
}
}
},
"template": "<NativeQuery nativeId=\"systems\">{(services) => <Stack>{services.map((service) => <Stack key={service.integrationId}><Text fw={600}>{service.integrationName}</Text>{service.systems.map((system) => <Text key={system.id}>{system.name}</Text>)}</Stack>)}</Stack>}</NativeQuery>"
}
```
Choose a local integration in the workbench, during installation, or in board configuration. Portable exports clear the
local selection. `integrationOption` reads saved configuration, not invocation parameters; a browser cannot replace the
integration ID by sending a different runtime input. An integration still needs to be accessible to the viewer under
Homarr's integration permissions. Placing a custom widget does not grant general integration access.
## Inputs, detail views and live data
A capability's `input` supports the same recursive `$option` and `$param` references as HTTP request bodies. Invocation
parameters are primitive values. Use `trigger: "manual"` when an input depends on a selected row or a temporary input;
load capabilities cannot depend on invocation parameters.
The workbench reads each installed capability's input schema and offers fixed values, saved-option bindings, and invocation
parameters. Switching an input to a parameter also switches its trigger to manual. Complex nested inputs retain a JSON
editor; scalar and list inputs do not require editing the extensions manifest.
Use `<NativeQuery nativeId="details" trigger="manual" params={{ systemId: inputs.systemId }}>` for a selected-system
query. For a declared `beszel.live` capability, add `live` to receive events. Historical and live responses retain the
underlying Beszel response shapes; a live event contains its event type and record. Compose those results with supported
charts, tables, fragments, modals, or drawers. Live subscriptions reconnect periodically to renew authorization and bounded
request capacity, and stop when their widget unmounts or a preview session expires.
## Actions and preview
Native actions require `kind: "action"`, `trigger: "manual"`, `permission: "modify"` or `"full"`, and a `confirmation`
object containing `title` and `message`. Render them with `NativeActionButton`. The server checks both board permission and
the native integration's interaction permission; Docker retains its administrator policy.
**Test and preview** runs declared load queries. Manual queries and actions are exercised through preview controls. Native
actions simulate until live preview actions are explicitly enabled. The journal records native entries as `native:<id>`;
preview-based creation requires current evidence for every declared HTTP and native capability.
Individual capability tests use the current preview session. Their parameter values and responses remain local to the
editor and are not saved into the definition. Action tests in the capability inspector always simulate; they are disabled
while the preview has live actions enabled. Use the rendered preview's confirmation flow to exercise live actions.
This is the initial native capability library. Other self-hosted services can use HTTP sources or browser embeds. Services
without an accessible HTTP interface, supported embed, or curated Homarr capability need an external bridge. Custom Widgets
do not run authored backend code, shell commands, imports, or arbitrary event callbacks.
@@ -1,12 +1,18 @@
# Configure requests and security
Most widgets use a single source named `default`; the manifest must contain it. Extra keyed sources are available for widgets
that combine APIs. Source IDs are case-insensitively unique. Base URLs must use HTTP(S), contain no embedded credentials,
HTTP widgets often use a source named `default`. Every request must reference an existing source; an omitted request source
resolves to `default`. Static and native-only widgets can use empty `sources` and `requests`. Extra keyed sources let widgets
combine APIs. Source IDs are case-insensitively unique. Base URLs must use HTTP(S), contain no embedded credentials,
query string, or fragment, and sources can be `public`, `private`, or `loopback`. Authentication is `none`, `bearer`,
`basic`, an API-key header, or an API-key query parameter. Credentials belong to the widget but remain outside its manifest.
Source origins are fixed by the definition and source-setup flow; widget options and invocation parameters cannot redirect a
request to a different origin.
Source URLs are fetched by the Homarr server. In a container, `127.0.0.1` refers to that container; Docker service names such
as `sonarr` must resolve from Homarr. LAN addresses and loopback services are supported with the appropriate explicit scope.
The workbench suggests a likely local scope from the hostname; server DNS and redirect checks remain authoritative.
Browser navigation and app embeds use browser-reachable URLs instead.
## Named requests
Requests are keyed by ID. `source`, `kind`, `method`, `trigger`, `auth`, and `permission` have sensible defaults, so a load query can be as small as:
+37 -34
View File
@@ -8,18 +8,17 @@ tags:
# Model Context Protocol (MCP)
Homarr exposes a stateless [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`. It maps the
current Homarr API to tools for compatible AI clients.
Connect an AI client to Homarr to manage boards, apps, integrations, and other services through MCP tools.
Homarr uses MCP v2 (protocol `2026-07-28`) at `/api/mcp`, with compatibility for 2025 Streamable HTTP clients.
Requests are stateless: each request authenticates independently, without an MCP session ID.
![The AI / MCP management tab](./img/mcp.png)
The endpoint implements MCP `2026-07-28` and accepts 2025 Streamable HTTP clients through a compatibility path. The old
`/api/mcp/mcp` route remains as an alias; use `/api/mcp` for new clients.
## Connect a client
## Configure a client
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
MCP configuration:
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
replacing the URL and API key:
```json
{
@@ -34,7 +33,10 @@ MCP configuration:
}
```
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
Use the complete key, including the ID, dot, and token. Keep it private and use HTTPS for remote connections.
The **AI / MCP** tab on Homarr's API page provides your endpoint, configuration, and available tools.
For clients that only support STDIO, use `mcp-remote`:
```json
{
@@ -47,26 +49,30 @@ Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
}
```
The **AI / MCP** tab on Homarr's API page shows the current endpoint, configuration, and generated tool catalog. Filter
the catalog by namespace, tool name, or description; matching categories expand automatically.
### OAuth
## Authentication and permissions
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
Homarr publishes discovery metadata under `/.well-known/`.
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
for clients that support dynamic authorization.
If your reverse proxy does not preserve the public host and protocol, set `BASE_URL` to your public origin,
such as `https://homarr.example.com`, without a path.
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
server and protected resource through Homarr's `/.well-known/` routes.
## Permissions and tools
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
Tools use the permissions of the API key owner or OAuth user. Queries read data; mutations change it.
Inspect the **AI / MCP** tab or your client's tool list for available actions.
Use HTTPS when the endpoint crosses an untrusted network.
Custom Widget and Workshop authoring tools, prompts, and resources require administrator access.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
## Verify the endpoint
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
The 2025 compatibility path accepts a compact `tools/list` request:
## Verify the connection
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
```sh
curl -X POST \
@@ -77,17 +83,14 @@ curl -X POST \
https://homarr.example.com/api/mcp
```
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
A successful discovery or initialization response alone does not verify that tools can be listed.
## Tools and resources
## Troubleshooting
The generated catalog includes available board, app, integration, Docker, Kubernetes, media, DNS, download, and other
Homarr procedures. Queries are read-only; mutations change state. The exact catalog depends on the release and current
user permissions, so inspect `tools/list` or the table in Homarr instead of relying on a static list.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
- **`invalid_token`**: copy the complete API key in `<id>.<token>` format.
- **Missing tools**: check the user's permissions. Invalid tool schemas and duplicate names are omitted; check server
logs for `MCP tool omitted from catalog` and report the diagnostic with your Homarr version. Exclude API keys.
- **`Date cannot be represented in JSON Schema`**: update Homarr. Older versions could fail to list all tools because
of an unsupported date schema.
- **`410 Gone` on `/sse` or `/message`**: use `/api/mcp` with a Streamable HTTP client. The old `/api/mcp/mcp` URL remains
an alias.
@@ -18,6 +18,10 @@ password. Decimal units are the default. Change them from the user form or **Com
Preferences → Data size units**. Byte rates follow the same preference; bit rates stay decimal and use labels such as
Mb/s. Deleting a user does not delete boards they created.
In demo mode (`DEMO_MODE=true`), user deletion, password changes, and local username changes are disabled for everyone,
including administrators, even when `DEMO_READ_ONLY=false` allows other changes. With demo editing enabled, other
profile preferences remain editable. This applies to the UI and API.
![The Users management page](../img/users.png)
## Groups
+1 -1
View File
@@ -12,7 +12,7 @@ import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
The App widget opens an application or website and can show whether Homarr can reach it. Configure the target and status
check from the tile's edit actions.
check from the tile's edit actions. In view mode, right-click uses the browser's link menu; Homarr's widget menu remains available in edit mode.
### Adding the widget
@@ -22,6 +22,8 @@ one-row widgets. Horizontal, vertical, compact-grid, and icon-only layouts keep
preserves its configured columns when the dashboard is scaled. Bookmark cards, icons, text, and gaps keep the same
visual density when a low-column dashboard is enlarged to fill the screen.
Right-click a bookmark link in view mode to use the browser menu, including opening it in a new tab or copying its address.
### Screenshots
import gridIconsOnly from "./img/grid-icons-only.png";
@@ -0,0 +1,43 @@
---
title: "llama.cpp"
description: "Displays the health, loaded model and generation speed of a local llama.cpp llama-server."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { llamacppWidget } from ".";
import { llamacppIntegration } from "@site/docs/integrations/llama-cpp";
<WidgetHeader widget={llamacppWidget} categories={["System Monitoring"]} />
This widget shows the live status of your local [llama.cpp](https://github.com/ggml-org/llama.cpp) `llama-server`: whether it is online, which model is loaded (with quantization, context size, file size and parameter count), the generation speed in tokens per second, and how many requests are being processed. When the server is started with `--metrics`, it also reports total token counts and the prompt cache hit rate.
### Generation speed
The widget picks the most meaningful speed for the current state (hover the speed for an explanation of which one is shown):
- **While a request is being generated** — the average speed of that request since it started, derived from the token count reported by `/slots`.
- **While the server is busy but the per-request data is unavailable** — the live speed from the `/metrics` rate gauge.
- **While the server is idle** — the average generation speed since the server started (cumulative tokens ÷ seconds), so the value stays meaningful instead of dropping to zero.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: llamacppIntegration,
note: "Displays health, model details, generation speed and request status",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={llamacppWidget.configuration} />
+37
View File
@@ -0,0 +1,37 @@
import { WidgetDefinition } from "@site/src/types";
import { IconCpu } from "@tabler/icons-react";
export const llamacppWidget: WidgetDefinition = {
icon: IconCpu,
name: "llama.cpp",
description: "Shows the health, loaded model and generation speed of a local llama.cpp llama-server.",
path: "../../widgets/llama-cpp",
configuration: {
items: [
{
name: "Show title",
description: "Displays the llama.cpp label and status badge at the top of the widget",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show model info",
description: "Displays the loaded model name, quantization, context size and file size",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show cache hit rate",
description: "Displays the prompt cache hit rate as a percentage bar",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show context usage",
description: "Displays the context (KV cache) token usage as a progress bar",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
@@ -41,6 +41,7 @@ import temperature from "./img/temperature.png";
### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled).
- After a successful toggle, the displayed state refreshes immediately.
- If Homarr cannot refresh an entity, the last known state stays visible but toggling is disabled until the connection recovers.
<WidgetInteractionGuide />
@@ -51,4 +51,6 @@ import withDisplayname from "./img/with-displayname.png";
### Configuration
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
+28 -3
View File
@@ -31,12 +31,37 @@ from Custom Widget and CSS submissions.
- Install a Custom Widget from **Management → Custom Widgets → Import from Workshop**.
- Import dashboard CSS from **Board settings → Custom CSS → Import from Workshop**.
Custom Widget credentials are configured in your Homarr instance and are not part of the Workshop submission.
Custom Widget credentials stay in your Homarr instance. Ordinary installation does not load the workbench; **Remix in
workbench** installs a separate local widget and opens its draft. Widgets without data sources need no URL or credentials.
Installation records the Workshop endpoint, submission, revision, and content fingerprint. A JSON file import has no
Workshop identity, even when its name matches a submission. Editor layout and credentials are not part of the portable
definition. Native integration selections are also local: exports clear their default IDs, and installation binds them
to existing integrations in your instance. Leaving a selection empty defers setup to each board widget's configuration.
## Updates and rollback
The workbench's Workshop panel compares a linked installation with its latest revision. Applying an update preserves
local service URLs, network scopes, and credentials. Changed source identities or authentication destinations require
installing a separate copy. Locally modified definitions also require a separate copy; Homarr does not merge authored JSX.
Compatible native integration selections are retained; new options and options whose allowed integration kinds change
start unconfigured. The review also includes shared-content declarations and their write permissions. Stored board content
and each user's preferences remain in Homarr and are not included in a submission.
Each update retains one previous installed package for rollback. Rollback restores the definition, preserves current
service URLs and credentials, and works without contacting Workshop. It refuses changed source authentication bindings.
Changing the configured Workshop endpoint disables upstream updates for installations linked to the previous endpoint.
## Publish content
Publish a saved Custom Widget from its action menu under **Management → Custom Widgets**. Custom CSS and manual
submissions can be published from the Workshop website.
Publish a saved Custom Widget from its action menu or workbench. The reviewed saved definition must still match when
publication starts. Updating a linked submission requires its Workshop author account and preserves its identity,
screenshots, votes, and comments. A remix creates a new submission with attribution. Publishing succeeds independently of
linking the resulting submission back to your local widget; a linking failure must not be retried as a new publication.
Custom JSX v2 remains supported. Scoped styles, fragments, preferences, shared content, and native capabilities use
`homarr-custom-widget-v3`; Workshop clients and the Workshop service must support that schema before publishing or
updating such submissions. Custom CSS and manual submissions can be published from the Workshop website.
Sign in to the Workshop and open **Yours** to update or delete submissions and review reports. Signed-in users can vote,
comment, and report content; Workshop moderators can remove content. Workshop moderation is separate from administration
@@ -50,7 +50,7 @@ const placeholders: Record<SubmissionType, string> = {
customCss: ".grid-stack-item-content {\n border-radius: 16px;\n}",
};
const contentLabels: Record<SubmissionType, string> = {
customWidget: "Widget JSON (homarr-custom-widget-v2)",
customWidget: "Widget JSON (homarr-custom-widget-v2 or v3)",
customCss: "Custom CSS",
};
@@ -314,7 +314,7 @@ const typeCards: { type: SubmissionType; icon: typeof IconBraces; label: string;
type: "customWidget",
icon: IconBraces,
label: "Custom Widget",
desc: "A JSON-based widget using the homarr-custom-widget-v2 schema",
desc: "A JSON-based widget using the homarr-custom-widget-v2 or v3 schema",
},
{
type: "customCss",
+5 -10
View File
@@ -2151,14 +2151,6 @@
}
]
},
"fixedScaling": {
"type": "boolean"
},
"fixedItemSize": {
"type": "integer",
"minimum": 40,
"maximum": 200
},
"disableStatus": {
"type": "boolean"
}
@@ -2312,7 +2304,8 @@
"traefik",
"customApi",
"assistant",
"wud"
"wud",
"llamacpp"
]
},
"options": {
@@ -2580,7 +2573,9 @@
"type": "object",
"properties": {
"expirationDate": {
"type": "string"
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
}
},
"required": [
@@ -0,0 +1,75 @@
{
"$schema": "homarr-custom-widget-v3",
"name": "Beszel systems studio",
"description": "Systems overview, configurable columns, selected-system history and live details using your existing Beszel integration. No HTTP source or copied API token required.",
"sources": {},
"requests": {},
"options": {
"beszel": {
"label": "Beszel integration",
"control": "integration",
"integrationKinds": ["beszel"],
"default": ""
}
},
"extensions": {
"stylesheet": ".metric { padding: 14px; border-radius: 12px; background: var(--mantine-color-default-hover); } .system-table { font-variant-numeric: tabular-nums; }",
"preferences": {
"system": {
"type": "string",
"defaultValue": ""
},
"period": {
"type": "string",
"defaultValue": "1h"
},
"columns": {
"type": "string[]",
"defaultValue": ["cpu", "memory", "disk"]
}
},
"fragments": {
"metric": "<Stack className=\"metric\" gap={2}><Text size=\"xs\" c=\"dimmed\">{props.label}</Text><Text fw={700} size=\"xl\">{Number(props.value ?? 0).toFixed(1)}{props.suffix ?? \"%\"}</Text></Stack>",
"containers": "<Stack gap=\"xs\">\n <Text size=\"xs\" c=\"dimmed\">{props.items.length} containers \u00b7 {Date.toLocaleTimeString(props.updatedAt)}</Text>\n {!props.items.length && <Text c=\"dimmed\">No containers reported.</Text>}\n {props.items.length > 0 && <ScrollArea><Table striped highlightOnHover>\n <Table.Thead><Table.Tr><Table.Th>Container</Table.Th><Table.Th>CPU</Table.Th><Table.Th>Memory</Table.Th><Table.Th>Sent</Table.Th><Table.Th>Received</Table.Th></Table.Tr></Table.Thead>\n <Table.Tbody>{props.items.map((row) => <Table.Tr key={row.n}>\n <Table.Td><Text fw={500}>{row.n}</Text></Table.Td>\n <Table.Td>{Number(row.c ?? 0).toFixed(1)}%</Table.Td>\n <Table.Td>{Number(row.m ?? 0).toFixed(1)} MiB</Table.Td>\n <Table.Td>{Number((row.b?.[0] ?? (row.ns ?? 0) * 1048576) / 1024).toFixed(1)} KiB/s</Table.Td>\n <Table.Td>{Number((row.b?.[1] ?? (row.nr ?? 0) * 1048576) / 1024).toFixed(1)} KiB/s</Table.Td>\n </Table.Tr>)}</Table.Tbody>\n </Table></ScrollArea>}\n</Stack>"
},
"native": {
"systems": {
"capability": "beszel.systems",
"integrationOption": "beszel",
"kind": "query",
"permission": "view",
"trigger": "load",
"input": {}
},
"history": {
"capability": "beszel.history",
"integrationOption": "beszel",
"kind": "query",
"permission": "view",
"trigger": "manual",
"input": {
"systemId": {
"$param": "systemId"
},
"timePeriod": {
"$param": "period"
},
"includeDocker": true
}
},
"live": {
"capability": "beszel.live",
"integrationOption": "beszel",
"kind": "query",
"permission": "view",
"trigger": "manual",
"input": {
"systemId": {
"$param": "systemId"
}
}
}
}
},
"template": "<NativeQuery nativeId=\"systems\" refreshInterval={30}>{(services) => (\n <Stack p=\"md\" gap=\"md\">\n <Group justify=\"space-between\"><Title order={3}>Systems</Title><Badge variant=\"light\">{services.flatMap((service) => service.systems).length} hosts</Badge></Group>\n <Group grow align=\"flex-start\">\n <TextInput label=\"Filter systems\" bind=\"filter\" />\n <Select label=\"Selected system\" bind=\"system\" persist=\"system\" searchable clearable data={services.flatMap((service) => service.systems.map((system) => ({ value: system.id, label: system.name })))} />\n <MultiSelect label=\"Visible columns\" bind=\"columns\" persist=\"columns\" data={[{value:\"cpu\",label:\"CPU\"},{value:\"memory\",label:\"Memory\"},{value:\"disk\",label:\"Disk\"}]} />\n </Group>\n {services.map((service) => <Stack key={service.integrationId} gap=\"xs\">\n <Group justify=\"space-between\"><Text fw={600}>{service.integrationName}</Text><Text size=\"xs\" c=\"dimmed\">Updated {Date.toLocaleTimeString(service.updatedAt)}</Text></Group>\n {service.error && <Alert color=\"yellow\" title=\"Integration unavailable\">{service.error}</Alert>}\n <ScrollArea><Table className=\"system-table\" striped highlightOnHover>\n <Table.Thead><Table.Tr><Table.Th>System</Table.Th><Table.Th>Status</Table.Th>{preferences.columns.includes(\"cpu\") && <Table.Th>CPU</Table.Th>}{preferences.columns.includes(\"memory\") && <Table.Th>Memory</Table.Th>}{preferences.columns.includes(\"disk\") && <Table.Th>Disk</Table.Th>}</Table.Tr></Table.Thead>\n <Table.Tbody>{service.systems.filter((system) => !inputs.filter || system.name.toLowerCase().includes(inputs.filter.toLowerCase())).map((system) => <Table.Tr key={system.id}>\n <Table.Td><Text fw={600}>{system.name}</Text><Text size=\"xs\" c=\"dimmed\">{system.hostname}</Text></Table.Td>\n <Table.Td><Badge variant=\"light\">{system.status}</Badge></Table.Td>\n {preferences.columns.includes(\"cpu\") && <Table.Td>{Number(system.cpu ?? 0).toFixed(1)}%</Table.Td>}\n {preferences.columns.includes(\"memory\") && <Table.Td>{Number(system.memory ?? 0).toFixed(1)}%</Table.Td>}\n {preferences.columns.includes(\"disk\") && <Table.Td>{Number(system.disk ?? 0).toFixed(1)}%</Table.Td>}\n </Table.Tr>)}</Table.Tbody>\n </Table></ScrollArea>\n {!service.systems.length && !service.error && <Text c=\"dimmed\">No systems reported.</Text>}\n </Stack>)}\n {!preferences.system && <Text size=\"sm\" c=\"dimmed\">Select a system to open historical and live details.</Text>}\n {preferences.system && <WidgetDrawer title=\"Selected system\" triggerLabel=\"Open system details\" size=\"xl\">\n <Stack gap=\"md\">\n {services.flatMap((service) => service.systems).filter((system) => system.id === preferences.system).map((system) => <Stack key={system.id}>\n <Title order={3}>{system.name}</Title><Text size=\"sm\" c=\"dimmed\">{system.osName} \u00b7 {system.cpuModel} \u00b7 {system.cores} cores</Text>\n <SimpleGrid cols={{base:1,sm:3}}><View name=\"metric\" values={{label:\"CPU\",value:system.cpu}}/><View name=\"metric\" values={{label:\"Memory\",value:system.memory}}/><View name=\"metric\" values={{label:\"Disk\",value:system.disk}}/></SimpleGrid>\n </Stack>)}\n <Select label=\"History period\" bind=\"period\" persist=\"period\" data={[{value:\"1h\",label:\"Last hour\"},{value:\"12h\",label:\"12 hours\"},{value:\"24h\",label:\"24 hours\"},{value:\"1w\",label:\"Week\"},{value:\"30d\",label:\"30 days\"}]} />\n <NativeQuery nativeId=\"history\" params={{systemId:preferences.system,period:preferences.period}}>{(history) => <Stack>\n <Text fw={600}>Resource history</Text>\n {!history.systemStats.length && <Text c=\"dimmed\">No history in this period.</Text>}\n {history.systemStats.length > 0 && <LineChart h={230} data={history.systemStats.slice().sort((a,b) => a.created.localeCompare(b.created)).map((point) => ({time:Date.toLocaleTimeString(point.created),cpu:point.stats.cpu,memory:point.stats.mp,disk:point.stats.dp}))} dataKey=\"time\" series={[{name:\"cpu\",color:\"blue.6\"},{name:\"memory\",color:\"violet.6\"},{name:\"disk\",color:\"teal.6\"}]} withLegend unit=\"%\" />}\n <Text fw={600}>Latest container statistics</Text>\n {!history.containerStats.length && <Text c=\"dimmed\">No container history in this period.</Text>}\n {history.containerStats.slice().sort((a,b) => b.created.localeCompare(a.created)).slice(0,1).map((record) => <View key={record.id} name=\"containers\" values={{items:record.stats,updatedAt:record.created}} />)}\n </Stack>}</NativeQuery>\n <NativeQuery nativeId=\"live\" live params={{systemId:preferences.system}}>{(event,meta) => <Stack gap=\"xs\">\n <Group><Text fw={600}>Live event</Text>{meta.stale && <Badge color=\"yellow\">Last received data</Badge>}</Group>\n Line truncated
}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"mantineVersion":"9.6.0","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\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"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\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\" | \"stream\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\"","\"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\" | \"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\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"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\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right\" | \"bottom\"","\"right-to-left\" | \"left-to-right\"","\"right-to-left\" | \"left-to-right\" | \"top-to-bottom\" | \"bottom-to-top\"","\"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\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Line truncated
{"schemaVersion":1,"mantineVersion":"9.6.0","customWidgetVersion":"3.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\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"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\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\" | \"stream\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\"","\"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\" | \"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\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"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\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right\" | \"bottom\"","\"right-to-left\" | \"left-to-right\"","\"right-to-left\" | \"left-to-right\" | \"top-to-bottom\" | \"bottom-to-top\"","\"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\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Line truncated
+2
View File
@@ -86,6 +86,7 @@
"@xterm/addon-canvas": "catalog:",
"@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:",
"@xyflow/react": "catalog:",
"adm-zip": "catalog:",
"ai": "catalog:",
"assistant-stream": "catalog:",
@@ -96,6 +97,7 @@
"dayjs": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"elkjs": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"isomorphic-dompurify": "catalog:",
@@ -171,7 +171,8 @@ const ScopedTRPCReactProvider = ({
splitLink({
condition: ({ type }) => type === "subscription",
true: splitLink({
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
condition: ({ path }) =>
path === "widget.beszel.subscribeSystemStats" || path === "customWidget.nativeSubscribe",
true: httpSubscriptionLink({
url: getTrpcUrl(),
transformer: superjson,
@@ -1,6 +1,7 @@
.columns {
display: grid;
align-items: start;
gap: 24px;
}
.lane {
@@ -26,10 +27,3 @@
outline-offset: -0.0625rem;
border-radius: var(--mantine-radius-md);
}
:global([data-canvas-scaling="fixed"]) .gutter {
--board-gutter-sticky-top: 0px;
--board-gutter-viewport-height: calc(
var(--board-canvas-fixed-viewport-height) * var(--board-canvas-inverse-scale, 1)
);
}
@@ -13,16 +13,15 @@ import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardContextMenu } from "~/components/board/board-context-menu";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalGridSize,
LOGICAL_GRID_GAP,
getRootSectionForLane,
} from "~/components/board/layout";
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BOARD_LANE_GAP, getBoardCanvasGeometry } from "~/components/board/layout/canvas-geometry";
import { getBoardScaling } from "~/components/board/layout/scaling";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
@@ -61,91 +60,99 @@ export const ClientBoard = () => {
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const { width: logicalWidth, gridTemplateColumns } = getBoardCanvasGeometry(currentLayout);
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0)
.map(getLogicalGridSize);
const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
const gridTemplateColumns = [
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
`${getLogicalGridSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
]
.filter((value) => value !== null)
.join(" ");
const content = (
<BoardSelectionGridProvider>
<BoardAdvancedFocusProvider>
<BoardContextMenu>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
scaling={getBoardScaling(board)}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<div className={classes.columns} style={{ gap: BOARD_LANE_GAP, gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<section className={classes.lane} aria-label={t("canvas")}>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</section>
</aside>
)}
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<BoardSelectionToolbar />
<FloatingTip
opened={isEditMode}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{tTips("multiSelectApps")}
</FloatingTip>
</Box>
</BoardContextMenu>
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
/>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<BoardSelectionToolbar />
<FloatingTip
opened={isEditMode}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{tTips("multiSelectApps")}
</FloatingTip>
</Box>
</BoardAdvancedFocusProvider>
</BoardSelectionGridProvider>
);
@@ -36,7 +36,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerBoardEditAction: <BoardContentEditAction />,
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
@@ -3,14 +3,18 @@
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Group, Menu } from "@mantine/core";
import { IconChevronDown, IconPlus } from "@tabler/icons-react";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { BoardAddMenuItems } from "~/components/board/board-add-menu-items";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -18,9 +22,16 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const tBoard = useI18n("board");
const tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
@@ -37,6 +48,34 @@ const AddMenu = () => {
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [board.id, openItemSelectModal, pathname, router, searchParams]);
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
onSelectMany: (apps) => {
for (const app of apps) {
createItem({
kind: "app",
options: { appId: app.id },
});
}
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({ completionBoardId: board.id });
};
return (
<Menu position="bottom-end">
<Menu.Target>
@@ -48,7 +87,21 @@ const AddMenu = () => {
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<BoardAddMenuItems />
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{tBoard("emptyState.addWidget")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{tBoard("emptyState.addApp")}
</Menu.Item>
{session?.user.permissions.includes("integration-create") && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{tIntegration("action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{tContainer("action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -1,247 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import type * as EditActionsModule from "./_edit-actions";
import { BoardEditingContext } from "~/components/board/board-editing-context";
let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
const loadEditActionsAsync = () => {
editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
editActionsModulePromise = undefined;
throw error;
});
return editActionsModulePromise;
};
const loadBoardEditorAsync = async () =>
await Promise.all([
loadGridEditorAsync(),
loadEditActionsAsync(),
import("~/components/board/items/item-menu"),
import("~/components/board/sections/container/container-menu"),
]);
export type BoardEditingControls = ReturnType<typeof useBoardEditingController>;
export const BoardEditingProvider = ({
children,
enabled,
demoReadOnly,
}: PropsWithChildren<{ enabled: boolean; demoReadOnly: boolean }>) => {
if (!enabled) return children;
return <ActiveBoardEditingProvider demoReadOnly={demoReadOnly}>{children}</ActiveBoardEditingProvider>;
};
const ActiveBoardEditingProvider = ({ children, demoReadOnly }: PropsWithChildren<{ demoReadOnly: boolean }>) => {
const controller = useBoardEditingController(demoReadOnly);
return <BoardEditingContext.Provider value={controller}>{children}</BoardEditingContext.Provider>;
};
const useBoardEditingController = (demoReadOnly: boolean) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const utils = clientApi.useUtils();
const router = useRouter();
const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tBoardSetting = useI18n("board.setting");
const t = useI18n("board.action.edit");
const returnToLayoutSettings =
isEditMode &&
searchParams.get("edit") === "true" &&
searchParams.get("layout") !== null &&
searchParams.get("returnTo") === "settings";
const latestBoardRef = useRef(board);
latestBoardRef.current = board;
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onMutate(boardToSave) {
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
},
onSuccess() {
showSuccessNotification({
title: tCommon("notification.update.success"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync(`/boards/${board.name}`);
close();
if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`);
},
onError() {
showErrorNotification({
title: tCommon("notification.update.error"),
message: t("notification.error.message"),
});
},
});
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const prepareEditorAsync = useCallback(async () => {
if (editorLoadState === "ready") return;
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
throw new Error("Unable to load the board editor");
}
}, [editorLoadState]);
useEffect(() => {
return scheduleGridEditorWarmup(async () => {
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
}
});
}, []);
const prewarmEditor = useCallback(() => {
void prepareEditorAsync().catch(() => {
// A click or hotkey retries and reports the error.
});
}, [prepareEditorAsync]);
const enter = useCallback(async () => {
if (!hasChangeAccess || isPending || isEnteringEditMode) return false;
if (isEditMode) return true;
setIsEnteringEditMode(true);
try {
await prepareEditorAsync();
open();
return true;
} catch {
showErrorNotification({
title: t("notification.loadError.title"),
message: t("notification.loadError.message"),
});
return false;
} finally {
setIsEnteringEditMode(false);
}
}, [hasChangeAccess, isPending, isEnteringEditMode, isEditMode, open, prepareEditorAsync, t]);
const toggle = useCallback(async () => {
if (!hasChangeAccess || isPending || isEnteringEditMode) return;
if (!isEditMode) return enter();
if (demoReadOnly) return discardDemoChanges();
saveBoard(latestBoardRef.current);
}, [hasChangeAccess, isPending, isEnteringEditMode, isEditMode, enter, demoReadOnly, discardDemoChanges, saveBoard]);
const cancelLayoutEdit = useCallback(() => {
openConfirmModal({
title: tBoardSetting("section.layout.edit.cancel.title"),
children: tBoardSetting("section.layout.edit.cancel.message"),
confirmProps: { children: tCommon("action.discard"), color: "red" },
onConfirm() {
void (async () => {
await utils.board.getBoardByName.invalidate({ name: board.name });
close();
router.push(`/boards/${board.name}/settings?tab=layout`);
})();
},
});
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode && hasChangeAccess);
let label = t("enter");
if (isEditMode) label = t("save");
if (isEditMode && demoReadOnly) label = t("exit");
return {
isEditMode,
hasChangeAccess,
label,
toggle,
enter,
prewarmEditor,
editorLoadState,
isPending,
isEnteringEditMode,
returnToLayoutSettings,
cancelLayoutEdit,
};
};
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
useEffect(() => {
if (!isDirty) return;
const handleClick = (event: MouseEvent) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
if (!target) return;
event.preventDefault();
event.stopPropagation();
openConfirmModal({
title: tBoard("action.edit.confirmLeave.title"),
children: tBoard("action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: tCommon("action.discard"),
},
});
};
const handlePopState = (event: Event) => {
window.history.pushState(null, document.title, window.location.href);
event.preventDefault();
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
event.preventDefault();
event.returnValue = true;
};
document.addEventListener("click", handleClick, true);
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
};
@@ -1,15 +1,45 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { useOptionalBoardEditing } from "~/components/board/board-editing-context";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
const BoardEditActions = dynamic(() => import("./_edit-actions"), { ssr: false });
export const BoardContentEditAction = () => {
import type * as EditActionsModule from "./_edit-actions";
let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
const loadEditActionsAsync = () => {
editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
editActionsModulePromise = undefined;
throw error;
});
return editActionsModulePromise;
};
const loadBoardEditorAsync = async () =>
await Promise.all([
loadGridEditorAsync(),
loadEditActionsAsync(),
import("~/components/board/items/item-menu"),
import("~/components/board/sections/container/container-menu"),
]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
@@ -19,7 +49,7 @@ export const BoardContentEditAction = () => {
return (
<>
{isEditMode && <BoardEditActions />}
<EditModeMenu />
<EditModeMenu demoReadOnly={demoReadOnly} />
</>
);
};
@@ -40,26 +70,127 @@ export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boo
);
};
const EditModeMenu = () => {
const editing = useOptionalBoardEditing();
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const router = useRouter();
const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tBoardSetting = useI18n("board.setting");
if (!editing) return null;
const {
isEditMode,
toggle,
prewarmEditor,
editorLoadState,
isPending,
isEnteringEditMode,
returnToLayoutSettings,
cancelLayoutEdit,
} = editing;
const t = useI18n("board.action.edit");
const returnToLayoutSettings =
isEditMode &&
searchParams.get("edit") === "true" &&
searchParams.get("layout") !== null &&
searchParams.get("returnTo") === "settings";
const latestBoardRef = useRef(board);
latestBoardRef.current = board;
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onMutate(boardToSave) {
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
},
onSuccess() {
showSuccessNotification({
title: tCommon("notification.update.success"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync(`/boards/${board.name}`);
close();
if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`);
},
onError() {
showErrorNotification({
title: tCommon("notification.update.error"),
message: t("notification.error.message"),
});
},
});
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const prepareEditorAsync = useCallback(async () => {
if (editorLoadState === "ready") return;
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
throw new Error("Unable to load the board editor");
}
}, [editorLoadState]);
useEffect(() => {
return scheduleGridEditorWarmup(async () => {
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
}
});
}, []);
const prewarmEditor = useCallback(() => {
void prepareEditorAsync().catch(() => {
// A click or hotkey retries and reports the error.
});
}, [prepareEditorAsync]);
const toggle = useCallback(async () => {
if (isEditMode) {
if (demoReadOnly) return discardDemoChanges();
return saveBoard(latestBoardRef.current);
}
setIsEnteringEditMode(true);
try {
await prepareEditorAsync();
open();
} catch {
showErrorNotification({
title: t("notification.loadError.title"),
message: t("notification.loadError.message"),
});
} finally {
setIsEnteringEditMode(false);
}
}, [demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
const cancelLayoutEdit = useCallback(() => {
openConfirmModal({
title: tBoardSetting("section.layout.edit.cancel.title"),
children: tBoardSetting("section.layout.edit.cancel.message"),
confirmProps: { children: tCommon("action.discard"), color: "red" },
onConfirm() {
void (async () => {
await utils.board.getBoardByName.invalidate({ name: board.name });
close();
router.push(`/boards/${board.name}/settings?tab=layout`);
})();
},
});
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode);
if (returnToLayoutSettings) {
return (
<>
<HeaderButton
onClick={() => void toggle()}
onClick={() => saveBoard(board)}
loading={isPending}
aria-label={tBoardSetting("section.layout.edit.save")}
>
@@ -86,7 +217,7 @@ const EditModeMenu = () => {
data-testid="board-edit-mode-toggle"
data-board-editor-preload-state={editorLoadState}
aria-busy={isPending || isEnteringEditMode}
aria-label={editing.label}
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
aria-pressed={isEditMode}
>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
@@ -94,3 +225,57 @@ const EditModeMenu = () => {
</TourTarget>
);
};
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
useEffect(() => {
if (!isDirty) return;
const handleClick = (event: MouseEvent) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
if (!target) return;
event.preventDefault();
event.stopPropagation();
openConfirmModal({
title: tBoard("action.edit.confirmLeave.title"),
children: tBoard("action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: tCommon("action.discard"),
},
});
};
const handlePopState = (event: Event) => {
window.history.pushState(null, document.title, window.location.href);
event.preventDefault();
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
event.preventDefault();
event.returnValue = true;
};
document.addEventListener("click", handleClick, true);
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
};
@@ -1,51 +0,0 @@
import { Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import type { Board } from "../../_types";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { LayoutPreview } from "./_layout-preview";
import type { FormValues } from "./_settings-form";
import classes from "./_settings-form.module.css";
export const AppearancePlayground = ({
board,
form,
apps,
selectedLayoutId,
}: {
board: Board;
form: UseFormReturnType<FormValues>;
apps: RouterOutputs["app"]["byIds"];
selectedLayoutId: string | null;
}) => {
const layout =
form.values.layouts.find((candidate) => candidate.id === selectedLayoutId) ??
form.values.layouts.find((candidate) => candidate.role === "base");
const sourceLayout =
board.layouts.find((candidate) => candidate.id === layout?.id) ??
board.layouts.find((candidate) => candidate.role === "base");
return (
<div className={classes.appearance}>
<Stack gap="lg">
<ColorSettingsContent form={form} />
<BackgroundSettingsContent form={form} />
</Stack>
{layout && sourceLayout && (
<div className={classes.appearancePreview}>
<LayoutPreview
board={board}
layout={layout}
layouts={form.values.layouts}
sourceLayout={sourceLayout}
apps={apps}
settings={form.values}
/>
</div>
)}
</div>
);
};
@@ -1,6 +1,7 @@
"use client";
import {
Anchor,
Button,
Collapse,
ColorInput,
@@ -9,6 +10,7 @@ import {
Group,
InputWrapper,
isLightColor,
Select,
Slider,
Stack,
Text,
@@ -19,7 +21,7 @@ import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
import { BoardColorInput } from "@homarr/ui";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card";
@@ -37,6 +39,7 @@ const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false);
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const theme = useMantineTheme();
const { branding } = useSettings();
@@ -47,11 +50,6 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Stack gap="xs">
<BoardColorInput
label={tBoard("field.primaryColor.label")}
eyeDropperButtonProps={{
"aria-label": tBoard("setting.section.appearance.pickColor", {
label: tBoard("field.primaryColor.label"),
}),
}}
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
disabled={branding.lockPrimaryColor}
{...form.getInputProps("primaryColor")}
@@ -59,22 +57,10 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<BoardColorInput
label={tBoard("field.secondaryColor.label")}
eyeDropperButtonProps={{
"aria-label": tBoard("setting.section.appearance.pickColor", {
label: tBoard("field.secondaryColor.label"),
}),
}}
{...form.getInputProps("secondaryColor")}
/>
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
</Grid.Col>
<Grid.Col span={12}>
<Button type="button" variant="subtle" size="compact-sm" onClick={toggle} aria-expanded={showPreview}>
{showPreview
? tBoard("setting.section.appearance.hideShades")
: tBoard("setting.section.appearance.showShades")}
</Button>
<Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
@@ -87,7 +73,6 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={tBoard("field.opacity.label")}>
<Slider
thumbLabel={tBoard("field.opacity.label")}
my={6}
min={0}
max={100}
@@ -101,11 +86,6 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Group align="end">
<ColorInput
label={tBoard("field.iconColor.label")}
eyeDropperButtonProps={{
"aria-label": tBoard("setting.section.appearance.pickColor", {
label: tBoard("field.iconColor.label"),
}),
}}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
@@ -122,18 +102,18 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Button>
</Group>
</Grid.Col>
<Grid.Col span={12}>
<CornerStylePicker
compact
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={tBoard("field.itemRadius.label")}
description={tBoard("field.itemRadius.description")}
value={form.values.itemRadius}
labels={
Object.fromEntries(
cornerStyleValues.map((cornerStyle) => [cornerStyle, tBoard(`field.itemRadius.option.${cornerStyle}`)]),
) as Record<(typeof cornerStyleValues)[number], string>
}
onChange={(cornerStyle) => form.setFieldValue("itemRadius", cornerStyle)}
data={[
{ label: tBoard("field.itemRadius.option.xs"), value: "xs" },
{ label: tBoard("field.itemRadius.option.sm"), value: "sm" },
{ label: tBoard("field.itemRadius.option.md"), value: "md" },
{ label: tBoard("field.itemRadius.option.lg"), value: "lg" },
{ label: tBoard("field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
</Grid.Col>
</Grid>
@@ -1,7 +1,7 @@
"use client";
import { startTransition } from "react";
import { ActionIcon, Autocomplete, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
@@ -24,7 +24,6 @@ interface Props {
export const BackgroundSettingsContent = ({ form }: Props) => {
const tBoard = useI18n("board");
const tMedia = useI18n("media");
const { data: session } = useSession();
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
@@ -51,15 +50,14 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<Autocomplete
flex={1}
leftSectionPointerEvents="all"
leftSection={
form.values.backgroundImageUrl &&
form.values.backgroundImageUrl.trim().length >= 2 && (
<Popover width={300} withArrow>
<Popover.Target>
<ActionIcon type="button" variant="subtle" aria-label={tMedia("field.preview")}>
<Center h="100%">
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
</ActionIcon>
</Center>
</Popover.Target>
<Popover.Dropdown>
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
@@ -115,15 +113,7 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}}
>
{({ onClick, loading }) => (
<ActionIcon
type="button"
aria-label={tMedia("action.upload.label")}
onClick={onClick}
loading={loading}
mt={24}
size={36}
variant="default"
>
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
@@ -1,6 +1,70 @@
.preview {
width: 100%;
max-width: 32rem;
min-width: 0;
margin-inline: auto;
.canvas {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-color-body) 82%, var(--mantine-primary-color-light));
padding: var(--mantine-spacing-xs);
min-height: 7rem;
max-height: min(18rem, 38dvh);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.section {
border-radius: var(--mantine-radius-sm);
background: color-mix(in srgb, var(--mantine-color-default) 78%, transparent);
padding: 0.35rem;
}
.grid {
display: grid;
gap: 3px;
grid-auto-rows: clamp(1.35rem, 2.2vw, 1.75rem);
min-height: 2.5rem;
}
.tile {
container-type: inline-size;
min-width: 0;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 24%, var(--mantine-color-default-border));
border-radius: calc(var(--mantine-radius-sm) * 0.75);
background: color-mix(in srgb, var(--mantine-primary-color-light) 70%, var(--mantine-color-body));
padding-inline: 0.25rem;
}
.tileContent {
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
}
.tileLabel {
display: none;
min-width: 0;
max-height: 2.1em;
overflow: hidden;
overflow-wrap: anywhere;
line-height: 1.05;
}
@container (min-width: 9rem) {
.tileContent {
justify-content: flex-start;
}
.tileLabel {
display: block;
}
}
.containerTile {
border-style: dashed;
background: color-mix(in srgb, var(--mantine-color-default) 88%, var(--mantine-primary-color-light));
}
.appIcon {
width: 0.85rem;
height: 0.85rem;
}
@@ -1,17 +1,17 @@
"use client";
import { useMemo } from "react";
import { Badge, ColorSwatch, Group, Stack, Text, Tooltip } from "@mantine/core";
import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { normalizeFixedItemSize } from "~/components/board/layout/scaling";
import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form";
import classes from "./_layout-preview.module.css";
interface Props {
@@ -20,55 +20,107 @@ interface Props {
layouts: BoardPreviewLayout[];
sourceLayout: BoardPreviewLayout;
apps: RouterOutputs["app"]["byIds"];
settings: FormValues;
}
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps, settings }: Props) => {
const t = useI18n("board.setting.section.layout.preview");
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
const tBoard = useI18n("board");
const preview = useMemo(() => {
const appsById = new Map(apps.map((app) => [app.id, app]));
return {
layouts,
sections: board.sections,
items: board.items.map((item) => {
let iconUrl: string | undefined;
if (item.kind === "app" && typeof item.options.appId === "string")
iconUrl = appsById.get(item.options.appId)?.iconUrl;
return { ...item, iconUrl };
}),
};
}, [board, layouts, apps]);
const tSection = useI18n("section");
const t = useI18n();
const elements = projectBoardLayout(board, sourceLayout, layout);
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
const largestRepresentativeWidth = Math.max(
...layouts.map((candidate) => getRepresentativeLayoutWidth(candidate, layouts)),
);
const previewWidth = `${(representativeWidth / largestRepresentativeWidth) * 100}%`;
const appsById = new Map(apps.map((app) => [app.id, app]));
const rootSections = board.sections
.filter((section) => section.kind === "empty")
.toSorted((sectionA, sectionB) => sectionA.xOffset - sectionB.xOffset);
return (
<Stack gap="xs" className={classes.preview} data-testid="board-layout-preview">
<Group justify="space-between" gap="xs">
<Text size="sm" fw={600}>
{t("title")}
</Text>
<Badge variant="light" size="sm">
{Math.max(320, getRepresentativeLayoutWidth(layout, layouts))} px
<Stack gap={6} align="center" w="100%">
<Group gap="xs" justify="center">
<Badge variant="light" color={layout.role === "mobile" ? "teal" : layout.role === "base" ? "blue" : "gray"}>
{representativeWidth}px
</Badge>
</Group>
<BoardLayoutThumbnail preview={preview} label={t("canvasLabel")} canvas={{ layout, sourceLayout, settings }} />
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
{settings.fixedScaling
? t("dimensions", { size: normalizeFixedItemSize(settings.fixedItemSize), columns: layout.columnCount })
: t("responsiveDimensions", { columns: layout.columnCount })}
<Text size="xs">
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
</Text>
<Group gap={6}>
<Tooltip label={tBoard("field.primaryColor.label")}>
<ColorSwatch color={settings.primaryColor} size={16} aria-hidden />
</Tooltip>
<Tooltip label={tBoard("field.secondaryColor.label")}>
<ColorSwatch color={settings.secondaryColor} size={16} aria-hidden />
</Tooltip>
</Group>
</Group>
<Text size="xs" c="dimmed">
{t("description")}
</Text>
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
{elements.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="xl">
{tBoard("setting.section.layout.preview.empty")}
</Text>
) : (
<Stack gap="xs">
{rootSections.map((section) => {
const sectionElements = elements.filter((element) => element.sectionId === section.id);
const columnCount = getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset));
if (sectionElements.length === 0 || columnCount === 0) return null;
return (
<Stack key={section.id} gap={4} className={classes.section}>
<Box
className={classes.grid}
style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}
>
{sectionElements.map((element) => {
const item =
element.type === "item" ? board.items.find((candidate) => candidate.id === element.id) : null;
const appId =
item?.kind === "app" && typeof item.options.appId === "string" ? item.options.appId : null;
const app = appId ? appsById.get(appId) : undefined;
const container =
element.type === "section"
? board.sections.find((candidate) => candidate.id === element.id)
: undefined;
const containerLabel =
container?.kind === "container" && container.options.title
? container.options.title
: tSection("container.untitled");
const label = app?.name ?? (item ? getWidgetName(item.kind, t) : containerLabel);
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
return (
<Tooltip key={`${element.type}-${element.id}`} label={label} openDelay={350}>
<Box
role="img"
aria-label={label}
className={`${classes.tile} ${element.type === "section" ? classes.containerTile : ""}`}
style={{
gridColumn: `${element.xOffset + 1} / span ${element.width}`,
gridRow: `${element.yOffset + 1} / span ${element.height}`,
}}
>
<Group gap={4} wrap="nowrap" className={classes.tileContent}>
<ThemeIcon size="sm" variant="light" radius="sm" style={{ flexShrink: 0 }}>
{app ? (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={false}
alt=""
className={classes.appIcon}
/>
) : (
<WidgetIcon size={13} stroke={1.7} />
)}
</ThemeIcon>
<Text component="span" size="xs" fw={500} className={classes.tileLabel}>
{label}
</Text>
</Group>
</Box>
</Tooltip>
);
})}
</Box>
</Stack>
);
})}
</Stack>
)}
</Box>
</Stack>
);
};
@@ -2,54 +2,6 @@
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
}
.scalingSettings {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.6fr);
gap: var(--mantine-spacing-xl);
padding: var(--mantine-spacing-lg);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
}
.scalingControls {
align-self: center;
min-width: 0;
}
.fixedItemSizePreview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-md);
margin: 0;
min-width: 0;
min-height: 200px;
}
.fixedItemSizePreviewTile {
display: grid;
flex: 0 0 auto;
place-items: center;
color: var(--mantine-primary-color-contrast);
background: var(--mantine-primary-color-filled);
border: 1px solid var(--mantine-primary-color-filled-hover);
border-radius: var(--mantine-radius-md);
}
@media (max-width: 48em) {
.scalingSettings {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 62em) {
.layoutPreview {
order: -1;
}
}
.gutterPreview {
display: grid;
gap: 6px;
@@ -89,7 +41,6 @@
}
@media (prefers-reduced-motion: reduce) {
.fixedItemSizePreviewTile,
.gutterPreview {
transition: none;
}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
Badge,
@@ -12,8 +12,6 @@ import {
Input,
NumberInput,
Paper,
Select,
Alert,
Slider,
Stack,
Switch,
@@ -23,7 +21,6 @@ import {
import { IconEdit, IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
import { createId } from "@homarr/common";
import type { UseFormReturnType } from "@homarr/form";
import { showErrorNotification } from "@homarr/notifications";
@@ -33,7 +30,6 @@ import { InlineConfirmButton } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { Board } from "../../_types";
import { LayoutPreview } from "./_layout-preview";
import { ScalingSettings } from "./_scaling";
import type { FormValues } from "./_settings-form";
import classes from "./_layout.module.css";
@@ -42,22 +38,11 @@ interface Props {
form: UseFormReturnType<FormValues>;
isSaving: boolean;
saveSettingsAsync: () => Promise<FormValues | null>;
selectedLayoutId: string | null;
setSelectedLayoutId: (value: string | null) => void;
apps: RouterOutputs["app"]["byIds"];
}
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
export const LayoutSettingsContent = ({
board,
form,
isSaving,
saveSettingsAsync,
selectedLayoutId,
setSelectedLayoutId,
apps,
}: Props) => {
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board");
const tLayout = useI18n("layout");
const tCommon = useI18n("common");
@@ -73,6 +58,18 @@ export const LayoutSettingsContent = ({
});
},
});
const appIds = useMemo(
() =>
Array.from(
new Set(
board.items.flatMap((item) =>
item.kind === "app" && typeof item.options.appId === "string" ? [item.options.appId] : [],
),
),
),
[board.items],
);
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
const openLayoutEditorAsync = async (layout: FormValues["layouts"][number]) => {
setEditingLayoutId(layout.id);
@@ -109,14 +106,10 @@ export const LayoutSettingsContent = ({
const displayedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
const activeLayoutId =
displayedLayouts.find(({ layout }) => layout.id === selectedLayoutId)?.layout.id ?? baseLayout?.id;
return (
<SectionCard title={tBoard("setting.section.layout.title")}>
<Stack gap="lg">
<ScalingSettings form={form} />
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2} maw="52rem">
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
@@ -140,169 +133,141 @@ export const LayoutSettingsContent = ({
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
form.insertListItem("layouts", newLayout, insertionIndex);
setSelectedLayoutId(newLayout.id);
}}
>
{tBoard("setting.section.layout.responsive.action.add")}
</Button>
</Group>
<Select
label={tBoard("setting.section.layout.preview.layout")}
value={activeLayoutId}
onChange={setSelectedLayoutId}
allowDeselect={false}
error={form.errors.layouts}
data={displayedLayouts.map(({ layout, index }) => ({
value: layout.id,
label: `${layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)} · ${layout.columnCount} ${tBoard("setting.section.layout.preview.columns")}${Object.keys(form.errors).some((key) => key.startsWith(`layouts.${index}.`)) ? " ⚠" : ""}`,
}))}
/>
{displayedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
{Object.entries(form.errors)
.filter(([key]) => key.startsWith("layouts."))
.map(([key, error]) => (
<Alert key={key} color="red">
{error}
</Alert>
))}
{displayedLayouts
.filter(({ layout }) => layout.id === activeLayoutId)
.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
return (
<Fieldset
key={layout.id}
legend={
<Group gap="xs">
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
<Badge size="sm" variant="default">
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
</Badge>
</Group>
}
bg="transparent"
>
<Grid gap="xl" align="flex-start">
<Grid.Col span={{ base: 12, md: 4 }}>
<Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
<Input.Wrapper
label={tLayout("field.columnCount.label")}
description={tLayout("field.columnCount.description")}
>
<Slider
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
mt="xs"
mb="lg"
min={1}
max={24}
step={1}
marks={[1, 6, 12, 18, 24].map((value) => ({ value, label: String(value) }))}
styles={{
markLabel: { color: "light-dark(var(--mantine-color-black), var(--mantine-color-white))" },
}}
value={layout.columnCount}
onChange={(columnCount) => {
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
}}
/>
</Input.Wrapper>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={tLayout("field.breakpoint.label")}
description={
layout.role === "mobile"
? tBoard("setting.section.layout.mobile.breakpointDescription")
: tLayout("field.breakpoint.description")
}
disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }}
min={layout.role === "mobile" ? 0 : 1}
max={32767}
allowDecimal={false}
/>
{layout.role !== "mobile" && (
<GutterSettings
left={layout.leftGutterColumnCount}
right={layout.rightGutterColumnCount}
columnCount={layout.columnCount}
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
onRightChange={(value) =>
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)
}
/>
)}
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 8 }} className={classes.layoutPreview}>
{sourceLayout && (
<LayoutPreview
board={board}
layout={layout}
layouts={form.values.layouts}
sourceLayout={sourceLayout}
apps={apps}
settings={form.values}
/>
)}
</Grid.Col>
</Grid>
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
<Group gap="xs">
{layout.role !== "base" && persistedLayout && (
<InlineConfirmButton
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
>
{tBoard("setting.section.layout.reset.action")}
</InlineConfirmButton>
)}
{layout.role === "custom" && (
<Button
type="button"
variant="subtle"
color="red"
disabled={isSaving}
onClick={() => {
form.setFieldValue(
"layouts",
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
);
}}
>
{tCommon("action.remove")}
</Button>
)}
</Group>
<Button
type="button"
variant="default"
leftSection={<IconEdit size={16} color="var(--mantine-color-red-5)" />}
loading={editingLayoutId === layout.id}
disabled={isSaving || !form.isValid()}
onClick={() => void openLayoutEditorAsync(layout)}
>
{form.isDirty() || !persistedLayout
? tBoard("setting.section.layout.edit.saveAndEdit")
: tBoard("setting.section.layout.edit.action")}
</Button>
return (
<Fieldset
key={layout.id}
legend={
<Group gap="xs">
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
<Badge size="sm" variant="default">
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
</Badge>
</Group>
</Fieldset>
);
})}
}
bg="transparent"
>
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
<Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
<Input.Wrapper label={tLayout("field.columnCount.label")}>
<Slider
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
mt="xs"
min={1}
max={24}
step={1}
marks={[1, 6, 12, 18, 24].map((value) => ({ value, label: String(value) }))}
styles={{
markLabel: { color: "light-dark(var(--mantine-color-black), var(--mantine-color-white))" },
}}
value={layout.columnCount}
onChange={(columnCount) => {
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
}}
/>
</Input.Wrapper>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={tLayout("field.breakpoint.label")}
description={
layout.role === "mobile"
? tBoard("setting.section.layout.mobile.breakpointDescription")
: tLayout("field.breakpoint.description")
}
disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }}
min={layout.role === "mobile" ? 0 : 1}
max={32767}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
{sourceLayout && (
<LayoutPreview
board={board}
layout={layout}
layouts={form.values.layouts}
sourceLayout={sourceLayout}
apps={apps}
/>
)}
</Grid.Col>
{layout.role !== "mobile" && (
<Grid.Col span={{ base: 12, lg: 4 }}>
<GutterSettings
left={layout.leftGutterColumnCount}
right={layout.rightGutterColumnCount}
columnCount={layout.columnCount}
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
/>
</Grid.Col>
)}
</Grid>
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
<Group gap="xs">
{layout.role !== "base" && persistedLayout && (
<InlineConfirmButton
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
>
{tBoard("setting.section.layout.reset.action")}
</InlineConfirmButton>
)}
{layout.role === "custom" && (
<Button
type="button"
variant="subtle"
color="red"
disabled={isSaving}
onClick={() => {
form.setFieldValue(
"layouts",
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
);
}}
>
{tCommon("action.remove")}
</Button>
)}
</Group>
<Button
type="button"
variant="default"
leftSection={<IconEdit size={16} color="var(--mantine-color-red-5)" />}
loading={editingLayoutId === layout.id}
disabled={isSaving || !form.isValid()}
onClick={() => void openLayoutEditorAsync(layout)}
>
{form.isDirty() || !persistedLayout
? tBoard("setting.section.layout.edit.saveAndEdit")
: tBoard("setting.section.layout.edit.action")}
</Button>
</Group>
</Fieldset>
);
})}
</Stack>
</SectionCard>
);
@@ -1,103 +0,0 @@
import { Box, Group, Input, NumberInput, SegmentedControl, Slider, Stack, Text } from "@mantine/core";
import { BOARD_FIXED_ITEM_SIZE_MAX, BOARD_FIXED_ITEM_SIZE_MIN } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import {
calculateBoardUiScale,
calculateFixedBoardCanvasScale,
normalizeFixedItemSize,
} from "~/components/board/layout/scaling";
import appearanceClasses from "~/components/board/layout/canvas-appearance.module.css";
import type { FormValues } from "./_settings-form";
import classes from "./_layout.module.css";
export const ScalingSettings = ({ form }: { form: UseFormReturnType<FormValues> }) => {
const t = useI18n("board");
const size = normalizeFixedItemSize(form.values.fixedItemSize);
const canvasScale = calculateFixedBoardCanvasScale(size);
const sampleUiScale = (canvasScale * calculateBoardUiScale(canvasScale)) / 2;
return (
<Box className={classes.scalingSettings}>
<Stack gap="md" className={classes.scalingControls}>
<Input.Wrapper label={t("setting.section.layout.scaling.title")}>
<SegmentedControl
fullWidth
mt={6}
aria-label={t("setting.section.layout.scaling.title")}
value={form.values.fixedScaling ? "fixed" : "responsive"}
onChange={(mode) => form.setFieldValue("fixedScaling", mode === "fixed")}
data={[
{ value: "fixed", label: t("field.fixedScaling.label") },
{ value: "responsive", label: t("setting.section.layout.scaling.fit") },
]}
/>
</Input.Wrapper>
<Text size="sm" c="dimmed">
{form.values.fixedScaling
? t("field.fixedScaling.description")
: t("setting.section.layout.scaling.fitDescription")}
</Text>
<Group align="end" wrap="nowrap" gap="lg">
<NumberInput
label={t("field.fixedItemSize.label")}
min={BOARD_FIXED_ITEM_SIZE_MIN}
max={BOARD_FIXED_ITEM_SIZE_MAX}
step={10}
allowDecimal={false}
disabled={!form.values.fixedScaling}
suffix=" px"
w={150}
{...form.getInputProps("fixedItemSize")}
onBlur={() => form.setFieldValue("fixedItemSize", normalizeFixedItemSize(form.getValues().fixedItemSize))}
/>
<Box flex={1} pb={8}>
<Slider
thumbLabel={t("field.fixedItemSize.label")}
min={BOARD_FIXED_ITEM_SIZE_MIN}
max={BOARD_FIXED_ITEM_SIZE_MAX}
step={10}
disabled={!form.values.fixedScaling}
value={size}
onChange={(value) => form.setFieldValue("fixedItemSize", value)}
label={(value) => `${value} px`}
/>
</Box>
</Group>
<Text size="xs" c="dimmed">
{t("setting.section.layout.scaling.sizeDescription")}
</Text>
</Stack>
<Box
component="figure"
className={classes.fixedItemSizePreview}
aria-label={t("field.fixedItemSize.previewLabel", { size })}
>
<Box
className={`${classes.fixedItemSizePreviewTile} ${appearanceClasses.appearance}`}
style={{
width: size / 2,
height: size / 2,
borderRadius: `var(--mantine-radius-${form.values.itemRadius})`,
"--board-canvas-ui-scale": sampleUiScale,
}}
aria-hidden="true"
>
<Text size="sm" fw={600}>
1 × 1
</Text>
</Box>
<Box component="figcaption" ta="center">
<Text size="sm" fw={600} ff="monospace">
{t("field.fixedItemSize.previewValue", { size })}
</Text>
<Text size="xs" c="dimmed">
{t("setting.section.layout.scaling.halfSize")}
</Text>
</Box>
</Box>
</Box>
);
};
@@ -1,43 +0,0 @@
.settings {
--mantine-color-dimmed: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.tabs {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.tabs > button {
flex-shrink: 0;
}
.panel {
padding-top: var(--mantine-spacing-lg);
padding-bottom: 5rem;
min-width: 0;
}
.appearance {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
gap: var(--mantine-spacing-lg);
}
.appearancePreview {
position: sticky;
top: calc(var(--app-shell-header-offset, 0px) + var(--mantine-spacing-md));
min-width: 0;
}
@media (max-width: 62em) {
.appearance {
grid-template-columns: minmax(0, 1fr);
}
.appearancePreview {
position: static;
grid-row: 1;
}
}
@@ -1,16 +1,13 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Button, Stack, Tabs, VisuallyHidden } from "@mantine/core";
import { IconAdjustments, IconCode, IconLayoutGrid, IconPalette, IconUsers } from "@tabler/icons-react";
import { useEffect, useRef } from "react";
import { Button, Stack } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useUpdateBoard } from "@homarr/boards/updater";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { normalizeFixedItemSize } from "~/components/board/layout/scaling";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
@@ -23,7 +20,8 @@ import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-guard";
import type { Board } from "../../_types";
import { AppearancePlayground } from "./_appearance-playground";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { CustomCssSettingsContent } from "./_customCss";
@@ -31,7 +29,6 @@ import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
import classes from "./_settings-form.module.css";
const boardSettingsFormSchema = boardSavePartialSettingsSchema
.extend({
@@ -62,8 +59,6 @@ const PARTIAL_FORM_KEYS = [
"itemRadius",
"customCss",
"disableStatus",
"fixedScaling",
"fixedItemSize",
] as const;
const buildInitialValues = (board: Board): FormValues => ({
@@ -82,8 +77,6 @@ const buildInitialValues = (board: Board): FormValues => ({
itemRadius: board.itemRadius,
customCss: board.customCss ?? "",
disableStatus: board.disableStatus,
fixedScaling: board.fixedScaling ?? false,
fixedItemSize: normalizeFixedItemSize(board.fixedItemSize ?? ""),
layouts: normalizeMobileLayoutGutters(board.layouts),
});
@@ -101,21 +94,6 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board);
const [activeTab, setActiveTab] = useState<string | null>("layout");
const [selectedLayoutId, setSelectedLayoutId] = useState<string | null>(null);
const appIds = useMemo(
() =>
Array.from(
new Set(
board.items.flatMap((item) => {
if (item.kind === "app" && typeof item.options.appId === "string") return [item.options.appId];
return [];
}),
),
),
[board.items],
);
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
const form = useZodForm(boardSettingsFormSchema, {
initialValues: buildInitialValues(board),
@@ -142,22 +120,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
};
}, [updateBoard]);
const revealValidationErrors = (errors: typeof form.errors) => {
const firstField = Object.keys(errors)[0];
if (firstField) setActiveTab(getSettingsTabForField(firstField));
const layoutField = Object.keys(errors).find((field) => /^layouts\.\d+\./.test(field));
if (layoutField) {
const layout = form.values.layouts[Number(layoutField.split(".")[1])];
if (layout) setSelectedLayoutId(layout.id);
}
};
const saveSettingsAsync = async (values: FormValues): Promise<FormValues | null> => {
const validation = form.validate();
if (validation.hasErrors) {
revealValidationErrors(validation.errors);
return null;
}
const defaults = initialValuesRef.current;
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
@@ -202,7 +165,6 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
};
const handleDiscard = () => {
form.clearErrors();
form.setValues(initialValuesRef.current);
form.resetDirty();
updateBoard((previous) => ({
@@ -215,99 +177,43 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
return (
<Tabs
value={activeTab}
onChange={setActiveTab}
keepMounted={false}
classNames={{ root: classes.settings, list: classes.tabs, panel: classes.panel }}
>
<VisuallyHidden>
<h2>{tSection("navigation")}</h2>
</VisuallyHidden>
<Tabs.List aria-label={tSection("navigation")}>
<Tabs.Tab value="layout" leftSection={<IconLayoutGrid size={16} />}>
{tSection("layout.title")}
</Tabs.Tab>
<Tabs.Tab value="appearance" leftSection={<IconPalette size={16} />}>
{tSection("appearance.title")}
</Tabs.Tab>
<Tabs.Tab value="general" leftSection={<IconAdjustments size={16} />}>
{tSection("general.title")}
</Tabs.Tab>
<Tabs.Tab value="advanced" leftSection={<IconCode size={16} />}>
{tSection("advanced")}
</Tabs.Tab>
{hasFullAccess && (
<Tabs.Tab value="access" leftSection={<IconUsers size={16} />}>
{tSection("access.title")}
</Tabs.Tab>
)}
</Tabs.List>
<form onSubmit={form.onSubmit((values) => void saveSettingsAsync(values), revealValidationErrors)}>
{Object.keys(form.errors).length > 0 && (
<Alert color="red" mt="md">
{tSection("validationError")}
</Alert>
)}
<Tabs.Panel value="layout">
<Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void saveSettingsAsync(values))}>
<Stack gap="xl">
<GeneralSettingsContent board={board} form={form} />
<LayoutSettingsContent
board={board}
form={form}
isSaving={isPending}
saveSettingsAsync={() => saveSettingsAsync(form.values)}
selectedLayoutId={selectedLayoutId}
setSelectedLayoutId={setSelectedLayoutId}
apps={apps}
/>
</Tabs.Panel>
<Tabs.Panel value="appearance">
<AppearancePlayground board={board} form={form} apps={apps} selectedLayoutId={selectedLayoutId} />
</Tabs.Panel>
<Tabs.Panel value="general">
<Stack gap="lg">
<GeneralSettingsContent board={board} form={form} />
<BehaviorSettingsContent form={form} />
</Stack>
</Tabs.Panel>
<Tabs.Panel value="advanced">
<BackgroundSettingsContent form={form} />
<ColorSettingsContent form={form} />
<CustomCssSettingsContent form={form} />
</Tabs.Panel>
<BehaviorSettingsContent form={form} />
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("action.discard")}
</Button>
<Button loading={isPending} type="submit">
{t("action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
</form>
{hasFullAccess && (
<Tabs.Panel value="access">
<Stack gap="lg">
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
<DangerZoneSettingsContent hideVisibility={hideVisibility} />
</Stack>
</Tabs.Panel>
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
)}
</Tabs>
);
};
const getSettingsTabForField = (field: string) => {
if (field.startsWith("layouts") || field === "fixedScaling" || field === "fixedItemSize") return "layout";
if (field === "customCss") return "advanced";
if (
field.startsWith("background") ||
["primaryColor", "secondaryColor", "opacity", "iconColor", "itemRadius"].includes(field)
)
return "appearance";
return "general";
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
</Stack>
);
};
// Previously part of the general settings section; applied on the unified save
@@ -20,7 +20,6 @@ import { env } from "~/env";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import { BoardProviders } from "./_providers";
import { BoardEditingProvider } from "./(content)/_editing-provider";
import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context";
@@ -107,25 +106,20 @@ export const createBoardLayout = <TParams extends Params>({
<BoardReadyProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<ModalProvider>
<BoardEditingProvider
enabled={Boolean(headerBoardEditAction) && hasChangeAccess}
demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY}
>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false}
/>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
</BoardEditingProvider>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false}
/>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
</ModalProvider>
</BoardMantineProvider>
</BoardReadyProvider>
@@ -1,6 +1,6 @@
"use client";
import { Alert, Anchor, Text } from "@mantine/core";
import { Anchor, Badge, Group } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
@@ -11,14 +11,13 @@ export function CustomWidgetBetaBanner() {
const t = useI18n("customWidget.betaBanner");
return (
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
<Text size="sm">
{t("description")}{" "}
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
{t("discussionLink")}
</Anchor>{" "}
{t("closing")}
</Text>
</Alert>
<Group gap="xs">
<Badge variant="light" color="yellow" leftSection={<IconFlask size={12} />}>
{t("title")}
</Badge>
<Anchor size="xs" href={FEEDBACK_URL} target="_blank" rel="noopener noreferrer">
{t("discussionLink")}
</Anchor>
</Group>
);
}
@@ -1 +1,57 @@
export { CodeEditor } from "~/components/custom-widgets/code-editor";
"use client";
import { useSyncExternalStore, useState, useEffect, useCallback, useMemo } from "react";
import type { ComponentProps } from "react";
import { CodeEditor as SharedCodeEditor } from "~/components/custom-widgets/code-editor";
import { useOptionalCustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
const subscribeEmpty = () => () => undefined;
const zero = () => 0;
export function CodeEditor({
localHistory = false,
...props
}: ComponentProps<typeof SharedCodeEditor> & { localHistory?: boolean }) {
const [insert, setInsert] = useState<{ text: string; key: number }>();
const [reveal, setReveal] = useState<{ index: number; key: number }>();
useEffect(() => {
const receive = (event: Event) => {
const detail = (event as CustomEvent<{ editorId: string; index: number }>).detail;
if (detail.editorId === props.id) setReveal({ index: detail.index, key: Date.now() });
};
const receiveInsert = (event: Event) => {
const detail = (event as CustomEvent<{ editorId: string; text: string }>).detail;
if (detail.editorId === props.id && !props.readOnly) setInsert({ text: detail.text, key: Date.now() });
};
window.addEventListener("homarr:widget-code-insert", receiveInsert);
window.addEventListener("homarr:widget-code-reveal", receive);
return () => {
window.removeEventListener("homarr:widget-code-reveal", receive);
window.removeEventListener("homarr:widget-code-insert", receiveInsert);
};
}, [props.id, props.readOnly]);
const store = useOptionalCustomWidgetFormDocumentStore();
const getHistoryState = useCallback(() => {
if (!store) return 0;
return Number(store.canUndo()) + 2 * Number(store.canRedo());
}, [store]);
const historyState = useSyncExternalStore(store?.subscribeEditor ?? subscribeEmpty, getHistoryState, zero);
const history = useMemo(() => {
if (!store || localHistory || props.readOnly || props.id.startsWith("preview-")) return undefined;
return {
canUndo: Boolean(historyState & 1),
canRedo: Boolean(historyState & 2),
undo: store.undo,
redo: store.redo,
};
}, [store, localHistory, props.readOnly, props.id, historyState]);
return (
<div data-workbench-local-input={localHistory || undefined}>
<SharedCodeEditor
{...props}
history={history}
insertText={insert?.text ?? props.insertText}
insertKey={insert?.key ?? props.insertKey}
revealIndex={reveal?.index}
revealKey={reveal?.key ?? props.revealKey}
/>
</div>
);
}
@@ -1,43 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
describe("Copy AI prompt", () => {
it("uses one self-contained prompt without an embedded-skill branch", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(source).toContain("buildCustomWidgetAiPrompt");
expect(source).not.toContain("embedded-authoring-prompt");
expect(source).not.toContain("Menu");
});
it("copies a complete prompt within the public size limit", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("exactly one complete `json` fenced block");
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
});
it("keeps AI actions and section navigation readable on narrow screens", () => {
const card = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx`,
"utf8",
);
const styles = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
"utf8",
);
const actions = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(card).toContain('<Accordion variant="contained">');
expect(card).not.toContain("defaultValue");
expect(actions).toContain("<ActionIcon");
expect(styles).toContain(".sectionNav > a");
expect(styles).toContain("flex: 0 0 auto");
});
});
@@ -3,7 +3,17 @@
import { useEffect, useRef } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import type { Icon } from "@tabler/icons-react";
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import {
Button,
Card,
getContrastColor,
Group,
Stack,
Text,
ThemeIcon,
useComputedColorScheme,
useMantineTheme,
} from "@mantine/core";
import {
IconAlertCircle,
IconAlertTriangle,
@@ -24,6 +34,7 @@ import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-
import { useCustomWidgetFormDocumentDirty } from "./_custom-widget-form-state";
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
import { useWorkbenchSelection } from "./_flow/selection";
import classes from "./_custom-widget-form.module.css";
const sectionLinks = [
@@ -46,10 +57,11 @@ export function EditorSection({
icon: Icon;
children: ReactNode;
}) {
const compact = useWorkbenchSelection() !== undefined;
return (
<Card id={id} component="section" withBorder p="lg" className={classes.editorSection}>
<Card id={id} component="section" withBorder={!compact} p={compact ? 0 : "lg"} className={classes.editorSection}>
<Stack gap="md">
<Group gap="sm">
<Group gap="sm" data-workbench-section-title>
<ThemeIcon variant="light" size="lg">
<SectionIcon size={18} />
</ThemeIcon>
@@ -64,6 +76,7 @@ export function EditorSection({
}
export function SaveActions({
formId,
dirty,
savePending,
previewPending,
@@ -71,6 +84,7 @@ export function SaveActions({
mode,
onPreview,
}: {
formId?: string;
dirty: boolean;
savePending: boolean;
previewPending: boolean;
@@ -80,6 +94,13 @@ export function SaveActions({
}) {
const t = useI18n("customWidget.workbench");
const tCommon = useI18n("common");
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme();
const submitColor = getContrastColor({
color: theme.primaryColor,
colorScheme,
theme: { ...theme, luminanceThreshold: 0.179 },
});
return (
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
@@ -103,7 +124,13 @@ export function SaveActions({
>
{previewPending ? t("action.previewLoading") : t("action.preview")}
</Button>
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
<Button
form={formId}
type="submit"
loading={savePending}
disabled={invalid || previewPending}
style={{ "--button-color": submitColor }}
>
{mode === "create" ? tCommon("action.create") : tCommon("action.save")}
</Button>
</Group>
@@ -143,11 +170,13 @@ export function CustomWidgetSectionNavigation({
}
export function CustomWidgetSaveActions({
formId,
mode,
savePending,
previewPending,
onPreview,
}: {
formId?: string;
mode: "create" | "edit";
savePending: boolean;
previewPending: boolean;
@@ -158,6 +187,7 @@ export function CustomWidgetSaveActions({
const hasDiagnostics = useCustomWidgetFormAnalysisField("hasDiagnostics");
return (
<SaveActions
formId={formId}
dirty={dirty}
savePending={savePending}
previewPending={previewPending}
@@ -13,60 +13,10 @@ import {
import type { ReactNode } from "react";
import type { UseFormReturnType } from "@mantine/form";
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
export interface CustomWidgetFormDocumentStore {
getValues(): CustomWidgetFormValues;
setValues(values: CustomWidgetFormValues): void;
getDirty(): boolean;
subscribe(listener: () => void): () => void;
markSaved(values: CustomWidgetFormValues): void;
}
const documentValueKeys = customWidgetFormSchema.keyof().options;
function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) {
if (Object.keys(left).length !== documentValueKeys.length) return false;
if (Object.keys(right).length !== documentValueKeys.length) return false;
return documentValueKeys.every(
(key) => Object.hasOwn(left, key) && Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]),
);
}
export function createCustomWidgetFormDocumentStore(
initialValues: CustomWidgetFormValues,
): CustomWidgetFormDocumentStore {
let values = initialValues;
let persistedValues = initialValues;
let dirty = false;
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
return {
getValues: () => values,
setValues: (nextValues) => {
if (Object.is(values, nextValues)) return;
values = nextValues;
dirty = !areDocumentValuesEqual(nextValues, persistedValues);
notify();
},
getDirty: () => dirty,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
markSaved: (savedValues) => {
const wasDirty = dirty;
persistedValues = savedValues;
dirty = !areDocumentValuesEqual(values, persistedValues);
if (wasDirty !== dirty) notify();
},
};
}
import type { CustomWidgetFormDocumentStore } from "./_flow/document-store";
export { createCustomWidgetFormDocumentStore } from "./_flow/document-store";
export type { CustomWidgetFormDocumentStore } from "./_flow/document-store";
const CustomWidgetFormDocumentContext = createContext<CustomWidgetFormDocumentStore | null>(null);
@@ -80,6 +30,10 @@ export function CustomWidgetFormDocumentProvider({
return <CustomWidgetFormDocumentContext.Provider value={store}>{children}</CustomWidgetFormDocumentContext.Provider>;
}
export function useOptionalCustomWidgetFormDocumentStore() {
return useContext(CustomWidgetFormDocumentContext);
}
export function useCustomWidgetFormDocumentStore() {
const store = useContext(CustomWidgetFormDocumentContext);
if (!store) throw new Error("Custom widget form document provider is missing");
@@ -104,9 +58,14 @@ export function useDeferredCustomWidgetFormDocumentValues() {
}, 150);
};
const unsubscribe = store.subscribe(scheduleUpdate);
const unsubscribeRenames = store.onNodeRenames(() => {
clearTimeout(timeout);
setValues(store.getValues());
});
return () => {
clearTimeout(timeout);
unsubscribe();
unsubscribeRenames();
};
}, [store]);
return values;
@@ -120,7 +79,7 @@ export function useCustomWidgetFormDocumentField<Key extends keyof CustomWidgetF
export function useCustomWidgetFormDocumentDirty() {
const store = useCustomWidgetFormDocumentStore();
return useSyncExternalStore(store.subscribe, store.getDirty, store.getDirty);
return useSyncExternalStore(store.subscribeEditor, store.getDirty, store.getDirty);
}
export function useCustomWidgetFormDocumentBridge(
@@ -129,6 +88,7 @@ export function useCustomWidgetFormDocumentBridge(
) {
const formRef = useRef(form);
formRef.current = form;
useEffect(() => store.onRestore((values) => formRef.current.setValues(values)), [store]);
return useMemo(
() =>
new Proxy({} as UseFormReturnType<CustomWidgetFormValues>, {
@@ -19,7 +19,8 @@ const PREVIEW_QUERY_CONCURRENCY = 4;
export function buildDefinition(values: CustomWidgetFormValues) {
return customWidgetDefinitionSchema.safeParse({
$schema: "homarr-custom-widget-v2",
$schema: values.extensions?.trim() ? "homarr-custom-widget-v3" : "homarr-custom-widget-v2",
...(values.extensions?.trim() ? { extensions: parseJson(values.extensions) } : {}),
name: values.name,
description: values.description || undefined,
iconUrl: values.iconUrl || undefined,
@@ -59,6 +60,7 @@ export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrC
requests: JSON.stringify(widget.requests, null, 2),
options: JSON.stringify(widget.options, null, 2),
template: widget.template,
extensions: widget.$schema === "homarr-custom-widget-v3" ? JSON.stringify(widget.extensions ?? {}, null, 2) : "",
});
}
@@ -104,9 +106,14 @@ export function isRuntimeParams(
}
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const requests = Object.entries(definition.requests).filter(
([, entry]) => entry.kind === "query" && entry.trigger === "load",
);
const requests = [
...Object.entries(definition.requests)
.filter(([, entry]) => entry.kind === "query" && entry.trigger === "load")
.map(([id]) => ({ id, native: false })),
...Object.entries(definition.extensions?.native ?? {})
.filter(([, entry]) => entry.kind === "query" && entry.trigger === "load")
.map(([id]) => ({ id, native: true })),
];
const results: Array<{
requestId: string;
data: unknown;
@@ -119,9 +126,11 @@ export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessi
nextIndex += 1;
const request = requests[index];
if (!request) return;
const [requestId] = request;
const requestId = request.id;
try {
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
const result = await (request.native
? fetchApi.customWidget.nativeQuery.query({ previewSessionId: sessionId, nativeId: requestId, params: {} })
: fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} }));
results[index] = { requestId, data: result.data, status: { loading: false, ...result } };
} catch (error) {
results[index] = {
@@ -1,8 +1,8 @@
"use client";
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { customWidgetEditorLayoutSchema, EMPTY_EDITOR_LAYOUT } from "@homarr/custom-widgets/core";
import { memo, useId, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, FormEventHandler, SetStateAction } from "react";
import { Box, Paper, SegmentedControl, Stack } from "@mantine/core";
import type { CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { customWidgetFormSchema, DEFAULT_CUSTOM_WIDGET_FORM_VALUES } from "@homarr/custom-widgets/workbench";
@@ -14,7 +14,6 @@ import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest
import {
CustomWidgetOptionsSnapshotSync,
CustomWidgetSaveActions,
CustomWidgetSectionNavigation,
CustomWidgetUnsavedChangesGuard,
} from "./_custom-widget-form-layout";
import {
@@ -40,12 +39,15 @@ import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
import { analyzeCustomWidgetAiDiagnostics, CustomWidgetFormAnalysisProvider } from "./_use-custom-widget-form-analysis";
import { FlowWorkbench } from "./_flow/workbench";
import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps {
mode: "create" | "edit";
initialValues?: Partial<CustomWidgetFormValues>;
definitionId?: string;
editorLayout?: string | null;
savedRevision?: string;
}
type MobilePane = "configure" | "preview";
@@ -69,7 +71,7 @@ interface CustomWidgetFormViewProps {
savePending: boolean;
previewPending: boolean;
onSubmit: FormEventHandler<HTMLFormElement>;
onPreview(): void;
onPreview(): Promise<unknown>;
onPasteAiResponse(): void;
}
@@ -81,35 +83,32 @@ function getAiDraft(values: CustomWidgetFormValues): CustomWidgetAiDraft {
sources: values.sources,
requests: values.requests,
options: values.options,
extensions: values.extensions,
template: values.template,
};
}
const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWidgetFormViewProps) {
const w = useI18n("customWidget.workbench");
const saveFormId = useId();
const documentStore = props.documentStore;
const form = props.form;
const setMobilePane = props.setMobilePane;
const setPreview = props.setPreview;
const { renameRequest, renameOption } = useMemo(
() =>
createCustomWidgetRenameHandlers({
form,
transaction: documentStore.transaction,
renameRequestBinding: documentStore.renameRequestBinding,
invalidWidgetMessage: w("invalidWidget"),
}),
[form, w],
[documentStore, form, w],
);
const readAiDraft = useCallback(() => getAiDraft(documentStore.getValues()), [documentStore]);
const readAiDiagnostics = useCallback(
() => analyzeCustomWidgetAiDiagnostics(documentStore.getValues()),
[documentStore],
);
const handleSectionSelect = useCallback(
(section: string) => {
setMobilePane(section === "preview" ? "preview" : "configure");
},
[setMobilePane],
);
const handleLiveActionsChange = useCallback(
(enabled: boolean) =>
setPreview((current) => ({
@@ -122,54 +121,39 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
return (
<CustomWidgetFormDocumentProvider store={documentStore}>
<CustomWidgetFormAnalysisProvider>
<form onSubmit={props.onSubmit} className={classes.form}>
<div className={classes.form}>
<form id={saveFormId} onSubmit={props.onSubmit} />
<CustomWidgetUnsavedChangesGuard />
<CustomWidgetOptionsSnapshotSync setOptionsSnapshot={props.setOptionsSnapshot} />
<SegmentedControl
className={classes.paneSwitcher}
fullWidth
value={props.mobilePane}
onChange={(value) => {
if (value === "configure" || value === "preview") props.setMobilePane(value);
}}
data={[
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
]}
/>
<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />
<CustomWidgetSectionNavigation onSelect={handleSectionSelect} />
<div className={classes.workbench} data-mobile-pane={props.mobilePane}>
<Stack gap="lg" className={classes.configuration}>
<FlowWorkbench
feedback={<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />}
form={form}
onPreview={props.onPreview}
execution={props.preview}
definitionId={props.definitionId}
actions={
<CustomWidgetSaveActions
formId={saveFormId}
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
}
general={<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />}
sources={<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />}
requests={<CustomWidgetRequestsSection form={form} onRename={renameRequest} />}
options={<CustomWidgetOptionsSection form={form} onRename={renameOption} />}
template={<CustomWidgetTemplateSection form={form} />}
assistant={
<CustomWidgetAiSection
getDraft={readAiDraft}
getDiagnostics={readAiDiagnostics}
onPaste={props.onPasteAiResponse}
/>
<CustomWidgetAdvancedManifest form={form} />
<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />
<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />
<CustomWidgetRequestsSection form={form} onRename={renameRequest} />
<CustomWidgetOptionsSection form={form} onRename={renameOption} />
<CustomWidgetTemplateSection form={form} />
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<CustomWidgetSaveActions
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
</Paper>
</Stack>
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
<Paper p="md" className={classes.actionBar} shadow="sm">
<CustomWidgetSaveActions
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
</Paper>
}
advanced={<CustomWidgetAdvancedManifest form={form} />}
preview={
<CustomWidgetPreviewSection
preview={props.preview}
size={props.previewSize}
@@ -178,20 +162,32 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
onOptionsChange={props.setOptionsSnapshot}
onLiveActionsChange={handleLiveActionsChange}
/>
</Box>
</div>
</form>
}
/>
</div>
</CustomWidgetFormAnalysisProvider>
</CustomWidgetFormDocumentProvider>
);
});
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
export function CustomWidgetForm({
mode,
initialValues,
definitionId,
editorLayout,
savedRevision,
}: CustomWidgetFormProps) {
const flowT = useI18n("customWidget.flow");
const [formInitialValues] = useState<CustomWidgetFormValues>(() => ({
...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
sources: "{}",
requests: "{}",
template: `<Stack align="center" justify="center" h="100%"><Text size="xl" fw={600}>{${JSON.stringify(flowT("welcomeTemplate"))}}</Text></Stack>`,
...initialValues,
}));
const [documentStore] = useState(() => createCustomWidgetFormDocumentStore(formInitialValues));
const [documentStore] = useState(() =>
createCustomWidgetFormDocumentStore(formInitialValues, parseEditorLayout(editorLayout)),
);
const mantineForm = useZodForm(customWidgetFormSchema, {
initialValues: formInitialValues,
mode: "uncontrolled",
@@ -208,6 +204,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
});
const actions = useCustomWidgetFormActions({
savedRevision,
mode,
definitionId,
form,
@@ -222,7 +219,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
latestActions.current = actions;
}, [actions]);
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>((event) => latestActions.current.save(event), []);
const handlePreview = useCallback(() => void latestActions.current.runPreview(), []);
const handlePreview = useCallback(() => latestActions.current.runPreview(), []);
const handlePasteAiResponse = useCallback(() => void latestActions.current.pasteAiResponse(), []);
return (
@@ -250,3 +247,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
/>
);
}
function parseEditorLayout(value?: string | null) {
try {
return customWidgetEditorLayoutSchema.parse(JSON.parse(value ?? "null"));
} catch {
return EMPTY_EDITOR_LAYOUT;
}
}
@@ -26,6 +26,7 @@ export function CustomWidgetIdentifierInput({
};
return (
<TextInput
data-workbench-local-input
label={label}
value={draft}
error={error}
@@ -1,36 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
describe("legacy custom widget migration UI", () => {
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
expect(list).toContain("widget.migrationRequired");
expect(list).toContain('t("page.list.migrationInstructions")');
expect(actions).toContain("legacyMigrationPrompt.fetch");
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
expect(actions).toContain("navigator.clipboard.readText()");
expect(actions).toContain('t("action.pasteMigration")');
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
expect(actions).toContain("legacyId={widget.id}");
});
it("replaces the legacy record through migration and refreshes every consumer", () => {
// The import pipeline is shared by the dialog and the Workshop install page.
const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
expect(importer).toMatch(
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
);
expect(importer).toContain("utils.customWidget.list.invalidate()");
expect(importer).toContain("utils.customWidget.available.invalidate()");
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
expect(dialog).toContain("useCustomWidgetImport({");
expect(dialog).toContain("legacyId,");
});
});
@@ -6,6 +6,8 @@ import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { CustomWidgetIntegrationOption } from "@homarr/widgets/custom-api/integration-option";
import { CodeEditor } from "./_code-editor";
export function DefaultValueEditor({
@@ -22,6 +24,8 @@ export function DefaultValueEditor({
onChange: (value: unknown) => void;
}) {
const t = useI18n("customWidget.workbench.builder");
if (control === "integration")
return <CustomWidgetIntegrationOption label={t("default")} value={value} onChange={onChange} />;
if (control === "switch")
return (
<Switch
@@ -6,7 +6,7 @@ interface PreviewSnapshot {
session: { id: string; liveActions: boolean } | null;
}
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template">;
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template" | "extensions">;
export function createPreviewDisplayData({
candidate,
@@ -23,15 +23,18 @@ export function createPreviewDisplayData({
}): Record<string, unknown> | null {
if (!candidate) return null;
const requests = Object.entries(candidate.requests);
const data = fixture === "empty" ? Object.fromEntries(requests.map(([id]) => [id, []])) : preview.data;
const queryIds = [...requests.map(([id]) => id), ...Object.keys(candidate.extensions?.native ?? {})];
const data =
fixture === "empty" ? Object.fromEntries(queryIds.map((id) => [id, emptyFixture(preview.data[id])])) : preview.data;
const status =
fixture === "loading"
? Object.fromEntries(requests.map(([id]) => [id, { loading: true }]))
? Object.fromEntries(queryIds.map((id) => [id, { loading: true }]))
: fixture === "error"
? Object.fromEntries(requests.map(([id]) => [id, { loading: false, ok: false, error: fixtureError }]))
? Object.fromEntries(queryIds.map((id) => [id, { loading: false, ok: false, error: fixtureError }]))
: preview.status;
return {
template: candidate.template,
extensions: candidate.extensions,
data,
status,
options,
@@ -51,3 +54,10 @@ export function createPreviewDisplayData({
isEditMode: fixture !== "live",
};
}
function emptyFixture(value: unknown, depth = 0): unknown {
if (depth > 10 || Array.isArray(value) || value === undefined) return [];
if (value && typeof value === "object")
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, emptyFixture(entry, depth + 1)]));
return value;
}
@@ -0,0 +1,124 @@
"use client";
import { useMemo } from "react";
import { Alert, Group, Stack, Switch, Tabs, Text } from "@mantine/core";
import { analyzeCustomWidgetAccessibility } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import type { PreviewPanelProps } from "./_flow/preview-panel-types";
import { PreviewRequestData } from "./_flow/preview-request-data";
import { WorkbenchRequestJournal } from "./_flow/request-journal";
import { PreviewStatusDot } from "./_custom-widget-preview-status";
import { PreviewActionControl } from "./_custom-widget-preview-action";
import { JsonPreviewEditor } from "./_json-preview-editor";
export function PreviewTabList({
outcome,
widgetLabel,
}: {
outcome: PreviewPanelProps["preview"]["outcome"];
widgetLabel?: string;
}) {
const t = useI18n("customWidget.workbench.preview");
const flowT = useI18n("customWidget.flow");
return (
<Tabs.List grow>
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
<Tabs.Tab key={tab} value={tab}>
<Group gap={6} wrap="nowrap" justify="center">
{tab === "widget" && widgetLabel ? widgetLabel : t(`tab.${tab}`)}
{tab === "data" && <PreviewStatusDot outcome={outcome} label={t(`status.${outcome}`)} />}
</Group>
</Tabs.Tab>
))}
<Tabs.Tab value="journal">{flowT("requestJournal")}</Tabs.Tab>
</Tabs.List>
);
}
export function PreviewDetailPanels(
props: PreviewPanelProps & {
requestId?: string;
onShowAll(): void;
liveActions: { pending: boolean; toggle(enabled: boolean): void };
},
) {
const t = useI18n("customWidget.workbench.preview");
const candidate = props.candidate;
const candidateTemplate = candidate?.template;
const accessibilityIssues = useMemo(() => {
if (candidateTemplate === undefined) return [];
return analyzeCustomWidgetAccessibility(candidateTemplate);
}, [candidateTemplate]);
return (
<>
<Tabs.Panel value="data" pt="sm">
<PreviewRequestData preview={props.preview} requestId={props.requestId} onShowAll={props.onShowAll} />
</Tabs.Panel>
<Tabs.Panel value="journal" pt="sm">
<WorkbenchRequestJournal />
</Tabs.Panel>
<Tabs.Panel value="options" pt="sm">
<JsonPreviewEditor
id="preview-options"
label={t("instanceOptions")}
value={props.optionsSnapshot}
onChange={props.onOptionsChange}
/>
</Tabs.Panel>
<Tabs.Panel value="actions" pt="sm">
<Stack gap="sm">
<Switch
label={t("liveActions")}
description={t("liveActionsDescription")}
checked={props.preview.session?.liveActions ?? false}
disabled={!props.preview.session || props.liveActions.pending}
onChange={(event) => props.liveActions.toggle(event.currentTarget.checked)}
/>
{Object.entries(candidate?.requests ?? {})
.filter(([, request]) => request.kind === "action")
.map(([id, request]) => (
<PreviewActionControl key={id} request={{ id, ...request }} sessionId={props.preview.session?.id} />
))}
{Object.values(candidate?.requests ?? {}).every((request) => request.kind !== "action") && (
<Text size="sm" c="dimmed">
{t("noActions")}
</Text>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="diagnostics" pt="sm">
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t("containedFailures")}
</Text>
<Text size="sm" fw={600}>
{t("validation.title")}
</Text>
{props.validationIssues.length === 0 ? (
<Alert color="green">{t("validation.ready")}</Alert>
) : (
props.validationIssues.map((issue, index) => (
<Alert key={`${issue.path ?? "widget"}-${index}`} color="red" title={issue.path}>
{issue.message}
</Alert>
))
)}
<Text size="sm" fw={600}>
{t("accessibility.title")}
</Text>
{!candidate ? (
<Alert color="gray">{t("accessibility.pending")}</Alert>
) : accessibilityIssues.length === 0 ? (
<Alert color="green">{t("accessibility.ready")}</Alert>
) : (
accessibilityIssues.map((issue) => (
<Alert key={issue} color="yellow">
{t(`accessibility.${issue}`)}
</Alert>
))
)}
</Stack>
</Tabs.Panel>
</>
);
}
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
@@ -39,6 +39,9 @@ vi.mock("./_code-editor", () => ({
return <div>{props.label}</div>;
},
}));
vi.mock("./_flow/preview-theme", () => ({
WorkbenchPreviewSurface: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
let root: Root;
@@ -76,16 +79,6 @@ afterEach(async () => {
});
describe("Custom Widget preview panel", () => {
test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`,
"utf8",
);
expect(source).not.toContain("forceColorScheme");
expect(source).not.toContain("onThemeChange");
});
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
await act(async () => {
root.render(
@@ -116,21 +109,21 @@ describe("Custom Widget preview panel", () => {
await act(async () => dataTab?.click());
expect(host.textContent).toContain("requestData");
expect(codeEditorProps).toHaveBeenCalledWith(expect.objectContaining({ label: "requestData", readOnly: true }));
expect(host.textContent).not.toContain("invalid");
expect(previewCanvas?.closest<HTMLElement>('[role="tabpanel"]')?.style.display).toBe("none");
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.options"),
);
await act(async () => optionsTab?.click());
expect(host.textContent).toContain("instanceOptions");
expect(host.textContent).not.toContain("invalid");
expect(previewCanvas?.closest<HTMLElement>('[role="tabpanel"]')?.style.display).toBe("none");
const actionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.actions"),
);
await act(async () => actionsTab?.click());
expect(host.textContent).toContain("liveActions");
expect(host.textContent).not.toContain("invalid");
expect(previewCanvas?.closest<HTMLElement>('[role="tabpanel"]')?.style.display).toBe("none");
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.diagnostics"),
@@ -1,70 +1,51 @@
"use client";
import { memo, useMemo, useState } from "react";
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications";
import { memo, useMemo, useState, useRef, useEffect } from "react";
import { Box, Card, Group, Stack, Tabs, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
import { CodeEditor } from "./_code-editor";
import { WorkbenchPreviewWidget, workbenchPreviewWidth } from "./_flow/preview-widget";
import { WorkbenchPreviewControls, WorkbenchPreviewTools } from "./_flow/preview-controls";
import { useWorkbenchPreviewPresentation } from "./_flow/preview-presentation";
import { WorkbenchPreviewThemeControl } from "./_flow/preview-theme";
import type { PreviewPanelProps, PreviewFixture } from "./_flow/preview-panel-types";
import { usePreviewExecution } from "./_flow/preview-execution";
import { usePreviewLiveActions } from "./_flow/preview-live-actions";
import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-widget-preview-status";
import type { PreviewOutcome } from "./_custom-widget-preview-status";
import { PreviewActionControl } from "./_custom-widget-preview-action";
import { PreviewDetailPanels, PreviewTabList } from "./_custom-widget-preview-details";
import { createPreviewDisplayData } from "./_custom-widget-preview-data";
import { CustomWidgetPreviewSizeControl } from "./_custom-widget-preview-size-control";
import { JsonPreviewEditor } from "./_json-preview-editor";
import classes from "./_custom-widget-form.module.css";
const previewWidths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
export interface PreviewState {
data: Record<string, unknown>;
status: Record<string, unknown>;
session: { id: string; expiresAt: number; liveActions: boolean } | null;
outcome: PreviewOutcome;
}
interface PreviewPanelProps {
candidate: HomarrCustomWidgetV2 | null;
validationIssues: Array<{ path?: string; message: string }>;
preview: PreviewState;
size: string;
onSizeChange(value: string): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange(value: Record<string, unknown>): void;
onLiveActionsChange(enabled: boolean): void;
}
export type { PreviewState } from "./_flow/preview-state";
function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
const t = useI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
const actionT = useI18n("common.action");
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
{ sessionId: props.preview.session?.id ?? "" },
{ enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false },
);
const candidate = props.candidate;
const [inspect, setInspect] = useState(false);
const [fixture, setFixture] = useState<PreviewFixture>("live");
const execution = usePreviewExecution();
const liveActions = usePreviewLiveActions(props);
const presentation = useWorkbenchPreviewPresentation();
const canvas = presentation === "canvas";
const lastValid = useRef(props.candidate);
useEffect(() => {
if (props.candidate) lastValid.current = props.candidate;
}, [props.candidate]);
const candidate = props.candidate ?? lastValid.current;
const flowT = useI18n("customWidget.flow");
const candidateExtensions = candidate?.extensions;
const candidateTemplate = candidate?.template;
const candidateRequests = candidate?.requests;
const hasActions =
Object.values(candidateRequests ?? {}).some((request) => request.kind === "action") ||
Object.values(candidateExtensions?.native ?? {}).some((capability) => capability.kind === "action");
const previewCandidate = useMemo(() => {
if (candidateTemplate === undefined || candidateRequests === undefined) return null;
return { template: candidateTemplate, requests: candidateRequests };
}, [candidateRequests, candidateTemplate]);
return { template: candidateTemplate, requests: candidateRequests, extensions: candidateExtensions };
}, [candidateRequests, candidateTemplate, candidateExtensions]);
const previewSnapshot = useMemo(
() => ({ data: props.preview.data, status: props.preview.status, session: props.preview.session }),
[props.preview.data, props.preview.session, props.preview.status],
);
const fixtureError = t("fixtureError");
const accessibilityIssues = useMemo(() => {
if (candidateTemplate === undefined) return [];
return analyzeCustomWidgetAccessibility(candidateTemplate);
}, [candidateTemplate]);
const displayData = useMemo(
() =>
createPreviewDisplayData({
@@ -82,6 +63,7 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
JSON.stringify({
template: candidateTemplate,
requests: candidateRequests,
extensions: candidateExtensions,
fixture,
data: props.preview.data,
status: props.preview.status,
@@ -91,6 +73,7 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
[
candidateRequests,
candidateTemplate,
candidateExtensions,
fixture,
props.optionsSnapshot,
props.preview.data,
@@ -113,161 +96,114 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
? { title: t("result.loading.title"), description: t("result.loading.description") }
: { title: "", description: "" };
const controls = (
<WorkbenchPreviewControls
size={props.size}
onSizeChange={props.onSizeChange}
inspect={inspect}
onInspectChange={setInspect}
fixture={fixture}
onFixtureChange={setFixture}
disabled={!candidate}
/>
);
const result = candidate && (
<PreviewResult
outcome={props.preview.outcome}
title={previewResult.title}
description={previewResult.description}
/>
);
const details = (
<PreviewDetailPanels
{...props}
candidate={candidate}
requestId={execution.requestId}
onShowAll={execution.showAll}
liveActions={liveActions}
/>
);
return (
<Card withBorder p="md">
<Stack gap="md">
<Group gap="xs" justify="space-between">
<CustomWidgetPreviewSizeControl value={props.size} onChange={props.onSizeChange} />
<SegmentedControl
size="xs"
value={fixture}
disabled={!candidate}
onChange={(value) => setFixture(value as typeof fixture)}
data={[
{ value: "live", label: t("fixture.live") },
{ value: "loading", label: t("fixture.loading") },
{ value: "empty", label: t("fixture.empty") },
{ value: "error", label: t("fixture.error") },
]}
/>
</Group>
{candidate && (
<PreviewResult
outcome={props.preview.outcome}
title={previewResult.title}
description={previewResult.description}
/>
<Card
withBorder={!canvas}
p={canvas ? 0 : "xs"}
w={canvas ? workbenchPreviewWidth(props.size) : undefined}
data-preview-presentation={presentation}
className="nodrag nopan nowheel"
>
<Stack gap="xs">
{(props.preview.stale || !props.candidate) && (
<Text size="xs" c="dimmed" component="output">
{flowT("stalePreview")}
</Text>
)}
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
<Tabs.Tab key={tab} value={tab}>
<Group gap={6} wrap="nowrap" justify="center">
{t(`tab.${tab}`)}
{tab === "data" && (
<PreviewStatusDot outcome={props.preview.outcome} label={t(`status.${props.preview.outcome}`)} />
)}
</Group>
</Tabs.Tab>
))}
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
<Box className={classes.previewCanvas}>
<Paper
withBorder
p="sm"
h={360}
w={previewWidths[props.size] ?? 480}
maw="100%"
style={{ overflow: "auto" }}
>
{displayData ? (
<PreviewErrorBoundary
title={errorBoundaryMessages("title")}
description={t("containedFailures")}
retryLabel={actionT("tryAgain")}
resetKeys={[rendererResetKey]}
>
<CustomJsxDisplay data={displayData} />
</PreviewErrorBoundary>
) : (
<Alert color="yellow">{t("invalid")}</Alert>
)}
</Paper>
</Box>
</Tabs.Panel>
<Tabs.Panel value="data" pt="sm">
<CodeEditor
id="preview-data"
label={t("requestData")}
language="json"
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
readOnly
onChange={() => undefined}
/>
</Tabs.Panel>
<Tabs.Panel value="options" pt="sm">
<JsonPreviewEditor
id="preview-options"
label={t("instanceOptions")}
value={props.optionsSnapshot}
onChange={props.onOptionsChange}
/>
</Tabs.Panel>
<Tabs.Panel value="actions" pt="sm">
<Stack gap="sm">
<Switch
label={t("liveActions")}
description={t("liveActionsDescription")}
checked={props.preview.session?.liveActions ?? false}
disabled={!props.preview.session || liveActionsMutation.isPending}
onChange={(event) => {
if (!props.preview.session) return;
const enabled = event.currentTarget.checked;
liveActionsMutation.mutate(
{ sessionId: props.preview.session.id, enabled },
{
onSuccess: () => props.onLiveActionsChange(enabled),
onError: (error) => showErrorNotification({ title: t("liveActions"), message: error.message }),
},
);
}}
/>
{Object.entries(candidate?.requests ?? {})
.filter(([, request]) => request.kind === "action")
.map(([id, request]) => (
<PreviewActionControl key={id} request={{ id, ...request }} sessionId={props.preview.session?.id} />
))}
{Object.values(candidate?.requests ?? {}).every((request) => request.kind !== "action") && (
<Text size="sm" c="dimmed">
{t("noActions")}
{canvas && (
<Group gap="xs" justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<PreviewStatusDot outcome={props.preview.outcome} label={t(`status.${props.preview.outcome}`)} />
{hasActions && (
<Text size="xs" c="dimmed">
{props.preview.session?.liveActions ? t("liveActions") : flowT("previewActionsSimulated")}
</Text>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="diagnostics" pt="sm">
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t("containedFailures")}
</Text>
<Text size="sm" fw={600}>
{t("validation.title")}
</Text>
{props.validationIssues.length === 0 ? (
<Alert color="green">{t("validation.ready")}</Alert>
) : (
props.validationIssues.map((issue, index) => (
<Alert key={`${issue.path ?? "widget"}-${index}`} color="red" title={issue.path}>
{issue.message}
</Alert>
))
)}
<Text size="sm" fw={600}>
{t("accessibility.title")}
</Text>
{!candidate ? (
<Alert color="gray">{t("accessibility.pending")}</Alert>
) : accessibilityIssues.length === 0 ? (
<Alert color="green">{t("accessibility.ready")}</Alert>
) : (
accessibilityIssues.map((issue) => (
<Alert key={issue} color="yellow">
{t(`accessibility.${issue}`)}
</Alert>
))
)}
{props.preview.session && (
<CodeEditor
id="preview-journal"
label={t("journal")}
language="json"
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
readOnly
onChange={() => undefined}
/>
)}
</Stack>
</Group>
<WorkbenchPreviewTools revealVersion={execution.revealVersion}>
<Tabs
value={execution.activeTab}
onChange={execution.setActiveTab}
keepMounted
keepMountedMode="display-none"
>
<PreviewTabList outcome={props.preview.outcome} widgetLabel={flowT("previewDisplay")} />
<Tabs.Panel value="widget" pt="sm">
<Stack gap="sm">
{controls}
<WorkbenchPreviewThemeControl />
{result}
</Stack>
</Tabs.Panel>
{details}
</Tabs>
</WorkbenchPreviewTools>
</Group>
)}
{!canvas && controls}
{!canvas && result}
<Tabs
value={canvas ? "widget" : execution.activeTab}
onChange={execution.setActiveTab}
keepMounted
keepMountedMode="display-none"
>
{!canvas && <PreviewTabList outcome={props.preview.outcome} />}
<Tabs.Panel
key="widget-renderer"
value="widget"
pt={canvas ? 0 : "sm"}
renderRoot={(attributes) => {
if (canvas) {
const canvasAttributes = { ...attributes };
delete canvasAttributes.role;
delete canvasAttributes["aria-labelledby"];
return <section {...canvasAttributes} aria-label={t("tab.widget")} />;
}
return <section {...attributes} />;
}}
>
<Box
className={classes.previewCanvas}
style={canvas ? { padding: 0, background: "none", overflow: "visible" } : undefined}
>
<WorkbenchPreviewWidget
data={displayData}
size={props.size}
inspect={inspect}
resetKey={rendererResetKey}
/>
</Box>
</Tabs.Panel>
{!canvas && details}
</Tabs>
</Stack>
</Card>
@@ -5,6 +5,8 @@ import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
interface RenameHandlersInput {
form: CustomWidgetWorkbenchForm;
transaction(action: () => void): void;
renameRequestBinding(previousId: string, nextId: string): void;
invalidWidgetMessage: string;
}
@@ -16,7 +18,11 @@ export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
return;
}
try {
applyDefinition(input.form, renameCustomWidgetRequest(candidate.data, currentId, nextId));
input.transaction(() => {
const renamed = renameCustomWidgetRequest(candidate.data, currentId, nextId);
input.renameRequestBinding(currentId, nextId);
applyDefinition(input.form, renamed);
});
input.form.clearFieldError("requests");
} catch (error) {
input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage);
@@ -29,7 +35,9 @@ export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
return;
}
try {
applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName));
input.transaction(() =>
applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName)),
);
input.form.clearFieldError("options");
} catch (error) {
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage);
@@ -157,11 +157,16 @@ export function RequestBodyEditor({
const [optionName, setOptionName] = useState<string | null>(optionNames[0] ?? null);
const [parameterName, setParameterName] = useState("");
const [insertion, setInsertion] = useState({ text: "", key: 0 });
useEffect(() => setDraft(serialized), [serialized]);
useEffect(() => {
setDraft(serialized);
setError(undefined);
}, [serialized]);
return (
<Stack gap="xs">
<CodeEditor
id={`${id}-request-body-editor`}
// Incomplete JSON is a local buffer; its undo must not change another document field.
localHistory
label={t("jsonBody")}
description={t("jsonBodyDescription")}
language="json"
@@ -182,7 +187,7 @@ export function RequestBodyEditor({
}
}}
/>
<Group align="end" wrap="wrap">
<Group align="end" wrap="wrap" data-workbench-local-input>
<Select label={t("option")} data={optionNames} value={optionName} searchable onChange={setOptionName} />
<Button
type="button"
@@ -1,5 +1,10 @@
"use client";
import { useConfirmModal } from "@homarr/modals";
import { projectWidgetGraph } from "./_flow/graph";
import { useCustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import { useWorkbenchSelection } from "./_flow/selection";
import { ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
@@ -20,6 +25,10 @@ export function CustomWidgetRequestsEditor({
form: CustomWidgetWorkbenchForm;
onRename(currentId: string, nextId: string): void;
}) {
const selection = useWorkbenchSelection();
const store = useCustomWidgetFormDocumentStore();
const flowT = useI18n("customWidget.flow");
const { openConfirmModal } = useConfirmModal();
const t = useI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.requests);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
@@ -32,7 +41,24 @@ export function CustomWidgetRequestsEditor({
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
const update = (index: number, id: string, request: Record<string, unknown>) =>
commit(entries.map((entry, i) => (i === index ? [id, request] : entry)));
const remove = (index: number) => commit(entries.filter((_, i) => i !== index));
const remove = (index: number) => {
const id = entries[index]?.[0];
const graph = projectWidgetGraph(form.values, store.getLayout());
const references = graph.edges
.filter((edge) => edge.source === `request:${id}` || edge.target === `request:${id}`)
.filter((edge) => edge.data?.relationship !== "source")
.map((edge) => (edge.source === `request:${id}` ? edge.target : edge.source));
const apply = () => store.transaction(() => commit(entries.filter((_, i) => i !== index)));
if (!references.length) {
apply();
return;
}
openConfirmModal({
title: flowT("removeReferenced"),
children: flowT("removeReferencedDescription", { references: [...new Set(references)].join(", ") }),
onConfirm: apply,
});
};
const move = (index: number, direction: -1 | 1) => {
const next = [...entries];
const target = index + direction;
@@ -49,13 +75,14 @@ export function CustomWidgetRequestsEditor({
return (
<Stack gap="sm">
{entries.map(([id, rawRequest], index) => {
if (selection && selection !== `request:${id}`) return null;
const request = isRecord(rawRequest) ? rawRequest : {};
const kind = request.kind === "action" ? "action" : "query";
const trigger = request.trigger === "manual" ? "manual" : "load";
return (
<Fieldset key={id} legend={id || t("request", { count: index + 1 })}>
<Fieldset key={id} legend={id || t("request", { count: index + 1 })} data-workbench-request={id}>
<Stack gap="sm">
<Group grow align="start">
<Group grow align="start" data-workbench-request-row>
<CustomWidgetIdentifierInput
label={t("requestId")}
value={id}
@@ -89,7 +116,7 @@ export function CustomWidgetRequestsEditor({
}
/>
</Group>
<Group grow align="start">
<Group grow align="start" data-workbench-request-row>
<Select
label={t("source")}
data={sourceIds}
@@ -121,6 +148,7 @@ export function CustomWidgetRequestsEditor({
</Group>
<TextInput
label={t("path")}
data-workbench-request-path
description={t("pathDescription")}
descriptionProps={{
style: { color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))" },
@@ -0,0 +1,39 @@
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
type Secrets = CustomWidgetFormValues["secrets"];
export function getSourceSecretRenames(secrets: Secrets) {
return Object.fromEntries(
secrets.flatMap((secret) => {
if (!secret.hasValue || secret.value.trim() || !secret.savedSourceId || secret.savedSourceId === secret.sourceId)
return [];
return [[secret.savedSourceId, secret.sourceId]];
}),
);
}
/** A save changes where credentials live, including when the user renamed a
* source again while that save was in flight. Preserve newer input values. */
export function markSourceSecretsSaved(
submitted: Secrets,
current: Secrets,
presence?: Array<{ sourceId: string; kind: string }>,
): Secrets {
return current.map((secret) => {
const saved = submitted.find(
(candidate) =>
candidate.kind === secret.kind &&
(candidate.savedSourceId ?? candidate.sourceId) === (secret.savedSourceId ?? secret.sourceId),
);
if (!saved) return secret;
const { savedSourceId: _previousSourceId, ...next } = secret;
let hasValue = Boolean(saved.hasValue || saved.value.trim());
if (presence) hasValue = presence.some((entry) => entry.sourceId === saved.sourceId && entry.kind === saved.kind);
return {
...next,
hasValue,
value: secret.value === saved.value ? "" : secret.value,
...(hasValue && secret.sourceId !== saved.sourceId ? { savedSourceId: saved.sourceId } : {}),
};
});
}
@@ -7,6 +7,8 @@ import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { SourceNetworkHint } from "./_flow/source-network-hint";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
@@ -98,6 +100,7 @@ export function CustomWidgetSourceField({
allowDeselect={false}
/>
</Group>
<SourceNetworkHint source={source} onChange={(networkScope) => onUpdate(index, { networkScope })} />
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
@@ -116,7 +119,7 @@ export function CustomWidgetSourceField({
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
const Input = kind === "username" ? TextInput : PasswordInput;
return (
<Group key={kind} align="end" wrap="nowrap">
<Group key={kind} align="end" wrap="nowrap" data-workbench-credential>
<Input
style={{ flex: 1 }}
label={tSecret(kind)}
@@ -131,6 +134,7 @@ export function CustomWidgetSourceField({
color="red"
variant="subtle"
loading={clearSecretPending}
disabled={form.submitting}
onClick={() => void onClearSecret(source.id, kind)}
>
{t("clear")}
@@ -139,7 +143,7 @@ export function CustomWidgetSourceField({
</Group>
);
})}
{index > 0 && (
{
<Button
type="button"
color="red"
@@ -149,7 +153,7 @@ export function CustomWidgetSourceField({
>
{t("remove")}
</Button>
)}
}
</Stack>
</Fieldset>
);
@@ -1,5 +1,8 @@
"use client";
import { useCustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import { useWorkbenchSelection } from "./_flow/selection";
import { Accordion, Button, List, Stack, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
@@ -21,6 +24,8 @@ export function CustomWidgetSourcesEditor({
form: CustomWidgetWorkbenchForm;
definitionId?: string;
}) {
const selection = useWorkbenchSelection();
const store = useCustomWidgetFormDocumentStore();
const t = useI18n("customWidget.workbench.sources");
const w = useI18n("customWidget.workbench");
const { openConfirmModal } = useConfirmModal();
@@ -34,7 +39,16 @@ export function CustomWidgetSourcesEditor({
form.setFieldError("sources", t("duplicateSourceId"));
return;
}
let parsedRequests: unknown;
if (previousId !== nextId) {
parsedRequests = parseJson(form.values.requests);
if (!isRecord(parsedRequests)) {
form.setFieldError("sources", w("invalidWidget"));
return;
}
}
if (form.errors.sources === t("duplicateSourceId")) form.clearFieldError("sources");
if (previousId && nextId && previousId !== nextId) store.renameSourceBinding(previousId, nextId);
const sourceEntries = sources.map(({ id, ...source }) => [id, source] as const);
form.setFieldValue(
"sources",
@@ -50,12 +64,13 @@ export function CustomWidgetSourcesEditor({
),
);
if (previousId && nextId && previousId !== nextId) {
const parsedRequests = parseJson(form.values.requests);
const requests = isRecord(parsedRequests)
? Object.fromEntries(
Object.entries(parsedRequests).map(([id, request]) => [
id,
isRecord(request) && request.source === previousId ? { ...request, source: nextId } : request,
isRecord(request) && (request.source ?? "default") === previousId
? { ...request, source: nextId }
: request,
]),
)
: {};
@@ -63,7 +78,13 @@ export function CustomWidgetSourcesEditor({
form.setFieldValue(
"secrets",
form.values.secrets.map((secret) =>
secret.sourceId === previousId ? { ...secret, sourceId: nextId } : secret,
secret.sourceId === previousId
? {
...secret,
sourceId: nextId,
...(secret.hasValue ? { savedSourceId: secret.savedSourceId ?? previousId } : {}),
}
: secret,
),
);
}
@@ -84,7 +105,10 @@ export function CustomWidgetSourcesEditor({
const setSecret = (sourceId: string, kind: string, value: string) => {
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
const existing = form.values.secrets.find((secret) => secret.sourceId === sourceId && secret.kind === kind);
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
form.setFieldValue("secrets", [
...current,
{ sourceId, kind, value, hasValue: existing?.hasValue, savedSourceId: existing?.savedSourceId },
]);
};
const addSource = () => {
let suffix = sources.length + 1;
@@ -132,7 +156,7 @@ export function CustomWidgetSourcesEditor({
if (!removedId) return;
const dependentRequestIds = getDependentRequestIds(form.values.requests, removedId);
if (dependentRequestIds.length === 0) {
removeSourceAndDependents(index);
store.transaction(() => removeSourceAndDependents(index));
return;
}
openConfirmModal({
@@ -150,12 +174,16 @@ export function CustomWidgetSourcesEditor({
</Stack>
),
confirmProps: { children: t("remove") },
onConfirm: () => removeSourceAndDependents(index),
onConfirm: () => store.transaction(() => removeSourceAndDependents(index)),
});
};
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
if (form.submitting) return;
if (!definitionId) return;
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
const savedSourceId = form.values.secrets.find(
(secret) => secret.sourceId === sourceId && secret.kind === kind,
)?.savedSourceId;
await clearSecretMutation.mutateAsync({ definitionId, sourceId: savedSourceId ?? sourceId, kind });
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
form.setFieldValue("secrets", [...current, { sourceId, kind, value: "", hasValue: false }]);
await utils.customWidget.get.invalidate({ id: definitionId });
@@ -165,6 +193,7 @@ export function CustomWidgetSourcesEditor({
return (
<Stack gap="sm">
{sources.map((source, index) => {
if (selection && selection !== `source:${source.id}`) return null;
return (
<CustomWidgetSourceField
key={source.id}
@@ -173,8 +202,8 @@ export function CustomWidgetSourcesEditor({
form={form}
definitionId={definitionId}
clearSecretPending={clearSecretMutation.isPending}
onUpdate={update}
onSetAuthentication={setAuth}
onUpdate={(sourceIndex, changes) => store.transaction(() => update(sourceIndex, changes))}
onSetAuthentication={(sourceIndex, type) => store.transaction(() => setAuth(sourceIndex, type))}
onSetSecret={setSecret}
onClearSecret={clearSecret}
onRemove={removeSource}
@@ -0,0 +1,83 @@
# Custom Widget workbench
The flow editor projects the existing Custom Widget definition. It does not execute a graph or replace the dashboard
runtime. The canvas is the default authoring surface; the complete form remains available from the More menu.
The workspace fills the management area by default, beside a collapsible icon rail. Optional immersive full-screen mode
hides management chrome through CSS and background focus isolation; it preserves the React tree, form association, draft,
and execution session. Save errors render inside the workspace.
## Ownership
- `document-store.ts` owns definition transactions and bounded Undo/Redo. Credentials stay in the live form;
`credential-bindings.ts` restores their association across source renames without putting values in history.
- `graph.ts` projects references into nodes and edges. `commands.ts` and the specialized editors change manifest fields.
Template edits use parser source ranges and preserve unrelated formatting and comments. Complex relationships are
inspectable in code rather than converted into a visual component tree.
- Layout metadata lives beside the persisted definition. Moving nodes does not notify definition subscribers, parse JSX,
invalidate preview evidence, or call the executor. ELK is loaded on demand for explicit arrangement.
- Selection, viewport, panes, response bodies, temporary parameters, and Assistant conversation state are session state.
Node data contains compact summaries and stable identifiers, not credentials or response bodies.
- `canvas-preview.tsx` mounts the live preview inside the Widget node. Its header drags the node; preview controls and
authored interactions use `nodrag`, `nopan`, and `nowheel`. CodeMirror and Assistant live in a contextual side pane,
outside canvas zoom, and remain mounted when hidden. Preview tools open in a portaled popup. The preview theme surface
isolates its styles and portal content from management chrome. Runtime-owned dropdown targets escape widget paint
containment while preserving scoped CSS, preview themes, and dialog focus traps. Nested menus and popovers retain
inline dropdowns; interpreter-authored events and portal targets remain blocked.
- Assistant draft commands use the existing conversation and client-tool infrastructure. Draft identity and generation
preconditions reject delayed patches. An accepted patch is one document transaction, not an implicit save or action.
## Compatibility and capabilities
Plain HTTP and static widgets retain the v2 schema. A definition with runtime extensions uses v3; strict v2 definitions
never acquire extension fields. Editor layout is not portable runtime content. Empty sources and requests support static
widgets without a placeholder service URL.
V3 adds scoped styles and keyframes, theme/container bindings, named views, supported detail overlays and embeds,
per-user browser preferences, permissioned shared board content, and curated native capabilities. The initial native
catalog covers Beszel systems/history/alerts/live data, calendars, media requests, Home Assistant, and Docker. It does not
expose arbitrary tRPC methods. Other services use HTTP or embeds; services without these interfaces need an external bridge.
The executable Beszel reference definition and current capability inventory are documented under
`apps/docs/docs/management/custom-widgets/`. The reference includes system selection, historical charts, container statistics,
live statistics, preferences, and partial failures. The catalog is an explicit coverage boundary, not a claim that every
native Homarr widget has a dedicated capability descriptor.
Workshop installation and MCP use the same install procedure. Provenance records endpoint, submission, revision, and
portable fingerprints. Update review retains local connections and secrets, rejects incompatible/local changes, and keeps
one previous package for rollback. Publication updates the existing owned submission or creates an attributed remix.
PocketBase schema validation and SQLite, MySQL, and PostgreSQL migrations ship with these changes.
## Authoring connections
`connection-planner.ts` validates the same operations for ports and the inspector picker. Source associations and action
invalidations update their manifest fields directly. Query, action, and option connections open a binding dialog: choose a
response field, display, or parameter mapping before applying a bounded JSX/manifest edit. Repeated connections can add
another field or display for the same relationship. Query and action creation never runs a remote operation implicitly.
`template-insertion.ts` preserves existing source text around inserted components. Generated bindings carry portable
comment markers for ownership checks. Only unchanged, wholly generated relationships can be removed or replaced visually;
authored or mixed references open in JSX. Replacement applies the removal and new binding in one document transaction.
A stale dialog cannot overwrite changes made after it opened.
## Acceptance evidence and limits
The current canvas presentation has been checked in the isolated SQLite development app with Chromium on Linux. Focused
browser checks cover real query/option/action bindings, repeated mappings, shared Undo, live JSX rendering, invalid-JSX
preview continuity, generated binding disconnect/reconnect and cancellation, compact controls, theme and size changes,
and recovery while the inspector is closed. The preview tools fit a 390px viewport. Real pointer movement and zoom retained the preview shadow tree and CodeMirror
without execution requests. Scoped light/dark accessibility scans found no violations; overlap prevents automated contrast
verification for some canvas elements. The 38 focused existing workbench/runtime/preview tests passed. Tests use local/sample data and simulated actions; these checks do not exercise a real external action
or public Workshop publication.
Earlier implementation passes covered local mock Beszel data and a deterministic Assistant provider, Workshop installation
and board rendering, scoped portals, focused runtime tests, and production builds. Earlier production canvas measurements
covered 16 nodes, 64 requests, and a template near 50,000 characters with zero execution requests or parser entries during
movement; board manifests excluded React Flow, ELK, and workbench chunks. Those measurements predate the preview-in-node
presentation and are not production performance or bundle proof for this final UI.
Physical touch/trackpad testing, a broader browser/device matrix, a fresh production performance pass, real external service
actions, and executing the MySQL/PostgreSQL migrations against live database servers remain separate acceptance work.
Schema snapshots and migration ancestry were checked for all three drivers. An existing demo-gallery test requires the
unavailable llama.cpp demo module. The current development acceptance uses repository routing unchanged; an earlier
production QA instance needed a temporary generated server-routing override.
@@ -0,0 +1,129 @@
"use client";
import { Button, Modal, Stack, Text } from "@mantine/core";
import type { Connection } from "@xyflow/react";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { createFlowCommands, isGraphEditable } from "./commands";
import { projectWidgetGraph, readObject, record } from "./graph";
import type { FlowKind } from "./graph";
import { placeAddedNodes } from "./node-placement";
import { useFlowConnection } from "./connection-provider";
import { connectionIssue, planFlowConnection } from "./connection-planner";
export interface PendingConnection {
nodeId: string;
handleType: "source" | "target";
position: { x: number; y: number };
}
export function AddNextStep({
pending,
form,
onClose,
onSelect,
}: {
pending: PendingConnection | null;
form: CustomWidgetWorkbenchForm;
onClose(): void;
onSelect(id: string): void;
}) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
const connect = useFlowConnection();
if (!pending) return null;
const graph = projectWidgetGraph(store.getValues(), store.getLayout());
const node = graph.nodes.find((entry) => entry.id === pending?.nodeId);
const kinds: FlowKind[] = [];
if (pending?.handleType === "source" && node?.data.kind === "source") kinds.push("query", "action");
if (pending?.handleType === "source" && node?.data.kind === "action") kinds.push("query");
if (pending?.handleType === "target" && node && ["query", "action"].includes(node.data.kind)) kinds.push("source");
if (node?.data.kind === "widget") {
kinds.push("query", "action");
if (pending.handleType === "target") kinds.push("options");
}
if (node?.data.kind === "options" && pending.handleType === "source") kinds.push("query", "action");
const existing = graph.nodes.filter((entry) => {
let source = pending.nodeId;
let target = entry.id;
if (pending.handleType === "target") {
source = entry.id;
target = pending.nodeId;
}
return !connectionIssue({ source, target }, graph.nodes);
});
const add = (kind: FlowKind) => {
if (!pending || !node || !isGraphEditable(store.getValues())) return;
let id = "widget";
let binding: Connection | undefined;
store.transaction(() => {
const previous = projectWidgetGraph(store.getValues(), store.getLayout()).nodes;
const commands = createFlowCommands(form, store);
let sourceId: string | undefined;
if (node.data.kind === "source") sourceId = node.data.identifier;
if (node.data.kind === "action") {
const source = record(readObject(store.getValues().requests)[node.data.identifier]).source;
if (typeof source === "string") sourceId = source;
}
id = commands.add(kind, sourceId);
const nextGraph = projectWidgetGraph(store.getValues(), store.getLayout());
let source = pending.nodeId;
let target = id;
if (pending.handleType === "target") {
source = id;
target = pending.nodeId;
}
const connection = { source, target, sourceHandle: "out", targetHandle: "in" };
if (planFlowConnection(connection, nextGraph.nodes, store.getValues()) === "direct")
commands.connect(connection, nextGraph.nodes);
else binding = connection;
store.setLayout({
...store.getLayout(),
nodes: { ...store.getLayout().nodes, ...placeAddedNodes(previous, nextGraph.nodes, id, pending.position) },
});
});
onClose();
onSelect(id);
if (binding) connect(binding, projectWidgetGraph(store.getValues(), store.getLayout()).nodes);
};
return (
<Modal
opened={!!pending}
onClose={onClose}
title={t("addNextStep")}
closeButtonProps={{ "aria-label": t("bindingCancel") }}
size="sm"
>
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t("nextStepFor", { name: node?.data.label ?? "" })}
</Text>
{kinds.map((kind) => (
<Button key={kind} disabled={!isGraphEditable(store.getValues())} variant="light" onClick={() => add(kind)}>
{t(`kind.${kind}`)}
</Button>
))}
{existing.map((entry) => (
<Button
key={entry.id}
disabled={!isGraphEditable(store.getValues())}
variant="subtle"
onClick={() => {
let source = pending.nodeId;
let target = entry.id;
if (pending.handleType === "target") {
source = entry.id;
target = pending.nodeId;
}
const issue = connect({ source, target, sourceHandle: "out", targetHandle: "in" }, graph.nodes);
if (issue) return;
onClose();
onSelect(entry.id);
}}
>
{t("bindingConnectExisting", { name: entry.data.label })}
</Button>
))}
{kinds.length === 0 && existing.length === 0 && <Text size="sm">{t("codeOwned")}</Text>}
</Stack>
</Modal>
);
}
@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, Stack, Text } from "@mantine/core";
import { useAuiState } from "@assistant-ui/react";
import { useI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant, useAssistantPreferences } from "~/components/assistant/assistant-context";
import { AssistantConversationSurface } from "~/components/assistant/assistant-panel";
import { AssistantComposerSurfaceBoundary } from "~/components/assistant/assistant-runtime-provider";
import { getPendingAssistantAction } from "~/components/assistant/assistant-pending-action";
import {
applyWorkbenchTemplateEdits,
getWorkbenchDraftId,
registerAssistantWorkbench,
} from "~/components/assistant/assistant-workbench-bridge";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { buildDefinition } from "../_custom-widget-form-utils";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import { withoutCredentials } from "./history";
export function WorkbenchAssistant({
form,
selected,
onSelect,
onPreview,
}: {
form: CustomWidgetWorkbenchForm;
selected: string;
onSelect(id: string): void;
onPreview(): Promise<unknown>;
}) {
const store = useCustomWidgetFormDocumentStore();
const draftId = getWorkbenchDraftId(store);
const t = useI18n("customWidget.flow");
const assistant = useOptionalHomarrAssistant();
const [result, setResult] = useState("");
useEffect(
() =>
registerAssistantWorkbench({
read: () => {
const draft = buildDefinition(store.getValues());
if (!draft.success)
return {
success: false,
draftId,
revision: store.getRevision(),
selected,
fields: withoutCredentials(store.getValues()),
diagnostics: draft.error.issues.map((issue) => ({ path: issue.path, message: issue.message })),
};
return { success: true, draftId, revision: store.getRevision(), selected, definition: draft.data };
},
apply: (patch) => {
if (patch.draftId !== draftId || patch.revision !== store.getRevision())
return { success: false, error: "Draft changed. Read it again before applying changes." };
const next = { ...withoutCredentials(store.getValues()), ...patch.changes };
if (patch.templateEdits?.length) {
if (patch.changes.template !== undefined)
return { success: false, error: "Choose targeted edits or a complete template, not both." };
try {
next.template = applyWorkbenchTemplateEdits(next.template, patch.templateEdits);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Invalid template edit" };
}
}
const parsed = buildDefinition({ ...next, secrets: [] });
if (!parsed.success)
return {
success: false,
diagnostics: parsed.error.issues.map((issue) => ({ path: issue.path, message: issue.message })),
};
store.transaction(() => form.setValues({ ...next, secrets: store.getValues().secrets }));
setResult(patch.summary);
if (patch.focus) onSelect(patch.focus);
return { success: true, draftId, revision: store.getRevision(), summary: patch.summary };
},
preview: async (precondition) => {
if (precondition.draftId !== draftId || precondition.revision !== store.getRevision())
return { success: false, error: "Draft changed. Read it again before running queries." };
return onPreview();
},
}),
[draftId, form, onPreview, onSelect, selected, store],
);
return (
<Stack gap="sm">
{result && (
<Alert color="teal" aria-live="polite">
{result}
</Alert>
)}
{!assistant?.enabled && (
<Text size="xs" c="dimmed">
{assistant?.unavailableDescription ?? t("assistantUnavailable")}
</Text>
)}
{assistant?.enabled && (
<AssistantComposerSurfaceBoundary surfaceId="custom-widget-workbench">
<Conversation />
</AssistantComposerSurfaceBoundary>
)}
</Stack>
);
}
function Conversation() {
const assistant = useOptionalHomarrAssistant();
const preferences = useAssistantPreferences();
const messages = useAuiState((state) => state.thread.messages);
const last = messages.findLast((message) => message.role === "assistant");
if (!assistant) return null;
return (
<div style={{ height: 580, display: "flex", flexDirection: "column", minHeight: 0 }}>
<AssistantConversationSurface
isRunning={assistant.isRunning}
pendingAction={getPendingAssistantAction(last)}
modelId={preferences.modelId}
models={preferences.models}
modelOptionsLoading={preferences.isLoading}
reasoning={preferences.reasoning}
isRefreshing={assistant.isRefreshing}
onRefresh={assistant.refreshCurrentView}
onModelChange={preferences.setModelId}
onReasoningChange={preferences.setReasoning}
/>
</div>
);
}
@@ -0,0 +1,214 @@
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import {
collectTemplateReferences,
insertCustomWidgetTemplateContent,
markGeneratedBinding,
removeGeneratedBindings,
} from "@homarr/custom-widgets/workbench";
import type { GeneratedBindingDescriptor } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { buildDefinition } from "../_custom-widget-form-utils";
import { readObject, record } from "./graph";
import type { FlowBindingIntent } from "./connection-planner";
import { isGeneratedConnectionCurrent } from "./connection-planner";
import { nativeLiteralDefault, nativeParameterFields } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
import { freshFlowIdentifier } from "./identifiers";
export interface BindingParameter {
mode: "control" | "input" | "option" | "literal";
name: string;
controlName: string;
value: unknown;
}
export interface BindingChoices {
format: "json" | "text" | "list" | "table";
path: string;
columns: string;
label: string;
option: string;
field: string;
location: "query" | "body" | "path" | "input";
params: Record<string, BindingParameter>;
}
export interface BindingLabels {
loading: string;
empty: string;
run: string;
error: string;
}
export function bindingDescriptor(intent: FlowBindingIntent) {
if (intent.native) return record(record(readObject(intent.snapshot.extensions).native)[intent.identifier]);
return record(readObject(intent.snapshot.requests)[intent.identifier]);
}
export function bindingParameters(intent: FlowBindingIntent, schema: NativeInputSchema) {
const descriptor = bindingDescriptor(intent);
if (intent.native) return nativeParameterFields(descriptor.input, schema);
const references = collectCustomWidgetRequestReferences({
path: String(descriptor.path ?? ""),
query: record(descriptor.query),
body: descriptor.body,
});
return [...references.params].map((name) => ({ name, schema: {} as NativeInputSchema }));
}
export function createBindingParameters(intent: FlowBindingIntent, schema: NativeInputSchema) {
const occupied = Object.fromEntries(
collectTemplateReferences(intent.snapshot.template)
.filter((ref) => ref.kind === "bind")
.map((ref) => [ref.name, true]),
);
return Object.fromEntries(
bindingParameters(intent, schema).map(({ name, schema: field }) => {
const binding = freshFlowIdentifier(occupied, `${intent.identifier}-${name}`);
occupied[binding] = true;
return [
name,
{
mode: "control",
name: binding,
controlName: binding,
value: nativeLiteralDefault(field),
} satisfies BindingParameter,
];
}),
);
}
const quote = JSON.stringify;
function propertyPath(root: string, path: string) {
const parts = path.trim().split(".").filter(Boolean);
if (parts.length > 16 || parts.some((part) => ["constructor", "prototype", "__proto__"].includes(part)))
throw new Error("Invalid data path");
return root + parts.map((part) => `?.[${quote(part)}]`).join("");
}
function parameterCode(choices: BindingChoices) {
const controls: string[] = [];
const entries = Object.entries(choices.params).map(([key, parameter]) => {
let expression = quote(parameter.value);
if (!["string", "number", "boolean"].includes(typeof parameter.value))
throw new Error("Parameters must be text, numbers, or booleans");
if (parameter.mode === "input" || parameter.mode === "control") expression = `inputs[${quote(parameter.name)}]`;
if (parameter.mode === "option") expression = `options[${quote(parameter.name)}]`;
if (parameter.mode === "control") {
let component = "TextInput";
let defaultProp = "defaultValue";
if (typeof parameter.value === "number") component = "NumberInput";
if (typeof parameter.value === "boolean") {
component = "Switch";
defaultProp = "defaultChecked";
}
controls.push(
`<${component} bind=${quote(parameter.name)} label={${quote(key)}} ${defaultProp}={${quote(parameter.value)}} />`,
);
}
return `${quote(key)}: ${expression}`;
});
return { controls, params: `params={{ ${entries.join(", ")} }}` };
}
function renderValue(root: string, choices: BindingChoices, labels: BindingLabels) {
const value = propertyPath(root, choices.path);
if (choices.format === "text") return `<Text>{String(${value} ?? ${quote(labels.empty)})}</Text>`;
if (choices.format === "json")
return `{(${value} ?? null) === null ? <Text c="dimmed">{${quote(labels.empty)}}</Text> : <Code block>{JSON.stringify(${value})}</Code>}`;
const rows = `(${value}).slice(0, 50)`;
let body = `${rows}.map((row, index) => <Text key={index} size="sm">{JSON.stringify(row)}</Text>)`;
if (choices.format === "table") {
const columns = choices.columns
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
.slice(0, 8);
if (!columns.length) throw new Error("Choose at least one table column");
const header = columns.map((column) => `<Table.Th>{${quote(column)}}</Table.Th>`).join("");
const cells = columns
.map((column) => `<Table.Td>{String(${propertyPath("row", column)} ?? "")}</Table.Td>`)
.join("");
body = `<Table.ScrollContainer minWidth={300}><Table><Table.Thead><Table.Tr>${header}</Table.Tr></Table.Thead><Table.Tbody>{${rows}.map((row, index) => <Table.Tr key={index}>${cells}</Table.Tr>)}</Table.Tbody></Table></Table.ScrollContainer>`;
} else body = `<Stack gap="xs">{${body}}</Stack>`;
return `{Array.isArray(${value}) && ${value}.length > 0 ? (${body}) : <Text c="dimmed">{${quote(labels.empty)}}</Text>}`;
}
export function createBindingSnippet(intent: FlowBindingIntent, choices: BindingChoices, labels: BindingLabels) {
if (intent.kind === "option") {
if (!record(readObject(intent.snapshot.options)[choices.option]).control)
throw new Error("Choose an existing option");
return `<Text>{String(options[${quote(choices.option)}] ?? "")}</Text>`;
}
const descriptor = bindingDescriptor(intent);
const { controls, params } = parameterCode(choices);
let content: string;
if (intent.kind === "action") {
let component = "ActionButton";
let idProp = "requestId";
if (intent.native) {
component = "NativeActionButton";
idProp = "nativeId";
}
content = `<${component} ${idProp}=${quote(intent.identifier)} ${params} variant="default">{${quote(choices.label || labels.run)}}</${component}>`;
} else if (descriptor.trigger === "manual" || descriptor.capability === "beszel.live") {
let component = "SubFetch";
let idProp = "requestId";
let live = "";
if (intent.native) {
component = "NativeQuery";
idProp = "nativeId";
}
if (descriptor.capability === "beszel.live") live = " live";
content = `<${component} ${idProp}=${quote(intent.identifier)} ${params} trigger="manual"${live}>{(result, meta) => (<Stack gap="xs">${renderValue("result", choices, labels)}</Stack>)}</${component}>`;
} else {
const status = `status[${quote(intent.identifier)}]`;
content = `<Stack gap="xs">\n{${status}?.loading ? <Text c="dimmed">{${quote(labels.loading)}}</Text> : ${status}?.error ? <Alert color="red">{String(${status}.error)}</Alert> : (${`<Stack gap="xs">${renderValue(`data[${quote(intent.identifier)}]`, choices, labels)}</Stack>`})}\n<RefreshButton />\n</Stack>`;
}
if (!controls.length) return content;
return `<Stack gap="xs">\n${controls.join("\n")}\n${content}\n</Stack>`;
}
export function bindingCandidate(
intent: FlowBindingIntent,
values: CustomWidgetFormValues,
choices: BindingChoices,
labels: BindingLabels,
) {
const options = readObject(values.options);
const bindings = collectTemplateReferences(values.template)
.filter((entry) => entry.kind === "bind")
.map((entry) => entry.name);
for (const parameter of Object.values(choices.params)) {
if (
parameter.mode === "option" &&
!["string", "number", "boolean"].includes(typeof record(options[parameter.name]).default)
)
throw new Error("Choose an option with a text, number, or boolean value");
if (parameter.mode === "input" && !bindings.includes(parameter.name))
throw new Error("Choose an existing widget input");
if (parameter.mode === "control" && bindings.includes(parameter.name))
throw new Error("A widget input with this name already exists");
}
const snippet = createBindingSnippet(intent, choices, labels);
let template = values.template;
if (intent.replaceGeneratedIds) {
if (!isGeneratedConnectionCurrent(values, intent.replaceEdgeId, intent.replaceGeneratedIds))
throw new Error("The original connection now includes code-owned references. Edit this relationship in code.");
const fragments = Object.values(record(readObject(values.extensions).fragments)).filter(
(value): value is string => typeof value === "string",
);
const removed = removeGeneratedBindings(template, intent.replaceGeneratedIds, fragments);
if (removed === null) throw new Error("The original binding changed. Edit this relationship in code.");
template = removed;
}
const descriptor: GeneratedBindingDescriptor = { source: intent.source, target: "widget", relationship: "data" };
if (intent.kind === "option") descriptor.relationship = "option";
if (intent.kind === "action" || (!intent.native && bindingDescriptor(intent).trigger === "manual")) {
descriptor.source = "widget";
descriptor.target = intent.source;
descriptor.relationship = "invoke";
}
const candidate = {
...values,
template: insertCustomWidgetTemplateContent(template, markGeneratedBinding(snippet, descriptor)),
};
const parsed = buildDefinition(candidate);
if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Invalid widget definition");
return { patch: { template: candidate.template }, preview: snippet };
}
@@ -0,0 +1,252 @@
"use client";
import { useMemo, useState, useSyncExternalStore } from "react";
import {
Accordion,
Alert,
Autocomplete,
Button,
Code,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { collectTemplateReferences } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { isFlowBindingCurrent } from "./connection-planner";
import type { FlowBindingIntent } from "./connection-planner";
import { bindingCandidate, bindingDescriptor, bindingParameters, createBindingParameters } from "./binding-code";
import type { BindingChoices } from "./binding-code";
import { BindingParameterEditor } from "./binding-parameter-editor";
import { BindingOptionEditor } from "./binding-option-editor";
import { requestOptionCandidate } from "./binding-request-option";
import { readObject } from "./graph";
import { useWorkbenchQuery } from "./execution";
import { nativeInputFields } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
export function FlowBindingDialog(props: {
intent: FlowBindingIntent;
form: CustomWidgetWorkbenchForm;
onClose(): void;
}) {
const t = useI18n("customWidget.flow");
const catalog = clientApi.customWidget.nativeCapabilities.useQuery(undefined, { enabled: props.intent.native });
const descriptor = bindingDescriptor(props.intent);
const schema = catalog.data?.find((entry) => entry.id === descriptor.capability)?.inputSchema;
let title = t("bindingTitle");
if (props.intent.kind === "requestOption") title = t("bindingConfigureRequest");
return (
<Modal
opened
onClose={props.onClose}
title={title}
closeButtonProps={{ "aria-label": t("bindingCancel") }}
size="lg"
>
{props.intent.native && !schema ? (
<Alert color="yellow">
{catalog.isError ? t("bindingCatalogError") : t("bindingCatalogLoading")}
{catalog.isError && (
<Button type="button" size="xs" onClick={() => void catalog.refetch()}>
{t("bindingRetry")}
</Button>
)}
</Alert>
) : (
<BindingEditor {...props} schema={schema ?? {}} />
)}
</Modal>
);
}
function BindingEditor({
intent,
form,
onClose,
schema,
}: {
intent: FlowBindingIntent;
form: CustomWidgetWorkbenchForm;
onClose(): void;
schema: NativeInputSchema;
}) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
useSyncExternalStore(store.subscribe, store.getRevision, store.getRevision);
const [choices, setChoices] = useState<BindingChoices>(() => ({
format: "json",
path: "",
columns: "name",
label: intent.identifier,
option: Object.keys(readObject(intent.snapshot.options))[0] ?? "",
field: nativeInputFields(schema)[0]?.name ?? "value",
location: intent.native ? "input" : "query",
params: createBindingParameters(intent, schema),
}));
const [applyError, setApplyError] = useState<string | null>(null);
const sample = useWorkbenchQuery(intent.identifier);
const paths = useMemo(() => samplePaths(sample?.data), [sample?.data]);
const options = readObject(intent.snapshot.options);
const inputs = [
...new Set(
collectTemplateReferences(intent.snapshot.template)
.filter((entry) => entry.kind === "bind")
.map((entry) => entry.name),
),
];
const params = bindingParameters(intent, schema);
const current = isFlowBindingCurrent(intent, store.getValues());
const change = (patch: Partial<BindingChoices>) => {
setChoices((value) => ({ ...value, ...patch }));
setApplyError(null);
};
const prepare = () => {
if (intent.kind === "requestOption") return requestOptionCandidate(intent, store.getValues(), choices, schema);
return bindingCandidate(intent, store.getValues(), choices, {
loading: t("bindingLoading"),
empty: t("bindingEmpty"),
run: t("bindingRun"),
error: t("bindingError"),
});
};
let candidate: ReturnType<typeof prepare> | undefined;
let error: string | null = null;
try {
if (current) candidate = prepare();
} catch (reason) {
error = reason instanceof Error ? reason.message : t("bindingError");
}
return (
<Stack gap="md" data-workbench-local-input>
<Text size="sm" c="dimmed">
{t("bindingDescription")}
</Text>
{(intent.kind === "option" || intent.kind === "requestOption") && (
<BindingOptionEditor intent={intent} choices={choices} schema={schema} onChange={change} />
)}
{intent.kind === "query" && (
<>
<Select
label={t("bindingDisplay")}
value={choices.format}
data={[
{ value: "json", label: t("bindingJson") },
{ value: "text", label: t("bindingText") },
{ value: "list", label: t("bindingList") },
{ value: "table", label: t("bindingTable") },
]}
onChange={(format) => format && change({ format: format as BindingChoices["format"] })}
/>
<Autocomplete
label={t("bindingDataPath")}
description={t("bindingDataPathHint")}
data={paths}
value={choices.path}
onChange={(path) => change({ path })}
/>
{choices.format === "table" && (
<TextInput
label={t("bindingColumns")}
description={t("bindingColumnsHint")}
value={choices.columns}
onChange={(event) => change({ columns: event.currentTarget.value })}
/>
)}
</>
)}
{intent.kind === "action" && (
<TextInput
label={t("bindingButtonLabel")}
value={choices.label}
onChange={(event) => change({ label: event.currentTarget.value })}
/>
)}
{(intent.kind === "query" || intent.kind === "action") && params.length > 0 && (
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("bindingParameterHint")}
</Text>
{params.map(
({ name, schema: fieldSchema }) =>
choices.params[name] && (
<BindingParameterEditor
key={name}
name={name}
schema={fieldSchema}
parameter={choices.params[name]}
options={options}
inputs={inputs}
onChange={(value) => change({ params: { ...choices.params, [name]: value } })}
/>
),
)}
</Stack>
)}
{!current && (
<Alert color="yellow" aria-live="polite">
{t("bindingStale")}
</Alert>
)}
{(error || applyError) && (
<Alert color="yellow" aria-live="polite">
{applyError ?? error}
</Alert>
)}
{candidate && (
<Accordion variant="contained">
<Accordion.Item value="code">
<Accordion.Control>{t("bindingReview")}</Accordion.Control>
<Accordion.Panel>
<Code block mah={240} style={{ overflow: "auto", whiteSpace: "pre-wrap" }}>
{candidate.preview}
</Code>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
<Group justify="end">
<Button type="button" variant="default" onClick={onClose}>
{t("bindingCancel")}
</Button>
<Button
type="button"
disabled={!current || !candidate}
onClick={() => {
try {
if (!isFlowBindingCurrent(intent, store.getValues())) throw new Error(t("bindingStale"));
const result = prepare();
store.transaction(() => form.setValues({ ...store.getValues(), ...result.patch }));
onClose();
} catch (reason) {
setApplyError(reason instanceof Error ? reason.message : t("bindingError"));
}
}}
>
{t("bindingApply")}
</Button>
</Group>
</Stack>
);
}
function samplePaths(value: unknown) {
const paths: string[] = [];
const visit = (entry: unknown, path: string, depth: number) => {
if (paths.length >= 100 || depth > 5 || !entry || typeof entry !== "object") return;
for (const [key, child] of Object.entries(entry).slice(0, 25)) {
if (key.includes(".")) continue;
let next = key;
if (path) next = `${path}.${key}`;
paths.push(next);
if (!Array.isArray(entry)) visit(child, next, depth + 1);
}
};
visit(value, "", 0);
return paths.slice(0, 100);
}
@@ -0,0 +1,87 @@
"use client";
import { Select, Stack, TextInput } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import type { FlowBindingIntent } from "./connection-planner";
import { bindingDescriptor } from "./binding-code";
import type { BindingChoices } from "./binding-code";
import { readObject } from "./graph";
import { isCompatibleNativeOption, nativeInputFields } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
export function BindingOptionEditor({
intent,
choices,
schema,
onChange,
}: {
intent: FlowBindingIntent;
choices: BindingChoices;
schema: NativeInputSchema;
onChange(value: Partial<BindingChoices>): void;
}) {
const t = useI18n("customWidget.flow");
const fields = nativeInputFields(schema);
const fieldSchema = fields.find((field) => field.name === choices.field)?.schema ?? {};
let options = Object.entries(readObject(intent.snapshot.options));
if (intent.native) options = options.filter(([, option]) => isCompatibleNativeOption(option, fieldSchema));
const descriptor = bindingDescriptor(intent);
const pathFields = [...String(descriptor.path ?? "").matchAll(/\{param:([A-Za-z][A-Za-z0-9_-]*)\}/gu)].map(
(match) => match[1] ?? "",
);
return (
<Stack gap="sm">
<Select
label={t("bindingOption")}
searchable
value={choices.option}
data={options.map(([name]) => name)}
onChange={(value) => value && onChange({ option: value })}
/>
{intent.kind === "requestOption" && (
<>
{!intent.native && (
<Select
label={t("bindingRequestLocation")}
value={choices.location}
data={[
{ value: "query", label: t("bindingQueryParameter") },
{ value: "body", label: t("bindingBodyField") },
{ value: "path", label: t("bindingPathParameter"), disabled: pathFields.length === 0 },
]}
onChange={(value) => {
if (!value) return;
let field = choices.field;
if (value === "path") field = pathFields[0] ?? "";
onChange({ location: value as BindingChoices["location"], field });
}}
/>
)}
{intent.native && (
<Select
label={t("bindingRequestField")}
searchable
value={choices.field}
data={fields.map(({ name }) => name)}
onChange={(value) => value && onChange({ field: value })}
/>
)}
{!intent.native && choices.location === "path" && (
<Select
label={t("bindingRequestField")}
value={choices.field}
data={[...new Set(pathFields)]}
onChange={(value) => value && onChange({ field: value })}
/>
)}
{!intent.native && choices.location !== "path" && (
<TextInput
label={t("bindingRequestField")}
value={choices.field}
onChange={(event) => onChange({ field: event.currentTarget.value })}
/>
)}
</>
)}
</Stack>
);
}
@@ -0,0 +1,91 @@
"use client";
import { Group, Select, Stack, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import type { BindingParameter } from "./binding-code";
import type { NativeInputSchema } from "./native-editor-model";
import { isCompatibleNativeOption } from "./native-editor-model";
import { NativeLiteralInput } from "./native-literal-input";
export function BindingParameterEditor({
name,
parameter,
schema,
options,
inputs,
onChange,
}: {
name: string;
parameter: BindingParameter;
schema: NativeInputSchema;
options: Record<string, unknown>;
inputs: string[];
onChange(value: BindingParameter): void;
}) {
const t = useI18n("customWidget.flow");
const optionNames = Object.keys(options).filter((key) => isCompatibleNativeOption(options[key], schema));
let fieldSchema = schema;
if (!schema.type) fieldSchema = { ...schema, type: typeof parameter.value };
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{name}
</Text>
<Group grow align="start">
<Select
label={t("bindingParameterSource")}
value={parameter.mode}
data={[
{ value: "control", label: t("bindingNewControl") },
{ value: "literal", label: t("nativeLiteral") },
{ value: "option", label: t("nativeOptionBinding"), disabled: optionNames.length === 0 },
{ value: "input", label: t("bindingExistingInput"), disabled: inputs.length === 0 },
]}
onChange={(mode) => {
if (!mode) return;
let selected = parameter.name;
if (mode === "option") selected = optionNames[0] ?? "";
if (mode === "input") selected = inputs[0] ?? "";
if (mode === "control") selected = parameter.controlName;
onChange({ ...parameter, mode: mode as BindingParameter["mode"], name: selected });
}}
/>
{(parameter.mode === "option" || parameter.mode === "input") && (
<Select
label={t("bindingValue")}
value={parameter.name}
searchable
data={parameter.mode === "option" ? optionNames : inputs}
onChange={(value) => value && onChange({ ...parameter, name: value })}
/>
)}
{(parameter.mode === "literal" || parameter.mode === "control") && (
<NativeLiteralInput
label={t("bindingValue")}
value={parameter.value}
schema={fieldSchema}
required
disabled={false}
onChange={(value) => onChange({ ...parameter, value })}
/>
)}
</Group>
{!schema.type && (parameter.mode === "literal" || parameter.mode === "control") && (
<Select
label={t("bindingParameterType")}
value={typeof parameter.value}
data={[
{ value: "string", label: t("bindingText") },
{ value: "number", label: t("bindingNumber") },
{ value: "boolean", label: t("bindingBoolean") },
]}
onChange={(type) => {
let value: unknown = "";
if (type === "number") value = 0;
if (type === "boolean") value = false;
onChange({ ...parameter, value });
}}
/>
)}
</Stack>
);
}
@@ -0,0 +1,84 @@
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { collectTemplateReferences } from "@homarr/custom-widgets/workbench";
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import { buildDefinition, isRecord } from "../_custom-widget-form-utils";
import { readObject, record } from "./graph";
import type { FlowBindingIntent } from "./connection-planner";
import type { BindingChoices } from "./binding-code";
import { isCompatibleNativeOption, nativeInputFields } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
const parameters = (value: Record<string, unknown>) =>
collectCustomWidgetRequestReferences({
path: String(value.path ?? ""),
query: record(value.query),
body: value.body ?? value.input,
}).params;
export function requestOptionCandidate(
intent: FlowBindingIntent,
values: CustomWidgetFormValues,
choices: BindingChoices,
schema: NativeInputSchema = {},
) {
if (!Object.hasOwn(readObject(values.options), choices.option)) throw new Error("Choose an existing option");
const key = choices.field.trim();
if (intent.native) {
const fields = nativeInputFields(schema);
const target = fields.find((field) => field.name === key);
if (
fields.length &&
(!target || !isCompatibleNativeOption(readObject(values.options)[choices.option], target.schema))
)
throw new Error("Choose an option compatible with this capability field");
}
if (!key || key.length > 256 || ["__proto__", "constructor", "prototype"].includes(key))
throw new Error("Choose a valid request field");
const reference = { $option: choices.option };
if (intent.native && choices.location !== "input") throw new Error("Choose a native capability input");
if (!intent.native && choices.location === "input") throw new Error("Choose an HTTP request field");
const field = intent.native ? "extensions" : "requests";
const document = readObject(values[field]);
let entries = document;
if (intent.native) entries = record(document.native);
const descriptor = record(entries[intent.identifier]);
const updated = { ...descriptor };
if (choices.location === "path") {
const placeholder = `{param:${key}}`;
const path = String(descriptor.path ?? "");
if (!path.includes(placeholder)) throw new Error("Choose an existing path parameter");
updated.path = path.replaceAll(placeholder, `{option:${choices.option}}`);
} else {
const current = descriptor[choices.location];
if (current !== undefined && (!isRecord(current) || "$option" in current || "$param" in current))
throw new Error("This field is code-owned. Edit its existing binding before adding a named field.");
updated[choices.location] = { ...record(current), [key]: reference };
}
const nextEntries = { ...entries, [intent.identifier]: updated };
const remaining = parameters(updated);
if ([...parameters(descriptor)].some((name) => !remaining.has(name))) {
const templates = [
values.template,
...Object.values(record(readObject(values.extensions).fragments)).filter(
(value): value is string => typeof value === "string",
),
];
if (
templates.some((template) =>
collectTemplateReferences(template).some(
(entry) =>
["request", "native"].includes(entry.kind) && (entry.name === intent.identifier || entry.name === "*"),
),
)
)
throw new Error(
"This change removes an invocation parameter used by existing JSX. Update that control in code first.",
);
}
let nextDocument = nextEntries;
if (intent.native) nextDocument = { ...document, native: nextEntries };
const patch = { [field]: JSON.stringify(nextDocument, null, 2) };
const parsed = buildDefinition({ ...values, ...patch });
if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Invalid widget definition");
return { patch, preview: JSON.stringify(updated, null, 2) };
}
@@ -0,0 +1,65 @@
"use client";
import { Controls } from "@xyflow/react";
import { Button, Group, Menu } from "@mantine/core";
import { IconArrowBackUp, IconChevronDown, IconFocus2, IconLayoutDistributeHorizontal } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import classes from "./workbench.module.css";
export function CanvasControls({
arranging,
selection,
branch,
arrange,
focus,
fit,
}: {
arranging: boolean;
selection: string[];
branch: boolean;
arrange(selectionOnly?: boolean): void;
focus(related: boolean): void;
fit(): void;
}) {
const t = useI18n("customWidget.flow");
return (
<Group className={classes.canvasTools} gap={4} p="xs">
<Menu position="bottom-start" withinPortal>
<Menu.Target>
<Button size="compact-sm" variant="default" rightSection={<IconChevronDown size={14} />} loading={arranging}>
{t("canvasOptions")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLayoutDistributeHorizontal size={16} />}
disabled={arranging}
onClick={() => arrange()}
>
{t("arrange")}
</Menu.Item>
<Menu.Item disabled={!selection.length || arranging} onClick={() => arrange(true)}>
{t("arrangeSelection")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconFocus2 size={16} />} onClick={fit}>
{t("fit")}
</Menu.Item>
{selection.length > 0 && <Menu.Item onClick={() => focus(false)}>{t("focus")}</Menu.Item>}
{selection.length > 0 && <Menu.Item onClick={() => focus(true)}>{t("focusBranch")}</Menu.Item>}
</Menu.Dropdown>
</Menu>
{branch && (
<Button size="compact-sm" variant="default" leftSection={<IconArrowBackUp size={14} />} onClick={fit}>
{t("allNodes")}
</Button>
)}
</Group>
);
}
export function CanvasViewportControls() {
const t = useI18n("customWidget.flow");
return (
<fieldset aria-label={t("canvasControls")} style={{ border: 0, margin: 0, padding: 0, minInlineSize: 0 }}>
<Controls showInteractive={false} aria-label="" />
</fieldset>
);
}
@@ -0,0 +1,22 @@
import { useEffect } from "react";
import { useReducedMotion } from "@mantine/hooks";
import { useReactFlow } from "@xyflow/react";
/** Keep the rendered widget beside its JSX editor after the canvas gives the editor room. */
export function useCanvasEditorFocus(opened: boolean, selected: string) {
const { fitView } = useReactFlow();
const reducedMotion = useReducedMotion();
useEffect(() => {
if (!opened || selected !== "widget") return;
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
void fitView({ nodes: [{ id: "widget" }], maxZoom: 1, padding: 0.2, duration: reducedMotion ? 0 : 180 });
});
});
return () => cancelAnimationFrame(frame);
}, [fitView, opened, selected, reducedMotion]);
}
export function isPreviewInteraction(target: EventTarget) {
return target instanceof Element && !!target.closest("[data-flow-preview], [data-mantine-portal]");
}
@@ -0,0 +1,166 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, KeyboardEvent, SetStateAction } from "react";
import { applyNodeChanges } from "@xyflow/react";
import type { AriaLabelConfig, EdgeChange, NodeChange, NodeMouseHandler, EdgeMouseHandler } from "@xyflow/react";
import { useI18n } from "@homarr/translation/client";
import type { CustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import type { createFlowCommands } from "./commands";
import type { WidgetEdge, WidgetNode } from "./graph";
import { captureLayout } from "./layout";
import { isPreviewInteraction } from "./canvas-editor-focus";
interface Props {
edges: WidgetEdge[];
selection: string[];
store: CustomWidgetFormDocumentStore;
commands: ReturnType<typeof createFlowCommands>;
getNodes(): WidgetNode[];
setNodes: Dispatch<SetStateAction<WidgetNode[]>>;
onSelect(id: string): void;
onInspect(id: string): void;
onSelection(ids: string[]): void;
onIssue(issue: string | null): void;
}
/** Owns canvas selection and keyboard commands without putting transient selection into document history. */
export function useCanvasInteractions({
edges,
selection,
store,
commands,
getNodes,
setNodes,
onSelect,
onInspect,
onSelection,
onIssue,
}: Props) {
const t = useI18n("customWidget.flow");
const ariaLabelConfig = useMemo<Partial<AriaLabelConfig>>(
() => ({
"controls.zoomIn.ariaLabel": t("zoomIn"),
"controls.zoomOut.ariaLabel": t("zoomOut"),
"controls.fitView.ariaLabel": t("fit"),
"controls.ariaLabel": t("canvasControls"),
"handle.ariaLabel": t("connect"),
"node.a11yDescription.default": t("nodeKeyboardHelp"),
"node.a11yDescription.keyboardDisabled": t("nodePointerHelp"),
"node.a11yDescription.ariaLiveMessage": ({ x, y }) => t("nodeMoved", { x, y }),
"edge.a11yDescription.default": t("edgeKeyboardHelp"),
}),
[t],
);
const [selectedEdge, setSelectedEdge] = useState<string | null>(null);
const selectionRef = useRef(selection);
selectionRef.current = selection;
useEffect(() => {
setNodes((current) =>
current.map((node) => {
const next = selection.includes(node.id);
if (node.selected === next) return node;
return { ...node, selected: next };
}),
);
}, [selection, setNodes]);
const onNodesChange = useCallback(
(changes: NodeChange<WidgetNode>[]) => {
setNodes((current) =>
applyNodeChanges(
changes.filter((change) => change.type !== "remove"),
current,
),
);
const selectionChanges = changes.filter((change) => change.type === "select");
if (selectionChanges.length === 0) return;
const ids = new Set(selectionRef.current);
for (const change of selectionChanges) {
if (change.selected) ids.add(change.id);
else ids.delete(change.id);
}
onSelection([...ids]);
},
[onSelection, setNodes],
);
const onEdgesChange = useCallback((changes: EdgeChange<WidgetEdge>[]) => {
setSelectedEdge((current) => {
let next = current;
for (const change of changes) {
if (change.type !== "select") continue;
if (change.selected) next = change.id;
else if (next === change.id) next = null;
}
return next;
});
}, []);
const inspectEdge = useCallback(
(edge: WidgetEdge) => {
let id = edge.target;
if (edge.data?.relationship === "invalidates") id = edge.source;
setSelectedEdge(edge.id);
onSelection([]);
onInspect(id);
},
[onInspect, onSelection],
);
const onNodeClick: NodeMouseHandler<WidgetNode> = useCallback(
(event, node) => {
if (isPreviewInteraction(event.target)) return;
setSelectedEdge(null);
onInspect(node.id);
},
[onInspect],
);
const onEdgeClick: EdgeMouseHandler<WidgetEdge> = useCallback((_event, edge) => inspectEdge(edge), [inspectEdge]);
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!["Delete", "Backspace"].includes(event.key) || !(event.target instanceof Element)) return;
const edgeId = event.target.closest(".react-flow__edge")?.getAttribute("data-id");
const edge = edges.find((entry) => entry.id === edgeId);
if (!edge) return;
// A focused edge owns Delete; never forward it to the workbench's previous node selection.
event.preventDefault();
event.stopPropagation();
onSelection([]);
const result = commands.disconnect(edge);
onIssue(result);
if (!result) {
setSelectedEdge(null);
// The removed SVG cannot retain focus. Keep Undo and subsequent keyboard commands inside the workbench.
event.currentTarget
.querySelector<HTMLElement>(`.react-flow__node[data-id="${CSS.escape(edge.source)}"]`)
?.focus({ preventScroll: true });
}
},
[commands, edges, onIssue, onSelection],
);
const onKeyUp = useCallback(
(event: KeyboardEvent) => {
if (isPreviewInteraction(event.target)) return;
if (!(event.target instanceof Element)) return;
if (event.target.closest('input, textarea, button, [contenteditable="true"], [role="combobox"]')) return;
const nodeId = event.target.closest(".react-flow__node")?.getAttribute("data-id");
if (nodeId && event.key === "F2") {
event.preventDefault();
onSelect(nodeId);
return;
}
if (["Enter", " "].includes(event.key)) {
if (nodeId) {
setSelectedEdge(null);
onInspect(nodeId);
}
const edgeId = event.target.closest(".react-flow__edge")?.getAttribute("data-id");
const edge = edges.find((entry) => entry.id === edgeId);
if (edge) inspectEdge(edge);
return;
}
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) return;
if (!event.target.closest(".react-flow__node, .react-flow__nodesselection")) return;
requestAnimationFrame(() => store.setLayout(captureLayout(getNodes(), store.getLayout())));
},
[edges, getNodes, inspectEdge, onInspect, onSelect, store],
);
return { selectedEdge, ariaLabelConfig, onNodesChange, onEdgesChange, onNodeClick, onEdgeClick, onKeyDown, onKeyUp };
}
@@ -0,0 +1,80 @@
"use client";
import { createContext, memo, useContext, useMemo } from "react";
import type { ReactNode } from "react";
import { Button, Group, Text } from "@mantine/core";
import { Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import { IconCode } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import type { WidgetNode } from "./graph";
import { FlowNode, FlowPort, useValidConnectionTarget } from "./node";
import { FlowGroup } from "./group";
import { WorkbenchPreviewTheme } from "./preview-theme";
import { WorkbenchPreviewPresentationProvider } from "./preview-presentation";
import classes from "./workbench.module.css";
const CanvasPreviewContext = createContext<{ preview: ReactNode; edit(id: string): void } | null>(null);
export function CanvasPreviewProvider({
preview,
edit,
children,
}: {
preview: ReactNode;
edit(id: string): void;
children: ReactNode;
}) {
const value = useMemo(() => ({ preview, edit }), [preview, edit]);
return <CanvasPreviewContext.Provider value={value}>{children}</CanvasPreviewContext.Provider>;
}
export const CanvasWidgetNode = memo(function CanvasWidgetNode({
id,
data,
selected,
isConnectable,
}: NodeProps<WidgetNode>) {
const context = useContext(CanvasPreviewContext);
const t = useI18n("customWidget.flow");
const validTarget = useValidConnectionTarget(id, isConnectable);
return (
<div className={classes.canvasWidgetNode} data-selected={selected} data-valid-target={validTarget}>
<FlowPort
id="in"
type="target"
position={Position.Left}
connectable={isConnectable}
label={`${t("input")} · ${data.label}`}
title={t("input")}
/>
<Group className="widget-node-drag" justify="space-between" wrap="nowrap" p="sm">
<Text size="sm" fw={600} truncate>
{data.label}
</Text>
<Button
className="nodrag nopan"
type="button"
variant="default"
size="compact-xs"
leftSection={<IconCode size={14} />}
onClick={() => context?.edit(id)}
>
{t("editJsx")}
</Button>
</Group>
<div className="nodrag nopan nowheel" data-flow-preview>
<WorkbenchPreviewTheme controls={false}>
<WorkbenchPreviewPresentationProvider mode="canvas">{context?.preview}</WorkbenchPreviewPresentationProvider>
</WorkbenchPreviewTheme>
</div>
<FlowPort
id="out"
type="source"
position={Position.Right}
connectable={isConnectable}
label={`${t("output")} · ${data.label}`}
title={t("output")}
/>
</div>
);
});
export const canvasNodeTypes = { widget: FlowNode, preview: CanvasWidgetNode, group: FlowGroup };
Loaded 100 of 455 files, more files were not shown because too many files have changed in this diff. Show more