Compare commits

..
321 changed files with 35311 additions and 30936 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]/`
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`.
When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
+5 -9
View File
@@ -1,17 +1,13 @@
---
name: documentation-sync
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.
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.
---
# Documentation Sync
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.
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.
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
## Map the change
| Code change | Documentation target |
| ----------------------------------- | -------------------------------------------------------------------- |
@@ -44,11 +40,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.
- Explain only non-obvious option semantics, integration prerequisites, permission constraints, or behavior that the interface does not make clear.
- Document options, required integrations, permissions, empty states, and changed behavior that affect setup or use.
## Completion criteria
1. Every passage adds information an advanced user cannot reasonably infer from the interface. Remove obvious, repetitive, or unnecessary prose.
1. Describe the shipped behavior, not an implementation plan.
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.
@@ -13,7 +13,7 @@ interface HomarrCustomWidgetV2 {
}
```
`sources.default` is required. HTTP source properties: `name?`, `baseUrl`, `networkScope`, and `auth?`:
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?`:
```json
{
@@ -40,12 +40,10 @@ interface HomarrCustomWidgetV2 {
}
```
Integration sources use `{"type":"integration","integrationKind":"sonarr"}`. Bind `integrationId` from `integration_all`; exports omit it. Calls require full integration access. Omit URL/auth fields. Paths append to the integration URL; non-GET requests must be actions.
Auth is `none`, `bearer`, `basic`, `{ "type": "apiKeyHeader", "name": "X-Api-Key" }`, or `{ "type": "apiKeyQuery", "name": "api_key" }`. A request defaults to source `default`, kind `query`, method `GET`, trigger `load`, inherited auth, and view permission. Set `trigger: "manual"` for a parameterized query. An action defaults to manual and modify permission. DELETE uses full permission and confirmation. Do not use `load: false`.
Auth: `none`, `bearer`, `basic`, `{ "type": "apiKeyHeader", "name": "X-Api-Key" }`, or `{ "type": "apiKeyQuery", "name": "api_key" }`. Requests default to source `default`, kind `query`, method `GET`, trigger `load`, inherited auth, and view permission. Parameterized queries need `trigger: "manual"`. Actions default to manual/modify; DELETE requires full permission and confirmation. Do not use `load: false`.
Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest.
HTTP sources declare public URLs or self-hosted suggestions; installers configure their URL, network scope, and credentials separately.
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.
Paths bind `{option:name}` and `{param:name}`; query/body references bind `{ "$option": "name" }` and `{ "$param": "name" }`. Use primitive constants (`take: 10`). `$param` is only for manual requests.
Options require `label`, `control`, and `default`. Optional fields: `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, `group`.
Every option has `label`, `control`, and `default`. Optional fields are `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, and `group`.
+2 -3
View File
@@ -16,10 +16,9 @@ LOG_LEVEL='info'
# This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3'
# 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.
# Safe local source-development path. SQLite files are ignored by Git.
# Container images use /appdata/db/db.sqlite by default instead.
DB_URL=
DB_URL='./db.sqlite'
# Those are the two ways to use the mysql2 driver:
# 1. Using the URL format:
-13
View File
@@ -25,18 +25,6 @@ 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:
@@ -45,7 +33,6 @@ 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
+57 -10
View File
@@ -97,16 +97,17 @@ jobs:
- name: Validate Custom Widget bundle budget
run: |
pnpm turbo run build --filter=@homarr/nextjs
pnpm --filter nextjs build
pnpm check:custom-widget-bundle
- name: Run unit tests
run: pnpm test
container:
name: Container build
# Same-repository previews already build amd64; only build a fallback when no digest is available.
name: Container and E2E
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') ||
@@ -114,9 +115,10 @@ jobs:
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
)
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
@@ -124,10 +126,23 @@ jobs:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
if: "!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
@@ -137,7 +152,9 @@ jobs:
platforms: linux/amd64
load: true
push: false
tags: homarr-ci
tags: |
homarr-ci
homarr-e2e
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
@@ -165,7 +182,37 @@ jobs:
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci "${secret_args[@]}" .
--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
preview-amd64:
name: Build amd64 preview image
@@ -273,10 +320,10 @@ jobs:
publish-preview:
name: Publish multi-platform preview image
needs: [fast-gate, preview-amd64, preview-arm64]
needs: [container, preview-amd64, preview-arm64]
if: |
!cancelled() &&
needs.fast-gate.result == 'success' &&
needs.container.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
(
+8 -19
View File
@@ -16,10 +16,10 @@ permissions:
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 2
steps:
- name: Discord notification
if: ${{ inputs.send-notifications }}
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
@@ -37,22 +37,11 @@ jobs:
branch: next
- name: Create pull request
id: create-pull-request
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"
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'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ inputs.send-notifications }}
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
@@ -69,7 +58,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
run: |
gh pr review "${{ steps.create-pull-request.outputs.url }}" --approve --body "Automatically approved by GitHub Action"
gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: actions/create-github-app-token@v3
@@ -93,21 +82,21 @@ jobs:
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && inputs.send-notifications }}
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.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' && inputs.send-notifications }}
if: ${{ steps.semver.outputs.bump == 'major' && github.event.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() && inputs.send-notifications }}
if: failure() && github.event.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
+12 -29
View File
@@ -2,36 +2,8 @@ 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
@@ -43,10 +15,21 @@ concurrency:
jobs:
validate:
runs-on: ubuntu-24.04
timeout-minutes: 5
timeout-minutes: 30
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')
+3 -13
View File
@@ -59,13 +59,7 @@ homarr/
The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`).
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:
When modifying user-facing code, you MUST also update the corresponding documentation:
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
@@ -92,7 +86,7 @@ When documentation is needed, use these locations:
- Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- 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.
- Mantine: use the project-scoped MCP server in `.mcp.json` for current v9 APIs, and check `packages/ui/` before creating a new primitive.
- 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.
@@ -100,15 +94,11 @@ When documentation is needed, use these locations:
- 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` — documentation for changes users need explained
- `documentation-sync` — required user-facing documentation updates
- `mcp-integration` — safe tRPC-to-MCP exposure
- `homarr-custom-widget` — safe Custom JSX v2 authoring
-4
View File
@@ -1,7 +1,3 @@
## [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)
+14 -6
View File
@@ -15,13 +15,19 @@ 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 builds on the dashboard you already use. Here is what changed.
Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is what actually changed.
![A populated Homarr 2.0 dashboard with compact widgets, an Assistant, and an application rail](./img/homarr-v2-dashboard.webp)
<!-- truncate -->
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.
:::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.
:::
## Custom Widgets v2 and Workshop
@@ -144,8 +150,9 @@ 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.
## Compatibility notes
## Before upgrading
- 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.
@@ -153,7 +160,8 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
## Links
- Try the [live demo](https://demo.homarr.dev/).
- See [installation and update instructions](/docs/getting-started/installation/docker).
- Browse [Workshop](https://homarr.dev/workshop).
- 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).
- 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://workshop.example.com/workshop
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
```
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
@@ -31,13 +31,9 @@ pnpm install --frozen-lockfile
### Run the Homarr application
1. Copy `.env.example` to `.env`.
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.
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.
```sh
cp .env.example .env
@@ -61,20 +57,6 @@ 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).
@@ -98,27 +80,5 @@ 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: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.
- `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.
@@ -13,8 +13,7 @@ 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
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.
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
:::
## Without mounted Docker socket
@@ -24,10 +24,6 @@ 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/) 24.18 or newer
- [pnpm](https://pnpm.io/installation) at the version pinned in the repository's `package.json`
- [Node.js](https://nodejs.org/en/download/)
- [pnpm](https://pnpm.io/installation)
- [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 `.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.
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.
</li>
<li>
Run `openssl rand -hex 32` twice and set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
@@ -0,0 +1,74 @@
---
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,8 +28,6 @@ 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 />
@@ -1,55 +0,0 @@
---
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: [],
},
]}
/>
@@ -1,9 +0,0 @@
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,8 +30,6 @@ 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,9 +19,6 @@ 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,8 +23,6 @@ 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,14 +24,12 @@ 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 uses `/api/containers` under that URL for both connection testing and widget data.
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.
:::
### Secrets
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.
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.
<IntegrationSecrets
secrets={[
@@ -42,7 +40,7 @@ Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside
{
credentials: ["username", "password"],
steps: [
"Use your WUD account credentials; older versions can use a configured WUD_AUTH_BASIC_* user",
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
],
},
+5 -6
View File
@@ -27,12 +27,6 @@ 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:
@@ -68,6 +62,11 @@ 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
+38 -17
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.
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.
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.
### 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 to add, move, resize, configure, or remove content. Save the board to persist the
layout.
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.
- 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,19 +56,40 @@ layout.
- 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
Board settings cover:
Settings are organized into tabs, with one **Save changes** action for the whole board. Switching tabs retains your
draft; **Discard** restores the saved settings.
- **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.
- **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.
See [Styling](/docs/advanced/styling) for board and global CSS.
@@ -64,8 +64,3 @@ 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).
@@ -57,5 +57,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.
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.
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.
@@ -3,28 +3,10 @@
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,
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. HTTP source credentials belong to the widget but remain outside its manifest.
`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.
## Existing integrations
Sources can reuse any configured HTTP integration, including its saved credentials and login session. WebSocket-only integrations and the internal mock integration are excluded:
```json
{
"sources": { "default": { "type": "integration", "integrationKind": "sonarr" } },
"requests": { "queue": { "path": "/api/v3/queue" } }
}
```
Select the local instance during source setup. Homarr uses its current URL, API key, and trusted certificates;
paths append to the saved URL, including reverse-proxy subpaths. Changing the integration updates all sources bound to it.
Deleting it leaves the widget unconfigured until another instance is selected. The binding is shared by every placement
of that widget definition. Exports and Workshop submissions retain the required type and omit the local integration ID.
Arbitrary integration requests require integration `full` access, including GET requests: service APIs can expose credentials or change state through reads. Board permissions still apply separately. Saved credentials are redacted from upstream responses. Non-GET integration requests must be actions. Requests and redirects stay within the configured URL path. Expired sessions are refreshed; queries can retry once, while actions require another explicit invocation.
## 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:
+34 -37
View File
@@ -8,17 +8,18 @@ tags:
# Model Context Protocol (MCP)
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.
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.
![The AI / MCP management tab](./img/mcp.png)
## Connect a client
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.
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
replacing the URL and API key:
## Configure a client
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
MCP configuration:
```json
{
@@ -33,10 +34,7 @@ replacing the URL and API key:
}
```
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`:
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
```json
{
@@ -49,30 +47,26 @@ For clients that only support STDIO, use `mcp-remote`:
}
```
### OAuth
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.
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
Homarr publishes discovery metadata under `/.well-known/`.
## Authentication and permissions
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.
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.
## Permissions and tools
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.
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.
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.
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.
Use HTTPS when the endpoint crosses an untrusted network.
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
## Verify the endpoint
## Verify the connection
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
The 2025 compatibility path accepts a compact `tools/list` request:
```sh
curl -X POST \
@@ -83,14 +77,17 @@ curl -X POST \
https://homarr.example.com/api/mcp
```
A successful discovery or initialization response alone does not verify that tools can be listed.
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
## Troubleshooting
## Tools and resources
- **`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.
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.
@@ -18,10 +18,6 @@ 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. In view mode, right-click uses the browser's link menu; Homarr's widget menu remains available in edit mode.
check from the tile's edit actions.
### Adding the widget
@@ -22,8 +22,6 @@ 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";
@@ -1,43 +0,0 @@
---
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
@@ -1,37 +0,0 @@
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,7 +41,6 @@ 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,6 +51,4 @@ 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} />
@@ -32,7 +32,6 @@ import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { integrationDefs, isHttpIntegrationKind } from "@homarr/definitions";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
githubAvatarUrl,
@@ -97,15 +96,7 @@ const isNotFound = (caught: unknown) =>
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
type WidgetSource = HomarrCustomWidgetV2["sources"][string];
const sourceHost = (source: WidgetSource) => {
if (source.type === "integration") {
if (isHttpIntegrationKind(source.integrationKind))
return `${integrationDefs[source.integrationKind].name} integration`;
return `${source.integrationKind} integration`;
}
const { baseUrl } = source;
const sourceHost = (baseUrl: string) => {
try {
return new URL(baseUrl).host;
} catch {
@@ -113,30 +104,26 @@ const sourceHost = (source: WidgetSource) => {
}
};
const sourceAuthLabel = (source: WidgetSource) => {
if (source.type === "integration") return "Uses stored integration credentials";
const { auth } = source;
if (auth === "none") return "No credentials";
if (auth === "basic") return "Basic auth";
if (auth === "bearer") return "Bearer token";
if (auth.type === "apiKeyHeader") return `API key header · ${auth.name}`;
return `API key query · ${auth.name}`;
const sourceAuthLabel = (auth: HomarrCustomWidgetV2["sources"][string]["auth"]) => {
if (typeof auth === "string")
return auth === "none" ? "No credentials" : auth === "basic" ? "Basic auth" : "Bearer token";
return auth.type === "apiKeyHeader" ? `API key header · ${auth.name}` : `API key query · ${auth.name}`;
};
const sourceNeedsSecret = (source: WidgetSource) => source.type === "integration" || source.auth !== "none";
const sourceNeedsSecret = (auth: HomarrCustomWidgetV2["sources"][string]["auth"]) => auth !== "none";
const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => {
const sources = Object.entries(widget.sources);
const requests = Object.values(widget.requests);
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source));
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
const queryCount = requests.filter((request) => request.kind === "query").length;
const actionCount = requests.filter((request) => request.kind === "action").length;
const credentialLabel =
protectedSources.length === 0
? "None required"
: protectedSources.length === 1
? sourceAuthLabel(protectedSources[0][1])
: `${protectedSources.length} authenticated sources`;
? sourceAuthLabel(protectedSources[0][1].auth)
: `${protectedSources.length} credentials required`;
return (
<section
@@ -165,9 +152,9 @@ const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => {
<dd className="mt-1.5 space-y-1.5">
{sources.slice(0, 2).map(([id, source]) => (
<span key={id} className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate font-mono text-xs font-medium">{sourceHost(source)}</span>
<span className="truncate font-mono text-xs font-medium">{sourceHost(source.baseUrl)}</span>
<Badge variant="secondary" className="shrink-0 font-normal">
{source.networkScope ?? "integration"}
{source.networkScope}
</Badge>
</span>
))}
@@ -549,7 +536,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const sources = widgetDefinition ? Object.entries(widgetDefinition.sources) : [];
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source));
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
const displayedContent = formatWorkshopContent(submission.type, submission.content);
const socialTitle = `${submission.title} · Homarr Workshop`;
const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim();
@@ -792,11 +779,11 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<div className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium">{source.name || id}</span>
<Badge variant="secondary" className="shrink-0">
{source.networkScope ?? "integration"}
{source.networkScope}
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">{sourceHost(source)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{sourceAuthLabel(source)}</p>
<p className="truncate text-xs text-muted-foreground">{sourceHost(source.baseUrl)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{sourceAuthLabel(source.auth)}</p>
</div>
))}
</div>
+10 -5
View File
@@ -2151,6 +2151,14 @@
}
]
},
"fixedScaling": {
"type": "boolean"
},
"fixedItemSize": {
"type": "integer",
"minimum": 40,
"maximum": 200
},
"disableStatus": {
"type": "boolean"
}
@@ -2304,8 +2312,7 @@
"traefik",
"customApi",
"assistant",
"wud",
"llamacpp"
"wud"
]
},
"options": {
@@ -2573,9 +2580,7 @@
"type": "object",
"properties": {
"expirationDate": {
"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)))$"
"type": "string"
}
},
"required": [
@@ -1,7 +1,6 @@
.columns {
display: grid;
align-items: start;
gap: 24px;
}
.lane {
@@ -27,3 +26,10 @@
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,15 +13,16 @@ 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";
@@ -60,99 +61,91 @@ export const ClientBoard = () => {
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
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 { width: logicalWidth, gridTemplateColumns } = getBoardCanvasGeometry(currentLayout);
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>
<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}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<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")}>
<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}
>
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
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>
</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>
</BoardContextMenu>
</BoardAdvancedFocusProvider>
</BoardSelectionGridProvider>
);
@@ -36,7 +36,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardEditAction: <BoardContentEditAction />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
@@ -3,18 +3,14 @@
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { IconChevronDown, IconPlus } 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 { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { BoardAddMenuItems } from "~/components/board/board-add-menu-items";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -22,16 +18,9 @@ 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();
@@ -48,34 +37,6 @@ 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>
@@ -87,21 +48,7 @@ const AddMenu = () => {
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<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>
<BoardAddMenuItems />
</Menu.Dropdown>
</Menu>
);
@@ -0,0 +1,247 @@
"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,45 +1,15 @@
"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 { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import { useOptionalBoardEditing } from "~/components/board/board-editing-context";
import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
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 BoardEditActions = dynamic(() => import("./_edit-actions"), { ssr: false });
export const BoardContentEditAction = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
@@ -49,7 +19,7 @@ export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean
return (
<>
{isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} />
<EditModeMenu />
</>
);
};
@@ -70,127 +40,26 @@ export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boo
);
};
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 EditModeMenu = () => {
const editing = useOptionalBoardEditing();
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 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 (!editing) return null;
const {
isEditMode,
toggle,
prewarmEditor,
editorLoadState,
isPending,
isEnteringEditMode,
returnToLayoutSettings,
cancelLayoutEdit,
} = editing;
if (returnToLayoutSettings) {
return (
<>
<HeaderButton
onClick={() => saveBoard(board)}
onClick={() => void toggle()}
loading={isPending}
aria-label={tBoardSetting("section.layout.edit.save")}
>
@@ -217,7 +86,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
data-testid="board-edit-mode-toggle"
data-board-editor-preload-state={editorLoadState}
aria-busy={isPending || isEnteringEditMode}
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
aria-label={editing.label}
aria-pressed={isEditMode}
>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
@@ -225,57 +94,3 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
</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]);
};
@@ -0,0 +1,51 @@
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,7 +1,6 @@
"use client";
import {
Anchor,
Button,
Collapse,
ColorInput,
@@ -10,7 +9,6 @@ import {
Group,
InputWrapper,
isLightColor,
Select,
Slider,
Stack,
Text,
@@ -21,7 +19,7 @@ import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput } from "@homarr/ui";
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card";
@@ -39,7 +37,6 @@ 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();
@@ -50,6 +47,11 @@ 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")}
@@ -57,10 +59,22 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
<BoardColorInput
label={tBoard("field.secondaryColor.label")}
eyeDropperButtonProps={{
"aria-label": tBoard("setting.section.appearance.pickColor", {
label: tBoard("field.secondaryColor.label"),
}),
}}
{...form.getInputProps("secondaryColor")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor>
<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>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
@@ -73,6 +87,7 @@ 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}
@@ -86,6 +101,11 @@ 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}
@@ -102,18 +122,18 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
<Grid.Col span={12}>
<CornerStylePicker
compact
label={tBoard("field.itemRadius.label")}
description={tBoard("field.itemRadius.description")}
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")}
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)}
/>
</Grid.Col>
</Grid>
@@ -1,7 +1,7 @@
"use client";
import { startTransition } from "react";
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { ActionIcon, Autocomplete, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
@@ -24,6 +24,7 @@ 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);
@@ -50,14 +51,15 @@ 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>
<Center h="100%">
<ActionIcon type="button" variant="subtle" aria-label={tMedia("field.preview")}>
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
</Center>
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
@@ -113,7 +115,15 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<ActionIcon
type="button"
aria-label={tMedia("action.upload.label")}
onClick={onClick}
loading={loading}
mt={24}
size={36}
variant="default"
>
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
@@ -1,70 +1,6 @@
.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;
.preview {
width: 100%;
height: 100%;
overflow: hidden;
}
.tileLabel {
display: none;
max-width: 32rem;
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;
margin-inline: auto;
}
@@ -1,17 +1,17 @@
"use client";
import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import { useMemo } from "react";
import { Badge, ColorSwatch, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
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,107 +20,55 @@ interface Props {
layouts: BoardPreviewLayout[];
sourceLayout: BoardPreviewLayout;
apps: RouterOutputs["app"]["byIds"];
settings: FormValues;
}
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps, settings }: Props) => {
const t = useI18n("board.setting.section.layout.preview");
const tBoard = useI18n("board");
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);
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]);
return (
<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>
<Text size="xs">
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
<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
</Badge>
</Group>
<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>
<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>
<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>
</Stack>
);
};
@@ -2,6 +2,54 @@
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;
@@ -41,6 +89,7 @@
}
@media (prefers-reduced-motion: reduce) {
.fixedItemSizePreviewTile,
.gutterPreview {
transition: none;
}
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Badge,
@@ -12,6 +12,8 @@ import {
Input,
NumberInput,
Paper,
Select,
Alert,
Slider,
Stack,
Switch,
@@ -21,6 +23,7 @@ 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";
@@ -30,6 +33,7 @@ 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";
@@ -38,11 +42,22 @@ 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 }: Props) => {
export const LayoutSettingsContent = ({
board,
form,
isSaving,
saveSettingsAsync,
selectedLayoutId,
setSelectedLayoutId,
apps,
}: Props) => {
const tBoard = useI18n("board");
const tLayout = useI18n("layout");
const tCommon = useI18n("common");
@@ -58,18 +73,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
});
},
});
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);
@@ -106,10 +109,14 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
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>
@@ -133,141 +140,169 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
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>
{displayedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
<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}.`)) ? " ⚠" : ""}`,
}))}
/>
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={{ 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);
}}
{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}
/>
</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)}
/>
{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>
<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 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>
<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>
);
})}
</Fieldset>
);
})}
</Stack>
</SectionCard>
);
@@ -0,0 +1,103 @@
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>
);
};
@@ -0,0 +1,43 @@
.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,13 +1,16 @@
"use client";
import { useEffect, useRef } from "react";
import { Button, Stack } from "@mantine/core";
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 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";
@@ -20,8 +23,7 @@ import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-guard";
import type { Board } from "../../_types";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { AppearancePlayground } from "./_appearance-playground";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { CustomCssSettingsContent } from "./_customCss";
@@ -29,6 +31,7 @@ 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({
@@ -59,6 +62,8 @@ const PARTIAL_FORM_KEYS = [
"itemRadius",
"customCss",
"disableStatus",
"fixedScaling",
"fixedItemSize",
] as const;
const buildInitialValues = (board: Board): FormValues => ({
@@ -77,6 +82,8 @@ 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),
});
@@ -94,6 +101,21 @@ 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),
@@ -120,7 +142,22 @@ 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]);
@@ -165,6 +202,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
};
const handleDiscard = () => {
form.clearErrors();
form.setValues(initialValuesRef.current);
form.resetDirty();
updateBoard((previous) => ({
@@ -177,45 +215,101 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void saveSettingsAsync(values))}>
<Stack gap="xl">
<GeneralSettingsContent board={board} form={form} />
<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">
<LayoutSettingsContent
board={board}
form={form}
isSaving={isPending}
saveSettingsAsync={() => saveSettingsAsync(form.values)}
selectedLayoutId={selectedLayoutId}
setSelectedLayoutId={setSelectedLayoutId}
apps={apps}
/>
<BackgroundSettingsContent form={form} />
<ColorSettingsContent form={form} />
</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">
<CustomCssSettingsContent form={form} />
<BehaviorSettingsContent form={form} />
</Tabs.Panel>
{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.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>
{hasFullAccess && (
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
<Tabs.Panel value="access">
<Stack gap="lg">
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
<DangerZoneSettingsContent hideVisibility={hideVisibility} />
</Stack>
</Tabs.Panel>
)}
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
</Stack>
</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";
};
// Previously part of the general settings section; applied on the unified save
// https://github.com/homarr-labs/homarr/issues/4905
const updateFavicon = (url: string) => {
@@ -20,6 +20,7 @@ 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";
@@ -106,20 +107,25 @@ export const createBoardLayout = <TParams extends Params>({
<BoardReadyProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<ModalProvider>
<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
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>
</ModalProvider>
</BoardMantineProvider>
</BoardReadyProvider>
@@ -16,9 +16,6 @@ import {
} from "@mantine/core";
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { IntegrationSourceSelect } from "~/components/custom-widgets/integration-source-select";
import { useI18n } from "@homarr/translation/client";
interface RequestDetails {
@@ -27,7 +24,10 @@ interface RequestDetails {
kinds: Array<"apiKey" | "username" | "password">;
expiresAt: number;
status: "pending" | "completed";
source: CustomWidgetSource;
source: {
baseUrl: string;
networkScope: "public" | "private" | "loopback";
};
}
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
@@ -35,7 +35,6 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const tSecret = useI18n("customWidget.secret");
const [details, setDetails] = useState<RequestDetails | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [integrationId, setIntegrationId] = useState<string>();
const [baseUrl, setBaseUrl] = useState("");
const [networkScope, setNetworkScope] = useState<"public" | "private" | "loopback">("public");
const [error, setError] = useState<string | null>(null);
@@ -48,9 +47,8 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const body = (await response.json()) as RequestDetails | { error: string };
if (!response.ok || "error" in body) throw new Error("error" in body ? body.error : t("unavailable"));
setDetails(body);
setBaseUrl(body.source.baseUrl ?? "");
setNetworkScope(body.source.networkScope ?? "public");
setIntegrationId(body.source.integrationId);
setBaseUrl(body.source.baseUrl);
setNetworkScope(body.source.networkScope);
})
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable")))
.finally(() => setLoading(false));
@@ -60,12 +58,10 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
setSaving(true);
setError(null);
try {
let configuration: unknown = { baseUrl, networkScope, secrets: values };
if (details?.source.type === "integration") configuration = { integrationId, secrets: {} };
const response = await fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(configuration),
body: JSON.stringify({ baseUrl, networkScope, secrets: values }),
});
const body = (await response.json()) as { status?: string; error?: string };
if (!response.ok) throw new Error(body.error ?? t("saveError"));
@@ -106,31 +102,20 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
{t("source", { name: details.sourceName })}
</Text>
</Card>
{details.source.type === "integration" && (
<IntegrationSourceSelect
kind={details.source.integrationKind}
integrationId={integrationId}
onChange={setIntegrationId}
/>
)}
{details.source.type !== "integration" && (
<>
<TextInput
label={t("baseUrl")}
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.currentTarget.value)}
required
/>
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={networkScope}
allowDeselect={false}
onChange={(value) => value && setNetworkScope(value as typeof networkScope)}
/>
</>
)}
<TextInput
label={t("baseUrl")}
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.currentTarget.value)}
required
/>
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={networkScope}
allowDeselect={false}
onChange={(value) => value && setNetworkScope(value as typeof networkScope)}
/>
{details.kinds.map((kind) => {
const Input = kind === "username" ? TextInput : PasswordInput;
return (
@@ -147,10 +132,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
})}
<Button
loading={saving}
disabled={
(details.source.type === "integration" ? !integrationId : !URL.canParse(baseUrl)) ||
details.kinds.some((kind) => !values[kind])
}
disabled={!URL.canParse(baseUrl) || details.kinds.some((kind) => !values[kind])}
onClick={() => void submit()}
>
{t("save")}
@@ -1,12 +1,43 @@
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");
});
});
@@ -0,0 +1,36 @@
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,");
});
});
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
@@ -75,6 +76,16 @@ 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(
@@ -4,15 +4,9 @@ import { Button, Fieldset, Group, PasswordInput, Select, Stack, TextInput } from
import { IconKey, IconTrash } from "@tabler/icons-react";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type {
CustomWidgetHttpSource,
CustomWidgetSource,
CustomWidgetSourceUrlIssue,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { IntegrationSourceSelect } from "~/components/custom-widgets/integration-source-select";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
@@ -58,6 +52,9 @@ export function CustomWidgetSourceField({
onRemove,
}: CustomWidgetSourceFieldProps) {
const t = useI18n("customWidget.workbench.sources");
const tSecret = useI18n("customWidget.secret");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return (
<Fieldset legend={index === 0 ? t("primary") : source.name}>
@@ -76,41 +73,72 @@ export function CustomWidgetSourceField({
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
/>
</Group>
<Select
label={t("sourceType")}
data={[
{ value: "http", label: t("manualSource") },
{ value: "integration", label: t("existingIntegration") },
]}
value={source.type ?? "http"}
allowDeselect={false}
onChange={(type) => {
if (type === "integration") onUpdate(index, { type, integrationKind: "sonarr" });
else if (type === "http")
onUpdate(index, { type, baseUrl: "https://example.com", networkScope: "public", auth: "none" });
}}
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
/>
{source.type === "integration" && (
<IntegrationSourceSelect
kind={source.integrationKind}
integrationId={source.integrationId}
onChange={(integrationId) => onUpdate(index, { integrationId })}
onKindChange={(integrationKind) => onUpdate(index, { integrationKind, integrationId: undefined })}
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) =>
value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })
}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={authType}
onChange={(value) => onSetAuthentication(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
/>
)}
{source.type !== "integration" && (
<CustomWidgetHttpSourceFields
source={source}
index={index}
form={form}
definitionId={definitionId}
clearSecretPending={clearSecretPending}
onUpdate={onUpdate}
onSetAuthentication={onSetAuthentication}
onSetSecret={onSetSecret}
onClearSecret={onClearSecret}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
/>
)}
{(secretFields[authType] ?? []).map(({ kind }) => {
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">
<Input
style={{ flex: 1 }}
label={tSecret(kind)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretPending}
onClick={() => void onClearSecret(source.id, kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
{index > 0 && (
<Button
type="button"
@@ -126,88 +154,3 @@ export function CustomWidgetSourceField({
</Fieldset>
);
}
function CustomWidgetHttpSourceFields({
source,
index,
form,
definitionId,
clearSecretPending,
onUpdate,
onSetAuthentication,
onSetSecret,
onClearSecret,
}: Omit<CustomWidgetSourceFieldProps, "source" | "onRemove"> & { source: CustomWidgetHttpSource & { id: string } }) {
const t = useI18n("customWidget.workbench.sources");
const tSecret = useI18n("customWidget.secret");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return (
<>
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
/>
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) => value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={authType}
onChange={(value) => onSetAuthentication(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
/>
)}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
/>
)}
{(secretFields[authType] ?? []).map(({ kind }) => {
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">
<Input
style={{ flex: 1 }}
label={tSecret(kind)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretPending}
onClick={() => void onClearSecret(source.id, kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
</>
);
}
@@ -28,12 +28,6 @@ export function CustomWidgetSourcesEditor({
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
const sources = parseSources(form.values.sources);
const update = (index: number, changes: Partial<CustomWidgetSource> & { id?: string }) => {
if (changes.type) {
form.setFieldValue(
"secrets",
form.values.secrets.filter((secret) => secret.sourceId !== sources[index]?.id),
);
}
const previousId = sources[index]?.id;
const nextId = changes.id ?? previousId;
if (nextId && sources.some((source, sourceIndex) => sourceIndex !== index && source.id === nextId)) {
@@ -48,7 +42,7 @@ export function CustomWidgetSourcesEditor({
Object.fromEntries(
sourceEntries.map(([id, source], i) => [
i === index ? nextId : id,
i === index ? { ...(changes.type ? { name: source.name } : source), ...changes, id: undefined } : source,
i === index ? { ...source, ...changes, id: undefined } : source,
]),
),
null,
@@ -1,6 +1,5 @@
"use client";
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import {
@@ -23,7 +22,7 @@ import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconF
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
@@ -191,7 +190,7 @@ export function WorkshopDetail({ id }: { id: string }) {
</Text>
</Box>
<ImportReviewContent review={importer.review} messages={importer.reviewMessages}>
<SourceSetupPanel
<CustomWidgetSourceSetupPanel
setups={importer.setups}
values={importer.values}
onChange={importer.setValue}
@@ -45,7 +45,7 @@ export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
onSubmit={form.onSubmit((values) => {
mutate(
{
expirationDate: new Date(values.expirationDate).toISOString(),
expirationDate: new Date(values.expirationDate),
},
{
onSuccess: (result) => {
@@ -7,7 +7,7 @@ import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepC
import { cookies } from "next/headers";
import { z } from "zod/v4";
import { callMcpTool, createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import {
createAssistantGenerationAccessToken,
getAssistantRequestContextEntitiesAsync,
@@ -547,7 +547,19 @@ export async function POST(request: Request) {
}
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
try {
const result = await callMcpTool(caller, mcpTool, executionInput);
const procedure = mcpTool.pathInRouter.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
if (typeof procedure !== "function") {
throw new Error("Procedure not callable");
}
const result = await (procedure as (value: unknown) => Promise<unknown>)(
executionInput && Object.keys(executionInput as object).length > 0 ? executionInput : undefined,
);
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
return toAssistantToolOutput(result, {
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
@@ -3,7 +3,6 @@ import { NextResponse } from "next/server";
import {
claimCustomWidgetConfigurationRequest,
assertCustomWidgetIntegrationBindings,
completeCustomWidgetConfigurationRequest,
configureCustomWidgetSourceFromRequest,
configurePreviewSessionSource,
@@ -13,7 +12,6 @@ import {
import { customWidgetSourceSchema } from "@homarr/custom-widgets/core";
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
import { db } from "@homarr/db";
import { auth } from "@homarr/auth/next";
import { adminRoute } from "../../admin";
import { readConfigurationRequestBody } from "../body";
@@ -54,40 +52,17 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
}
const body = parsedBody.data;
let candidate: unknown;
if (pending.source.type === "integration") {
if (
!body.integrationId ||
body.baseUrl !== undefined ||
body.networkScope !== undefined ||
Object.keys(body.secrets).length > 0
) {
return NextResponse.json({ error: "Select an existing integration." }, { status: 400 });
}
candidate = { ...pending.source, integrationId: body.integrationId };
} else {
if (body.integrationId)
return NextResponse.json({ error: "HTTP sources cannot bind an integration." }, { status: 400 });
candidate = { ...pending.source, baseUrl: body.baseUrl, networkScope: body.networkScope };
}
const sourceResult = customWidgetSourceSchema.safeParse(candidate);
const sourceResult = customWidgetSourceSchema.safeParse({
...pending.source,
baseUrl: body.baseUrl,
networkScope: body.networkScope,
});
if (!sourceResult.success) {
return NextResponse.json(
{ error: sourceResult.error.issues[0]?.message ?? "Enter a valid server URL." },
{ status: 400 },
);
}
try {
await assertCustomWidgetIntegrationBindings(
{ db, session: await auth() },
{ [pending.sourceId]: sourceResult.data },
);
} catch {
return NextResponse.json(
{ error: "The selected integration is unavailable or has a different type." },
{ status: 400 },
);
}
const secrets = pending.kinds.flatMap((kind) => {
const value = body.secrets[kind];
return typeof value === "string" && value.length > 0 && value.length <= 8192
@@ -120,7 +95,6 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route
const result = await configureCustomWidgetSourceFromRequest(db, {
definitionId: claimed.target.id,
sourceId: claimed.sourceId,
integrationId: sourceResult.data.integrationId,
baseUrl: sourceResult.data.baseUrl,
networkScope: sourceResult.data.networkScope,
secrets,
@@ -1,17 +1,11 @@
import { z } from "zod/v4";
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
const configurationRequestBodySchema = z
.strictObject({
baseUrl: z.string().optional(),
integrationId: z.string().min(1).max(100).optional(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
secrets: z.record(z.string(), z.string()),
})
.refine(
(value) => Boolean(value.integrationId) || (value.baseUrl !== undefined && value.networkScope !== undefined),
"Provide an integration or HTTP connection",
);
const configurationRequestBodySchema = z.strictObject({
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]),
secrets: z.record(z.string(), z.string()),
});
export async function readConfigurationRequestBody(
request: Request,
@@ -2,11 +2,8 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type * as CatalogModule from "../_extract-tools";
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
extractCatalog: vi.fn(),
ipAddress: vi.fn(() => "127.0.0.1"),
toolProcedure: vi.fn(),
loggerWarn: vi.fn(),
@@ -17,9 +14,7 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", async () => ({
callMcpTool: (await import("../../../../../../../packages/api/src/mcp-tools")).callMcpTool,
extractMcpToolsFromProcedures: mocks.extractCatalog,
vi.mock("@homarr/api/mcp", () => ({
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
}));
@@ -59,7 +54,6 @@ vi.mock("../_extract-tools", () => ({
description: "List boards",
type: "query",
pathInRouter: ["board", "getAllBoards"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
{
@@ -67,7 +61,6 @@ vi.mock("../_extract-tools", () => ({
description: "List Custom Widgets",
type: "query",
pathInRouter: ["customWidget", "list"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
{
@@ -75,7 +68,6 @@ vi.mock("../_extract-tools", () => ({
description: "Search Workshop",
type: "query",
pathInRouter: ["customWidget", "workshopSearch"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
],
@@ -313,21 +305,3 @@ describe("MCP protocol errors", () => {
expect(metadata).not.toHaveProperty("message");
});
});
test("logs catalog omissions only when the shared catalog is first built", async () => {
const diagnostic = { name: "broken", path: "broken", reason: "invalid_schema" };
const tools = [{ name: "valid" }];
mocks.extractCatalog.mockReturnValue({ tools, diagnostics: [diagnostic] });
const { extractMcpTools } = await vi.importActual<typeof CatalogModule>("../_extract-tools");
expect(extractMcpTools()).toBe(tools);
expect(extractMcpTools()).toBe(tools);
expect(mocks.extractCatalog).toHaveBeenCalledTimes(1);
expect(mocks.loggerWarn).toHaveBeenCalledExactlyOnceWith("MCP tool omitted from catalog", diagnostic);
});
test("rejects unauthenticated tool calls before execution", async () => {
mocks.authenticate.mockResolvedValue(null);
const { response } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} });
expect(response.status).toBe(401);
expect(mocks.toolProcedure).not.toHaveBeenCalled();
});
@@ -1,12 +1,11 @@
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
import { createLogger } from "@homarr/core/infrastructure/logs";
const logger = createLogger({ module: "mcp-catalog" });
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
export function extractMcpTools() {
if (cache) return cache.tools;
if (cache) return cache;
cache = extractMcpToolsFromProcedures(mcpRouter);
for (const diagnostic of cache.diagnostics) logger.warn("MCP tool omitted from catalog", diagnostic);
return cache.tools;
return cache;
}
+1 -201
View File
@@ -1,18 +1,12 @@
// @vitest-environment node
import { initTRPC, TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { appManageSchema } from "@homarr/validation/app";
import { extractMcpToolsFromProcedures } from "@homarr/api/mcp";
import type { McpMeta } from "../../../../../../packages/api/src/mcp-tools";
import { createMcpProtocolHandler } from "./_protocol";
vi.mock("@homarr/api/mcp", async () => import("../../../../../../packages/api/src/mcp-tools"));
const modernMetadata = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
@@ -58,7 +52,7 @@ const echoTool: McpTool = {
description: "Echo a message",
type: "query",
pathInRouter: ["echo"],
inputMode: "object",
inputValidator: echoValidator,
inputSchema: z.toJSONSchema(echoValidator),
};
@@ -169,197 +163,3 @@ describe("MCP v2 protocol", () => {
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
});
});
describe.each([false, true])("MCP argument dispatch (modern=%s)", (modern) => {
const trpc = initTRPC.context<{ user: string }>().meta<McpMeta>().create();
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
const call = async (
handler: ReturnType<typeof createMcpProtocolHandler>,
name: string,
argumentsValue: Record<string, unknown>,
) => {
let request = createRequest("tools/call", { name, arguments: argumentsValue }, name);
if (!modern) {
request = new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name, arguments: argumentsValue },
}),
});
}
const response = await handler.fetch(request);
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
return (await parseResponse(response)).result;
};
test("transforms once and does not coerce, insert defaults, or remove properties before tRPC", async () => {
const transform = vi.fn((value: string) => `${value}!`);
const execution = vi.fn((input: unknown) => input);
const router = trpc.router({
update: exposed
.input(
z.looseObject({
name: z.string().transform(transform),
limit: z.number().default(7),
}),
)
.mutation(({ input }) => execution(input)),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
const realCaller = router.createCaller({ user: "one" });
const dispatch = vi.fn(realCaller.update);
const handler = createMcpProtocolHandler({
caller: { update: dispatch },
tools: catalog.tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
const argumentsValue = { name: "hello", extra: "preserved" };
const result = await call(handler, "update", argumentsValue);
expect(result.isError).not.toBe(true);
expect(dispatch).toHaveBeenCalledWith(argumentsValue);
expect(transform).toHaveBeenCalledTimes(1);
expect(execution).toHaveBeenCalledWith({ name: "hello!", limit: 7, extra: "preserved" });
await call(handler, "update", { name: "hello", limit: "7" });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(execution).toHaveBeenCalledTimes(1);
});
test("retains overlapping chained constraints, refinements, and empty object defaults", async () => {
const execution = vi.fn((input: unknown) => input);
const router = trpc.router({
bounded: exposed
.input(z.object({ count: z.number().min(2) }))
.input(z.object({ count: z.number().max(5) }))
.mutation(({ input }) => execution(input)),
refined: exposed
.input(z.object({ value: z.string().refine((value) => value !== "blocked") }))
.mutation(({ input }) => execution(input)),
defaults: exposed.input(z.object({ count: z.number().default(3) })).query(({ input }) => input),
none: exposed.query(({ input }) => ({ absent: input === undefined })),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "one" }),
tools: catalog.tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
for (const count of [1, 6]) expect((await call(handler, "bounded", { count })).isError).toBe(true);
expect((await call(handler, "refined", { value: "blocked" })).isError).toBe(true);
expect(execution).not.toHaveBeenCalled();
expect((await call(handler, "bounded", { count: 3 })).isError).not.toBe(true);
expect(execution).toHaveBeenCalledWith({ count: 3 });
expect((await call(handler, "defaults", {})).content).toEqual([{ type: "text", text: '{"count":3}' }]);
expect((await call(handler, "none", {})).content).toEqual([{ type: "text", text: '{"absent":true}' }]);
});
test("accepts app inputs that tRPC normalizes before checking constraints", async () => {
const mutation = vi.fn((input: unknown) => input);
const router = trpc.router({ create: exposed.input(appManageSchema).mutation(({ input }) => mutation(input)) });
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "admin" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
const argumentsValue = {
name: `${" ".repeat(64)}App `,
description: " description ",
iconUrl: " icon ",
href: null,
pingUrl: " https://example.com ",
};
expect((await call(handler, "create", argumentsValue)).isError).not.toBe(true);
expect(mutation).toHaveBeenCalledExactlyOnceWith({
name: "App",
description: "description",
iconUrl: "icon",
href: null,
pingUrl: "https://example.com",
});
expect((await call(handler, "create", { ...argumentsValue, pingUrl: " javascript:alert(1) " })).isError).toBe(true);
expect(mutation).toHaveBeenCalledTimes(1);
});
test("accepts Zod ISO timestamps without seconds while rejecting invalid dates", async () => {
const mutation = vi.fn((input: unknown) => input);
const router = trpc.router({
invite: exposed
.input(z.object({ expirationDate: z.iso.datetime({ offset: true }) }))
.mutation(({ input }) => mutation(input)),
});
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "admin" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
for (const expirationDate of ["2026-12-01T18:00Z", "2026-12-01T19:00+01:00"]) {
expect((await call(handler, "invite", { expirationDate })).isError).not.toBe(true);
expect(mutation).toHaveBeenLastCalledWith({ expirationDate });
}
for (const expirationDate of ["2026-02-30T18:00Z", "not-a-date"]) {
expect((await call(handler, "invite", { expirationDate })).isError).toBe(true);
}
expect(mutation).toHaveBeenCalledTimes(2);
});
test("enforces tRPC permissions before executing mutations", async () => {
const mutation = vi.fn(() => "changed");
const router = trpc.router({
restricted: exposed
.use(({ ctx, next }) => {
if (ctx.user !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
return next();
})
.input(z.object({}))
.mutation(mutation),
});
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "guest" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Forbidden",
});
expect((await call(handler, "restricted", {})).isError).toBe(true);
expect(mutation).not.toHaveBeenCalled();
});
test("isolates authenticated callers when reusing catalog schemas", async () => {
const router = trpc.router({ who: exposed.query(({ ctx }) => ctx.user) });
const { tools } = extractMcpToolsFromProcedures(router);
const handlers = ["alice", "bob"].map((user) =>
createMcpProtocolHandler({
caller: router.createCaller({ user }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Failed",
}),
);
const results = await Promise.all(handlers.map((handler) => call(handler, "who", {})));
expect(results.map((result) => result.content)).toEqual([
[{ type: "text", text: '"alice"' }],
[{ type: "text", text: '"bob"' }],
]);
});
});
+22 -6
View File
@@ -1,10 +1,8 @@
import { createMcpHandler, fromJsonSchema, McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { callMcpTool } from "@homarr/api/mcp";
import type { McpTool } from "@homarr/api/mcp";
import { toAssistantToolOutput } from "../assistant/chat/assistant-tool-output";
import { serializeMcpToolResult } from "./_serialize-result";
interface CreateMcpProtocolHandlerOptions {
caller: unknown;
@@ -17,6 +15,15 @@ interface CreateMcpProtocolHandlerOptions {
onProtocolError?: (error: Error) => void;
}
const resolveProcedure = (caller: unknown, path: string[]) =>
path.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
export const createMcpProtocolHandler = ({
caller,
tools,
@@ -45,16 +52,25 @@ export const createMcpProtocolHandler = ({
tool.name,
{
description: tool.description,
inputSchema: fromJsonSchema(tool.inputSchema as Parameters<typeof fromJsonSchema>[0]),
inputSchema: tool.inputValidator,
},
async (args) => {
try {
const result = await callMcpTool(caller, tool, args);
const procedure = resolveProcedure(caller, tool.pathInRouter);
if (typeof procedure !== "function") {
return {
content: [{ type: "text" as const, text: `Tool "${tool.name}" is not callable` }],
isError: true,
};
}
const input = Object.keys(args).length > 0 ? args : undefined;
const result = await (procedure as (value: unknown) => Promise<unknown>)(input);
return {
content: [
{
type: "text" as const,
text: serializeMcpToolResult(toAssistantToolOutput(result)),
text: JSON.stringify(toAssistantToolOutput(result)),
},
],
};
@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { serializeMcpToolResult } from "./_serialize-result";
describe("serializeMcpToolResult", () => {
it("serializes objects as JSON strings", () => {
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
});
it("serializes primitive values", () => {
expect(serializeMcpToolResult("hello")).toBe('"hello"');
expect(serializeMcpToolResult(42)).toBe("42");
expect(serializeMcpToolResult(true)).toBe("true");
});
it("serializes null as the string null", () => {
expect(serializeMcpToolResult(null)).toBe("null");
});
it("serializes void results as the string null so MCP content text is always a string", () => {
expect(serializeMcpToolResult(undefined)).toBe("null");
});
});
@@ -1,3 +0,0 @@
export function serializeMcpToolResult(result: unknown): string {
return JSON.stringify(result) ?? "null";
}
@@ -11,6 +11,7 @@ import {
CopyButton,
Group,
InputWrapper,
NumberInput,
Select,
SimpleGrid,
Skeleton,
@@ -29,6 +30,7 @@ import {
IconCheck,
IconCopy,
IconFileTypeCss,
IconLayoutGrid,
IconPalette,
IconPhoto,
IconRefresh,
@@ -38,10 +40,17 @@ import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import {
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { normalizeFixedItemSize } from "~/components/board/layout/scaling";
import {
getAssistantBoardSettingsResult,
@@ -85,6 +94,8 @@ const normalizeFormValues = (
iconColor: values.iconColor ?? "",
itemRadius: values.itemRadius,
disableStatus: values.disableStatus,
fixedScaling: values.fixedScaling ?? false,
fixedItemSize: normalizeFixedItemSize(values.fixedItemSize ?? ""),
};
};
@@ -263,6 +274,9 @@ const BoardSettingsForm = ({
<Tabs.Tab value="css" leftSection={<IconFileTypeCss size={15} />}>
{t("tabs.css")}
</Tabs.Tab>
<Tabs.Tab value="layout" leftSection={<IconLayoutGrid size={15} />}>
{t("tabs.layout")}
</Tabs.Tab>
<Tabs.Tab value="appearance" leftSection={<IconPalette size={15} />}>
{t("tabs.appearance")}
</Tabs.Tab>
@@ -329,6 +343,29 @@ const BoardSettingsForm = ({
</Stack>
</Tabs.Panel>
<Tabs.Panel value="layout" pt="md">
<Stack gap="md">
<Switch
label={boardT("board.field.fixedScaling.label")}
description={boardT("board.field.fixedScaling.description")}
{...form.getInputProps("fixedScaling", { type: "checkbox" })}
/>
<NumberInput
label={boardT("board.field.fixedItemSize.label")}
description={boardT("board.field.fixedItemSize.description")}
min={BOARD_FIXED_ITEM_SIZE_MIN}
max={BOARD_FIXED_ITEM_SIZE_MAX}
step={10}
allowDecimal={false}
suffix=" px"
{...form.getInputProps("fixedItemSize")}
onBlur={() =>
form.setFieldValue("fixedItemSize", normalizeFixedItemSize(form.getValues().fixedItemSize))
}
/>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="appearance" pt="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
@@ -16,6 +16,8 @@ export const assistantBoardSettingKeys = [
"iconColor",
"itemRadius",
"disableStatus",
"fixedScaling",
"fixedItemSize",
] as const satisfies readonly (keyof AssistantBoardSettingsChanges)[];
const comparableValue = (value: unknown) => (value === null ? "" : value);
@@ -54,12 +56,14 @@ const backgroundKeys = new Set<keyof AssistantBoardSettingsChanges>([
"backgroundImageRepeat",
"backgroundImageSize",
]);
const layoutKeys = new Set<keyof AssistantBoardSettingsChanges>(["fixedScaling", "fixedItemSize"]);
export const getAssistantBoardSettingsDefaultTab = (
changes: AssistantBoardSettingsChanges,
): "general" | "appearance" | "background" | "css" => {
): "general" | "layout" | "appearance" | "background" | "css" => {
if (changes.customCss !== undefined) return "css";
const keys = Object.keys(changes) as (keyof AssistantBoardSettingsChanges)[];
if (keys.some((key) => layoutKeys.has(key))) return "layout";
if (keys.some((key) => appearanceKeys.has(key))) return "appearance";
if (keys.some((key) => backgroundKeys.has(key))) return "background";
return "general";
@@ -1,6 +1,8 @@
import { z } from "zod/v4";
import {
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
@@ -48,6 +50,17 @@ export const assistantBoardSettingsChangesSchema = z
iconColor: boardColorSchema.nullable().optional(),
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]).optional(),
disableStatus: z.boolean().optional(),
fixedScaling: z
.boolean()
.optional()
.describe("Keep board tiles at a configured size instead of fitting the complete layout to the viewport."),
fixedItemSize: z
.number()
.int()
.min(BOARD_FIXED_ITEM_SIZE_MIN)
.max(BOARD_FIXED_ITEM_SIZE_MAX)
.optional()
.describe("Visible pixel width and height of a 1 by 1 tile when fixedScaling is enabled."),
})
.describe("Only the board fields the user asked to change.");
@@ -0,0 +1,37 @@
"use client";
import { Menu } from "@mantine/core";
import { IconBox, IconLayoutGridAdd, IconPlug, IconResize } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useBoardAddActions } from "./use-board-add-actions";
import { useOptionalBoardEditing } from "./board-editing-context";
export const BoardAddMenuItems = () => {
const editing = useOptionalBoardEditing();
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
const tBoard = useI18n("board");
const tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const run = async (action: () => void) => {
if (await editing?.enter()) action();
};
return (
<>
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => void run(addWidget)}>
{tBoard("emptyState.addWidget")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={() => void run(addApp)}>
{tBoard("emptyState.addApp")}
</Menu.Item>
{canConnectService && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => void run(connectService)}>
{tIntegration("action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={() => void run(addContainer)}>
{tContainer("action.create")}
</Menu.Item>
</>
);
};
@@ -0,0 +1,68 @@
"use client";
import type { PropsWithChildren, MouseEvent } from "react";
import { useEffect, useState } from "react";
import { Box, Menu } from "@mantine/core";
import dynamic from "next/dynamic";
import { useOptionalBoardEditing } from "./board-editing-context";
const BoardAddMenuItems = dynamic(() => import("./board-add-menu-items").then((module) => module.BoardAddMenuItems));
const occupiedSpaceSelector =
'[data-grid-item-id], [data-editor-grid-entry], [data-item-id], button, a, input, textarea, select, [contenteditable], [role="button"], [role="menu"], [role="dialog"]';
export const BoardContextMenu = ({ children }: PropsWithChildren) => {
const editing = useOptionalBoardEditing();
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!position) return;
const close = () => setPosition(null);
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [position]);
const openMenu = (event: MouseEvent<HTMLDivElement>) => {
if (!editing?.hasChangeAccess || editing.isPending || event.defaultPrevented || event.shiftKey) return;
if (!(event.target instanceof Element) || event.target.closest(occupiedSpaceSelector)) return;
if (window.getSelection()?.toString()) return;
event.preventDefault();
event.stopPropagation();
setPosition({ x: event.clientX, y: event.clientY });
};
return (
<Box h="100%" onContextMenu={openMenu}>
{children}
<Menu
opened={position !== null && Boolean(editing?.hasChangeAccess)}
onChange={(opened) => {
if (!opened) setPosition(null);
}}
position="bottom-start"
offset={2}
withinPortal
withArrow={false}
shadow="md"
>
<Menu.Target>
<span
aria-hidden
style={{
position: "fixed",
left: position?.x ?? 0,
top: position?.y ?? 0,
width: 0,
height: 0,
pointerEvents: "none",
}}
/>
</Menu.Target>
<Menu.Dropdown>{position && <BoardAddMenuItems />}</Menu.Dropdown>
</Menu>
</Box>
);
};
@@ -0,0 +1,8 @@
"use client";
import { createContext, useContext } from "react";
import type { BoardEditingControls } from "~/app/[locale]/boards/(content)/_editing-provider";
export const BoardEditingContext = createContext<BoardEditingControls | null>(null);
export const useOptionalBoardEditing = () => useContext(BoardEditingContext);
@@ -0,0 +1,209 @@
"use client";
import { useMemo } from "react";
import { Box } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import type { BoardPreviewData, BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import type { GridAlgorithmItem } from "@homarr/common";
import { getRootSectionLane } from "@homarr/definitions";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { getBoardCanvasGeometry } from "./layout/canvas-geometry";
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./layout/constants";
import { calculateBoardUiScale, getBoardCanvasMetrics, getBoardScaling } from "./layout/scaling";
import { useCanvasViewportSize } from "./layout/use-canvas-viewport-size";
import appearanceClasses from "./layout/canvas-appearance.module.css";
import scrollbarClasses from "./layout/canvas-scrollbar.module.css";
import classes from "./board-layout-thumbnail.module.css";
export interface BoardLayoutThumbnailCanvasProps {
preview: BoardPreviewData;
label: string;
className?: string;
canvas: {
layout: BoardPreviewLayout;
sourceLayout: BoardPreviewLayout;
settings: {
fixedScaling: boolean;
fixedItemSize: number;
opacity: number;
itemRadius: string;
iconColor: string | null;
backgroundImageUrl: string | null;
backgroundImageSize: string;
backgroundImageRepeat: string;
backgroundImageAttachment: string;
};
};
}
/** The Boards thumbnail with exact canvas geometry for the settings draft. */
export const BoardLayoutThumbnailCanvas = ({ preview, label, canvas, className }: BoardLayoutThumbnailCanvasProps) => {
const { layout, sourceLayout, settings } = canvas;
const { ref: viewportRef, width, height } = useCanvasViewportSize();
const screenWidth = Math.max(320, getRepresentativeLayoutWidth(layout, preview.layouts));
const miniatureScale = width / screenWidth;
const geometry = getBoardCanvasGeometry(layout);
const metrics = getBoardCanvasMetrics(getBoardScaling(settings), screenWidth - 32, geometry.width);
const visualUiScale = metrics.scale * calculateBoardUiScale(metrics.scale);
const elements = useMemo(() => projectBoardLayout(preview, sourceLayout, layout), [preview, sourceLayout, layout]);
const roots = preview.sections.filter((section) => section.kind === "empty");
const rootIds = new Set(roots.map((section) => section.id));
const rootElements = elements.filter((element) => rootIds.has(element.sectionId));
const rowCount = Math.max(2, ...rootElements.map((element) => element.yOffset + element.height));
const pitch = LOGICAL_GRID_PITCH * metrics.scale;
const canvasOffset = Math.max(0, (screenWidth - 32 - metrics.width) / 2);
const contentWidth = Math.max(screenWidth, metrics.width + 32);
const canvasTop = 16;
const contentHeight = rowCount * pitch + canvasTop + 16;
const itemsById = new Map(preview.items.map((item) => [item.id, item]));
const stationaryBackground = settings.fixedScaling || settings.backgroundImageAttachment === "fixed";
const backgroundStyle = {
backgroundImage: settings.backgroundImageUrl ? `url(${JSON.stringify(settings.backgroundImageUrl)})` : undefined,
backgroundSize: settings.backgroundImageSize,
backgroundRepeat: settings.backgroundImageRepeat,
backgroundPosition: "center center",
};
const renderIcon = (id: string) => {
const item = itemsById.get(id);
const WidgetIcon = item?.kind ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
if (item?.iconUrl) {
return (
<MaskedOrNormalImage
imageUrl={item.iconUrl}
hasColor={Boolean(settings.iconColor)}
color={settings.iconColor || undefined}
alt=""
className={classes.scaledItemIcon}
/>
);
}
return <WidgetIcon className={classes.scaledItemIcon} stroke={1.7} />;
};
// Keep the familiar nested-container representation without mounting live widgets.
const renderContents = (element: GridAlgorithmItem) => {
if (element.type === "item") return renderIcon(element.id);
const nestedItems = elements.filter((candidate) => candidate.sectionId === element.id);
const columns = Math.max(element.width, ...nestedItems.map((item) => item.xOffset + item.width));
const rows = Math.max(element.height, ...nestedItems.map((item) => item.yOffset + item.height));
return (
<span
className={classes.containerContents}
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
}}
>
{nestedItems.map((item) => (
<span
key={item.id}
className={classes.tile}
style={{
gridColumn: `${item.xOffset + 1} / span ${item.width}`,
gridRow: `${item.yOffset + 1} / span ${item.height}`,
}}
>
{renderIcon(item.id)}
</span>
))}
</span>
);
};
return (
<Box className={[classes.canvas, classes.exactCanvas, className].filter(Boolean).join(" ")}>
{stationaryBackground && (
<Box
aria-hidden
className={classes.scaledSurface}
style={{
...backgroundStyle,
width: screenWidth,
height: height / (miniatureScale || 1),
transform: `scale(${miniatureScale})`,
pointerEvents: "none",
}}
/>
)}
<Box
component="section"
ref={viewportRef}
aria-label={label}
tabIndex={0}
className={`${classes.thumbnailViewport} ${scrollbarClasses.scrollbar}`}
data-testid="layout-preview-viewport"
data-preview-scale={miniatureScale}
data-item-size={metrics.itemSize}
>
<Box
style={{
width: contentWidth * miniatureScale,
height: contentHeight * miniatureScale,
minHeight: "100%",
overflow: "clip",
position: "relative",
}}
>
<Box
aria-hidden
className={`${classes.scaledSurface} ${appearanceClasses.appearance}`}
style={{
width: contentWidth,
height: Math.max(contentHeight, (screenWidth * 7) / 16),
transform: `scale(${miniatureScale})`,
"--board-canvas-ui-scale": visualUiScale,
"--preview-opacity": settings.opacity / 100,
"--preview-radius": `var(--mantine-radius-${settings.itemRadius})`,
color: settings.iconColor || undefined,
...(!stationaryBackground && backgroundStyle),
}}
>
{geometry.lanes.map((lane) => {
const root = roots.find((section) => getRootSectionLane(section.xOffset) === lane.lane);
const laneElements = rootElements.filter((element) => element.sectionId === root?.id);
return (
<Box
key={lane.lane}
data-preview-lane={lane.lane}
className={classes.scaledLane}
style={{
left: 16 + canvasOffset + lane.offset * metrics.scale,
top: canvasTop,
width: lane.width * metrics.scale,
height: rowCount * pitch,
}}
>
{laneElements.map((element) => {
let inset = BOARD_GRID_ITEM_INSET;
if (element.type === "section" && !settings.fixedScaling) inset = 5 * visualUiScale;
return (
<Box
key={`${element.type}-${element.id}`}
className={`${classes.tile} ${classes.scaledTile}`}
data-preview-tile
data-type={element.type}
style={{
left: element.xOffset * pitch + inset,
top: element.yOffset * pitch + inset,
width: Math.max(0, element.width * pitch - 2 * inset),
height: Math.max(0, element.height * pitch - 2 * inset),
}}
>
{renderContents(element)}
</Box>
);
})}
</Box>
);
})}
</Box>
</Box>
</Box>
</Box>
);
};
@@ -5,11 +5,7 @@
overflow: hidden;
color: var(--mantine-color-dimmed);
background:
linear-gradient(
to top,
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
transparent 55%
),
linear-gradient(to top, color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent), transparent 55%),
var(--mantine-color-body);
}
@@ -84,3 +80,60 @@
gap: rem(1px);
overflow: hidden;
}
.exactCanvas {
position: relative;
max-height: min(18rem, 38dvh);
overflow: hidden;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
}
.thumbnailViewport {
position: absolute;
inset: 0;
overflow: auto;
overscroll-behavior: contain;
}
.thumbnailViewport:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
.scaledSurface {
position: absolute;
inset: 0 auto auto 0;
transform-origin: top left;
}
.scaledLane,
.scaledTile {
position: absolute;
}
.scaledSurface .tile {
border-radius: var(--preview-radius);
background: rgb(
from light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)) r g b / var(--preview-opacity, 1)
);
border-color: rgb(
from light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)) r g b / var(--preview-opacity, 1)
);
}
.scaledTile[data-type="section"] {
border-style: dashed;
}
.scaledItemIcon {
position: absolute;
top: 50%;
left: 50%;
width: calc(32px * var(--board-canvas-ui-scale, 1));
height: calc(32px * var(--board-canvas-ui-scale, 1));
max-width: 55%;
max-height: 55%;
object-fit: contain;
transform: translate(-50%, -50%);
}
@@ -6,6 +6,8 @@ import { projectBoardLayout } from "@homarr/boards/layout-preview";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { BoardLayoutThumbnailCanvas } from "./board-layout-thumbnail-canvas";
import type { BoardLayoutThumbnailCanvasProps } from "./board-layout-thumbnail-canvas";
import classes from "./board-layout-thumbnail.module.css";
interface BoardLayoutThumbnailProps {
@@ -13,6 +15,7 @@ interface BoardLayoutThumbnailProps {
label: string;
previewRowLimit?: number;
className?: string;
canvas?: BoardLayoutThumbnailCanvasProps["canvas"];
}
const compactBoardLayoutThumbnailRows = 12;
@@ -23,7 +26,11 @@ export const BoardLayoutThumbnail = ({
label,
previewRowLimit = compactBoardLayoutThumbnailRows,
className,
canvas,
}: BoardLayoutThumbnailProps) => {
if (preview && canvas) {
return <BoardLayoutThumbnailCanvas preview={preview} label={label} canvas={canvas} className={className} />;
}
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) {
@@ -1,4 +1,5 @@
import { createId } from "@homarr/common";
import { BOARD_FIXED_ITEM_SIZE_DEFAULT } from "@homarr/definitions";
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
import { ContainerSectionMockBuilder } from "./container-section-mock";
@@ -27,6 +28,8 @@ export class BoardMockBuilder {
name: "board",
opacity: 100,
isPublic: true,
fixedScaling: false,
fixedItemSize: BOARD_FIXED_ITEM_SIZE_DEFAULT,
disableStatus: false,
customCss: "",
creatorId: createId(),
@@ -5,9 +5,10 @@ import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } f
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
@@ -39,7 +40,6 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
const tItem = useI18n("item");
const t = useI18n();
const { openModal } = useModalAction(LazyWidgetEditModal);
const { openConfirmModal } = useConfirmModal();
const openMoveModal = useOpenItemMoveModal();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
@@ -143,21 +143,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
<InlineConfirmMenuItem
color="red"
confirmLabel={tItem("remove.message")}
leftSection={<IconTrash size={16} />}
onClick={() => {
setIsMenuOpen(false);
openConfirmModal({
title: tItem("remove.message"),
children: label,
labels: { confirm: tItem("action.remove") },
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
onConfirm={() => removeItem({ itemId: item.id })}
>
{tItem("action.remove")}
</Menu.Item>
</InlineConfirmMenuItem>
</Menu.Dropdown>
</Menu>
);
@@ -244,13 +244,7 @@ export const WidgetContextMenu = ({
setMenuOpened(false);
}, []);
if (
!session ||
!settings.enableRightClickOnWidgets ||
isEditMode ||
item.kind === "app" ||
item.kind === "bookmarks"
) {
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>;
}
@@ -18,6 +18,11 @@
transform: translateY(calc(-100% - 8px));
}
/* Keep first-row labels inside the scrollport without adding space above the board. */
:global([data-canvas-scaling="fixed"] [data-grid-y="0"] > .board-grid-content-mount) > .wrapper {
transform: translateY(8px);
}
.panel {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
@@ -5,13 +5,33 @@ The board uses fixed logical units:
- one grid track has a `212 × 212` logical-pixel footprint;
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter;
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset);
- the canvas is zoomed once with one uniform scale derived from its available width.
- the canvas is zoomed once with one uniform scale.
Grid footprints keep the same logical dimensions at every viewport. Widget cards
use a scale-compensated inset so their painted size and content measurements
match the legacy responsive grid. The visual canvas always fits the available
width, every root lane keeps at least one viewport of height, and edit-mode
previews extend it downward as needed.
match the legacy responsive grid. Responsive scaling derives the zoom from the
available width. Fixed scaling derives it from the configured painted `1 x 1`
card size and keeps the canvas centered while it fits; otherwise the board's
named canvas region scrolls on both axes. Every root lane keeps at least one
viewport of height, and edit-mode previews extend it downward as needed.
## Sizing policy and previews
`scaling.ts` owns the explicit `BoardScaling` union and pure canvas metrics.
`getBoardScaling` is the adapter from the persisted boolean/size settings. Both
the live renderer and settings thumbnail consume the same metrics; neither
should add viewport fitting outside this module. Fixed sizing uses CSS pixels
and never compensates for browser zoom or device-pixel ratio.
`canvas-geometry.ts` owns lane offsets, widths, and gaps for both renderers.
The settings thumbnail applies a separate miniature transform after calculating
board geometry at a representative layout width. That display transform must never feed
back into the board sizing policy.
To retire responsive sizing, migrate the persisted default/settings, remove
the responsive policy branch and its UI option, then remove the responsive
viewport styles. Placement geometry and the fixed renderer do not need a
second conversion. Nested Container fitting is a separate layout concern.
The read-only renderer should:
@@ -0,0 +1,39 @@
.appearance {
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
* zoom and enlarges everything together.
*/
--mantine-scale: var(--board-canvas-ui-scale, 1);
/* Keep a subtle boundary inset without widening the visual gap too much. */
--board-grid-item-inset: calc(var(--mantine-spacing-xs) / 2);
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1));
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
* above, just never ported over to radius. */
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
}
@@ -0,0 +1,22 @@
import { boardLanes, getBoardLaneColumnCount } from "@homarr/definitions";
import { getLogicalGridSize } from "./geometry";
export const BOARD_LANE_GAP = 24;
/** Shared horizontal geometry for the live board and its settings playground. */
export const getBoardCanvasGeometry = (layout: Parameters<typeof getBoardLaneColumnCount>[0]) => {
let width = 0;
const lanes = boardLanes.flatMap((lane) => {
const columnCount = getBoardLaneColumnCount(layout, lane);
if (columnCount === 0) return [];
if (width > 0) width += BOARD_LANE_GAP;
const offset = width;
const laneWidth = getLogicalGridSize(columnCount);
width += laneWidth;
return [{ lane, columnCount, width: laneWidth, offset }];
});
return { lanes, width, gridTemplateColumns: lanes.map((lane) => `${lane.width}px`).join(" ") };
};
@@ -0,0 +1,35 @@
.scrollbar::-webkit-scrollbar {
width: 2px;
height: 2px;
}
.scrollbar::-webkit-scrollbar-track,
.scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
.scrollbar::-webkit-scrollbar-thumb {
border-radius: 999px;
background: transparent;
}
.scrollbar:hover::-webkit-scrollbar-thumb,
.scrollbar:focus-visible::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent);
}
.scrollbar::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--mantine-color-dimmed) 70%, transparent);
}
@supports (-moz-appearance: none) {
.scrollbar {
scrollbar-color: transparent transparent;
scrollbar-width: thin;
}
.scrollbar:hover,
.scrollbar:focus-visible {
scrollbar-color: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent) transparent;
}
}
@@ -5,5 +5,7 @@
export const LOGICAL_GRID_CELL_SIZE = 200;
export const LOGICAL_GRID_GAP = 12;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
/** Physical inset between a grid footprint and each edge of its painted tile. */
export const BOARD_GRID_ITEM_INSET = 10;
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
@@ -10,6 +10,36 @@
overflow: clip;
}
.viewport[data-canvas-scaling="fixed"] {
--board-canvas-shell-offset: calc(var(--app-shell-header-offset, 0px) + var(--app-shell-footer-offset, 0px));
--board-canvas-shell-padding: calc(2 * var(--app-shell-padding, 1rem));
--board-canvas-fixed-viewport-height: calc(
100dvh - var(--board-canvas-shell-offset) - var(--board-canvas-shell-padding)
);
height: var(--board-canvas-fixed-viewport-height);
overflow: auto;
overscroll-behavior: contain;
}
.viewport[data-canvas-scaling="fixed"]:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
.viewport[data-canvas-scaling="fixed"][data-canvas-centered="true"] .sizer {
margin-inline: auto;
}
.viewport[data-canvas-scaling="fixed"] .canvas {
/* Fixed size describes every painted tile, including container sections. */
--board-grid-item-inset: calc(var(--board-grid-card-inset, 10px) * var(--board-canvas-inverse-scale, 1));
}
.viewport[data-canvas-scaling="fixed"][data-board-hydrated="false"] {
overflow: clip;
}
.sizer {
position: relative;
}
@@ -17,42 +47,4 @@
.canvas {
position: absolute;
inset: 0 auto auto 0;
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
* zoom and enlarges everything together.
*/
--mantine-scale: var(--board-canvas-ui-scale, 1);
/* Keep a subtle boundary inset without widening the visual gap too much. */
--board-grid-item-inset: calc(var(--mantine-spacing-xs) / 2);
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1));
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
* above, just never ported over to radius. */
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
}
@@ -1,49 +1,46 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { CSSProperties, PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { Box } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { BOARD_GRID_ITEM_INSET } from "./constants";
import type { BoardScaling } from "./scaling";
import { calculateBoardUiScale, getBoardCanvasMetrics } from "./scaling";
import { useCanvasViewportSize } from "./use-canvas-viewport-size";
import classes from "./scaled-board-canvas.module.css";
import appearanceClasses from "./canvas-appearance.module.css";
import scrollbarClasses from "./canvas-scrollbar.module.css";
const BoardCanvasScaleContext = createContext(1);
const BoardCanvasViewportHeightContext = createContext<number | null>(null);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
return availableWidth / logicalWidth;
};
export const calculateBoardUiScale = (canvasScale: number) => {
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
return canvasScale < 1 ? 1 / canvasScale : 1;
};
export const useBoardCanvasViewportHeight = () => useContext(BoardCanvasViewportHeightContext);
interface ScaledBoardCanvasProps {
logicalWidth: number;
initialLogicalHeight: number;
initialAvailableWidth: number;
scaling: BoardScaling;
label: string;
}
/**
* Scales the complete board as one surface while keeping widget layout in
* fixed logical pixels. The complete canvas always fits its viewport so board
* content can never widen the document or require horizontal scrolling.
* fixed logical pixels. Responsive scaling fits the canvas to its viewport;
* fixed scaling contains overflow in a keyboard-scrollable canvas region.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
initialLogicalHeight,
initialAvailableWidth,
scaling,
label,
children,
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
const { ref: viewportRef, width: availableWidth, height: viewportHeight } = useCanvasViewportSize();
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
const [isHydrated, setIsHydrated] = useState(false);
@@ -53,40 +50,58 @@ export const ScaledBoardCanvas = ({
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
const scale = useMemo(
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
[logicalWidth, resolvedAvailableWidth],
);
const isFixedScaling = scaling.mode === "fixed";
const {
scale,
width: visualWidth,
centered,
overflows,
} = getBoardCanvasMetrics(scaling, resolvedAvailableWidth, logicalWidth);
const uiScale = calculateBoardUiScale(scale);
const inverseScale = scale > 0 ? 1 / scale : 1;
const visualWidth = logicalWidth * scale;
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
return (
<Box
component="section"
ref={viewportRef}
className={classes.viewport}
className={`${classes.viewport} ${isFixedScaling ? scrollbarClasses.scrollbar : ""}`}
data-testid="board-canvas"
data-board-hydrated={isHydrated ? "true" : "false"}
data-canvas-scaling={scaling.mode}
data-canvas-scale={scale}
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
data-canvas-overflow={overflows ? "true" : "false"}
data-canvas-centered={centered ? "true" : undefined}
data-canvas-initial-height={initialLogicalHeight}
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
tabIndex={isFixedScaling ? 0 : undefined}
>
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
<Box
className={classes.sizer}
style={
{
"--board-canvas-scale": scale,
width: visualWidth,
height: resolvedLogicalHeight * scale,
} as CSSProperties
}
>
<Box
ref={canvasRef}
className={classes.canvas}
className={`${classes.canvas} ${appearanceClasses.appearance}`}
style={{
"--board-grid-card-inset": `${BOARD_GRID_ITEM_INSET}px`,
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
width: logicalWidth,
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
<BoardCanvasScaleContext.Provider value={scale}>
<BoardCanvasViewportHeightContext.Provider value={isFixedScaling ? viewportHeight : null}>
{children}
</BoardCanvasViewportHeightContext.Provider>
</BoardCanvasScaleContext.Provider>
</Box>
</Box>
</Box>
@@ -0,0 +1,55 @@
import {
BOARD_FIXED_ITEM_SIZE_DEFAULT,
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
} from "@homarr/definitions";
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./constants";
export type BoardScaling = { mode: "fixed"; itemSize: number } | { mode: "responsive" };
export const normalizeFixedItemSize = (value: number | string) => {
if (value === "" || !Number.isFinite(Number(value))) return BOARD_FIXED_ITEM_SIZE_DEFAULT;
return Math.min(BOARD_FIXED_ITEM_SIZE_MAX, Math.max(BOARD_FIXED_ITEM_SIZE_MIN, Math.round(Number(value))));
};
/** The only adapter from persisted settings to the canvas sizing policy. */
export const getBoardScaling = (settings: { fixedScaling: boolean; fixedItemSize: number }): BoardScaling => {
if (settings.fixedScaling) return { mode: "fixed", itemSize: normalizeFixedItemSize(settings.fixedItemSize) };
return { mode: "responsive" };
};
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
return availableWidth / logicalWidth;
};
export const calculateFixedBoardCanvasScale = (itemSize: number) =>
(normalizeFixedItemSize(itemSize) + 2 * BOARD_GRID_ITEM_INSET) / LOGICAL_GRID_PITCH;
/** Fixed sizing never reads viewport width or devicePixelRatio: browser zoom stays native. */
export const getBoardCanvasMetrics = (scaling: BoardScaling, availableWidth: number, logicalWidth: number) => {
let scale: number;
if (scaling.mode === "fixed") scale = calculateFixedBoardCanvasScale(scaling.itemSize);
else scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
const width = logicalWidth * scale;
return {
scale,
width,
itemSize: Math.max(0, LOGICAL_GRID_PITCH * scale - 2 * BOARD_GRID_ITEM_INSET),
centered: scaling.mode === "fixed" && width <= availableWidth,
overflows: availableWidth > 0 && width - availableWidth > 0.5,
};
};
export const calculateBoardUiScale = (canvasScale: number) => {
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
if (canvasScale < 1) return 1 / canvasScale;
return 1;
};
@@ -1,8 +1,65 @@
import { describe, expect, test } from "vitest";
import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas";
import { BOARD_FIXED_ITEM_SIZE_DEFAULT } from "@homarr/definitions";
import { getBoardCanvasGeometry } from "../canvas-geometry";
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "../constants";
import {
calculateBoardCanvasScale,
calculateBoardUiScale,
calculateFixedBoardCanvasScale,
getBoardCanvasMetrics,
getBoardScaling,
normalizeFixedItemSize,
} from "../scaling";
describe("board canvas scale policy", () => {
test.each([40, 80, 100, 200])("renders an exact %s CSS-pixel fixed tile", (itemSize) => {
const scale = calculateFixedBoardCanvasScale(itemSize);
expect(LOGICAL_GRID_PITCH * scale - 2 * BOARD_GRID_ITEM_INSET).toBeCloseTo(itemSize);
});
test.each([Number.NaN, Number.POSITIVE_INFINITY, ""])(
"uses the default fixed size for unavailable input %s",
(input) => {
expect(normalizeFixedItemSize(input)).toBe(BOARD_FIXED_ITEM_SIZE_DEFAULT);
},
);
test("clamps and rounds the configured fixed size", () => {
expect(normalizeFixedItemSize(20)).toBe(40);
expect(normalizeFixedItemSize(400)).toBe(200);
expect(normalizeFixedItemSize(100.7)).toBe(101);
});
test.each([0.75, 1, 1.25, 1.5, 2])("preserves fixed CSS dimensions at browser zoom %s", (zoom) => {
const scaling = getBoardScaling({ fixedScaling: true, fixedItemSize: 200 });
const metrics = getBoardCanvasMetrics(scaling, 1440 / zoom, 5 * LOGICAL_GRID_PITCH);
expect(metrics.itemSize).toBeCloseTo(200);
expect(metrics.width).toBeCloseTo(1100);
});
test("centers fixed boards that fit and scrolls without changing tile size when narrow", () => {
const scaling = getBoardScaling({ fixedScaling: true, fixedItemSize: 200 });
expect(getBoardCanvasMetrics(scaling, 2560, 1060)).toMatchObject({ centered: true, overflows: false });
expect(getBoardCanvasMetrics(scaling, 600, 1060)).toMatchObject({ centered: false, overflows: true });
});
test("keeps the sizing mode adapter reversible", () => {
expect(getBoardScaling({ fixedScaling: false, fixedItemSize: 200 })).toEqual({ mode: "responsive" });
expect(getBoardCanvasMetrics({ mode: "responsive" }, 600, 1060).width).toBeCloseTo(600);
});
test("aligns left, main, and right lanes using the same canvas geometry", () => {
const geometry = getBoardCanvasGeometry({ columnCount: 5, leftGutterColumnCount: 1, rightGutterColumnCount: 1 });
expect(geometry.lanes.map(({ lane, offset, width }) => ({ lane, offset, width }))).toEqual([
{ lane: "left", offset: 0, width: 212 },
{ lane: "main", offset: 236, width: 636 },
{ lane: "right", offset: 896, width: 212 },
]);
expect(geometry.width).toBe(1108);
});
test("fits the logical canvas exactly", () => {
const scale = calculateBoardCanvasScale(840, 1000);
@@ -0,0 +1,16 @@
"use client";
import { useCallback, useState } from "react";
/** Canvas fitting uses the scrollport, not the border box including native scrollbars. */
export const useCanvasViewportSize = () => {
const [size, setSize] = useState({ width: 0, height: 0 });
const ref = useCallback((node: HTMLElement | null) => {
if (!node) return;
const observer = new ResizeObserver(() => setSize({ width: node.clientWidth, height: node.clientHeight }));
observer.observe(node);
return () => observer.disconnect();
}, []);
return { ref, ...size };
};
@@ -11,7 +11,6 @@ import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { COLLAPSED_SECTION_ROW_COUNT } from "~/components/board/layout";
import { SectionGrid } from "./grid/section-grid";
import { useSectionCollapse } from "./section-collapse";
import { useSectionContext } from "./section-context";
import { useOpenSectionApps } from "./use-open-section-apps";
import classes from "./item.module.css";
@@ -26,7 +25,6 @@ interface Props {
export const BoardContainerSection = ({ section }: Props) => {
const board = useRequiredBoard();
const { section: parentSection } = useSectionContext();
const [isEditMode] = useEditMode();
const t = useI18n("section.container");
const tSection = useI18n("section");
@@ -41,26 +39,8 @@ export const BoardContainerSection = ({ section }: Props) => {
});
const label = options.title.trim() || t("untitled");
const contentId = `board-container-${section.id}-content`;
let labelLeft = 8;
let labelRight = 8;
if (isEditMode) {
// Match the root/nested menu offsets in BoardContainerMenu, plus its width and gap.
labelLeft = 36;
if (parentSection.kind === "container") labelLeft = 68;
} else if (options.showOpenAll) {
labelRight = 40;
}
// Expanded controls sit on the border like the ordinary label, without reserving a header row.
const toggleLayout = isVisuallyCollapsed
? { top: 0, left: 0, w: "100%", h: "100%", maw: "100%" }
: {
top: "calc(var(--mantine-spacing-xs) * -1)",
left: labelLeft,
w: "auto",
h: 20,
maw: `calc(100% - ${labelLeft + labelRight}px)`,
};
const toggleIcon = isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />;
const labelLeft = 8;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
return (
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
@@ -88,14 +68,24 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
{...toggleLayout}
px={6}
ps={isVisuallyCollapsed ? labelLeft : 6}
pe={isVisuallyCollapsed ? labelRight : 6}
top={0}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
px={12}
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
radius="sm"
variant="default"
justify={options.showLabel ? "flex-start" : "center"}
leftSection={options.showLabel && toggleIcon}
leftSection={
options.showLabel ? (
isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)
) : undefined
}
onClick={toggle}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
@@ -104,7 +94,13 @@ export const BoardContainerSection = ({ section }: Props) => {
data-board-container-label
title={options.showLabel ? label : undefined}
>
{options.showLabel ? label : toggleIcon}
{options.showLabel ? (
label
) : isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)}
</Button>
)}
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
@@ -1,11 +1,9 @@
.board-grid-editor {
position: absolute;
inset: 0 0 auto;
margin: 0 auto;
inset: 0 auto auto 0;
overflow: visible;
border-radius: var(--mantine-radius-md);
pointer-events: none;
zoom: var(--board-grid-content-scale, 1);
}
.board-grid-entry {
@@ -22,7 +20,14 @@
width: var(--board-grid-preview-width) !important;
height: var(--board-grid-preview-height) !important;
translate: var(--board-grid-preview-x) var(--board-grid-preview-y);
transition: none;
will-change: translate;
}
.board-grid-entry[data-grid-preview="true"]:not([data-dnd-active="true"]) {
transition:
translate 100ms ease,
width 100ms ease,
height 100ms ease;
}
.board-grid-entry[data-dnd-active="true"],
@@ -104,8 +109,8 @@
zoom: var(--board-grid-overlay-content-zoom, 1);
}
/* Displaced entries must snap with the preview too; animating their offsets can trail a new swap target. */
body[data-board-grid-interacting] .board-grid-entry,
body[data-board-grid-interacting] .board-grid-entry[data-dnd-active="true"],
body[data-board-grid-interacting] .board-grid-entry[data-dnd-drag-source="true"],
body[data-board-grid-interacting] .board-grid-placeholder {
transition: none;
}
@@ -133,21 +138,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent);
}
.board-grid-placeholder[data-grid-placeholder-type="section"] {
clip-path: none;
background: none;
border: 0;
}
.board-grid-placeholder[data-grid-placeholder-type="section"]::after {
position: absolute;
inset: var(--board-container-inset, 0px);
content: "";
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
border: 2px dashed var(--mantine-primary-color-filled);
border-radius: inherit;
}
.board-grid-resize-outline {
z-index: 1002;
clip-path: inset(var(--board-grid-item-inset, 0px));
@@ -415,11 +415,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
sectionId: grid.id,
placements: grid.placements,
}));
const hasChanges = commitSectionGrids(snapshots);
if (!hasChanges) {
clearInteraction();
return;
}
commitSectionGrids(snapshots);
const placement = state.grids
.flatMap((grid) => grid.placements)
@@ -433,9 +429,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
)} ${placement.h}`,
);
}
// Cache subscribers receive the committed board asynchronously. Keep the preview until
// the board-change layout effect clears it, so displaced items never flash at their old positions.
activeRef.current = null;
clearInteraction();
},
[announce, clearInteraction, commitSectionGrids, currentLayoutId, t],
);
@@ -136,7 +136,6 @@ export const GridPreviewLayer = ({
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-placeholder-type={targetPlacement.type}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
Loaded 100 of 321 files, more files were not shown because too many files have changed in this diff. Show more