Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67774de3dd | ||
|
|
d875fb9a75 | ||
|
|
b742840e96 | ||
|
|
da57a937ed | ||
|
|
2153069a2f | ||
|
|
ec295fe26e | ||
|
|
e56112cfd7 | ||
|
|
449adc5b12 | ||
|
|
240397e7ac | ||
|
|
3efb719eee | ||
|
|
382cffc015 | ||
|
|
0073dfe044 | ||
|
|
a22fb60647 | ||
|
|
89e4cb843d | ||
|
|
65a96b6b14 | ||
|
|
357355283c | ||
|
|
a2a408410a | ||
|
|
0d9eeb5752 | ||
|
|
bf0878c68b | ||
|
|
eb6d337dfb | ||
|
|
978b3c7f33 | ||
|
|
07547320f1 | ||
|
|
02870fc392 | ||
|
|
b6fc27e44d | ||
|
|
d5cf1b82f2 | ||
|
|
e022c3f735 | ||
|
|
14cc3e8a44 | ||
|
|
93d160aebd | ||
|
|
493e93ea42 | ||
|
|
85c20702cd | ||
|
|
91b248d395 | ||
|
|
b7eadc7867 | ||
|
|
1861d8415c | ||
|
|
7fe5f8f81c | ||
|
|
b3a30e0cf6 | ||
|
|
763e1324e8 | ||
|
|
5188ed89d6 | ||
|
|
9eafd4f7a7 | ||
|
|
71ffa5d1be | ||
|
|
1b843817a4 | ||
|
|
8ae9630b3e | ||
|
|
63f5c43b69 | ||
|
|
e97fe7861d | ||
|
|
19760e5722 | ||
|
|
2570de25a6 | ||
|
|
f46ccadbe4 | ||
|
|
6297ca4b16 | ||
|
|
2accdc2fad | ||
|
|
8a08293016 | ||
|
|
7591f7e313 | ||
|
|
011ce8e5a3 | ||
|
|
b2fa46c760 | ||
|
|
9e14a07e64 | ||
|
|
81aa704537 | ||
|
|
f0f9539466 | ||
|
|
3c2f95d529 | ||
|
|
64e3d04517 | ||
|
|
002381d294 | ||
|
|
9b406d5d83 | ||
|
|
4c475b8c0a | ||
|
|
f3014fe271 | ||
|
|
bd4f6a92c6 | ||
|
|
7e8c139590 | ||
|
|
d241bac55d | ||
|
|
58a24569ce | ||
|
|
53b518960c | ||
|
|
ddf8ba9948 | ||
|
|
a2219b72f6 | ||
|
|
7150cdbbab | ||
|
|
ff14613f95 | ||
|
|
78b3dc1922 | ||
|
|
2e97b337d2 | ||
|
|
369a2660b1 | ||
|
|
60aec370d6 | ||
|
|
6a76b9eafe | ||
|
|
76ba13fbad | ||
|
|
150d38be62 | ||
|
|
a4bbed0cb8 | ||
|
|
90fa9e8741 | ||
|
|
4b20d6ecd0 | ||
|
|
6cac34e968 | ||
|
|
c6f77cec2e | ||
|
|
acc97da84b | ||
|
|
6b10ac2ab5 | ||
|
|
c7563f2c39 | ||
|
|
96676182f4 | ||
|
|
3769d88ebb | ||
|
|
d9c35744f6 | ||
|
|
33991f4e22 | ||
|
|
df300919fc | ||
|
|
315245e048 | ||
|
|
ebdfeb724b | ||
|
|
e842177d9b | ||
|
|
e3a713d2df | ||
|
|
e7879ed3a2 | ||
|
|
19e72fc757 | ||
|
|
38fc5dd582 | ||
|
|
6d284adb98 | ||
|
|
bd171bf73b | ||
|
|
1ee519bb7a | ||
|
|
c53852fdb6 | ||
|
|
206905ea2d | ||
|
|
50537d077c | ||
|
|
229a559aff | ||
|
|
4e514ab151 | ||
|
|
960551f573 | ||
|
|
304ea089a2 | ||
|
|
199f6ffc3b | ||
|
|
7e569bff4c | ||
|
|
0ec5f36050 |
No files matched your search
+82
-41
@@ -8,7 +8,7 @@ on:
|
||||
types: [closed]
|
||||
branches: ["**"]
|
||||
push:
|
||||
branches: [main, dev, beta, next, v2]
|
||||
branches: [main, dev, beta, next, v2, release/v2]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -105,18 +105,20 @@ jobs:
|
||||
|
||||
container:
|
||||
name: Container and E2E
|
||||
needs: fast-gate
|
||||
needs: [fast-gate, preview-amd64]
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
!cancelled() && needs.fast-gate.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.preview-build.outputs.digest }}
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
@@ -131,8 +133,18 @@ jobs:
|
||||
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: "!env.ACT"
|
||||
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -147,8 +159,8 @@ jobs:
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
|
||||
cache-from: type=gha,scope=homarr-linux-amd64
|
||||
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
||||
cache-from: type=gha,scope=homarr-e2e
|
||||
cache-to: type=gha,scope=homarr-e2e,mode=max
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
@@ -189,7 +201,10 @@ jobs:
|
||||
run: pnpm exec playwright install-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pnpm test:e2e
|
||||
env:
|
||||
NODE_ENV: development
|
||||
CI: "true"
|
||||
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
|
||||
|
||||
- name: Upload E2E artifacts
|
||||
if: failure() && !env.ACT
|
||||
@@ -199,27 +214,44 @@ jobs:
|
||||
path: e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Log in to GHCR for preview publication
|
||||
if: |
|
||||
!env.ACT &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
preview-amd64:
|
||||
name: Build amd64 preview image
|
||||
needs: fast-gate
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.preview-build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: "!env.ACT"
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push amd64 preview image by digest
|
||||
- name: Set up Docker Buildx
|
||||
if: "!env.ACT"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push amd64 preview image by digest
|
||||
id: preview-build
|
||||
if: |
|
||||
!env.ACT &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
if: "!env.ACT"
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -227,22 +259,20 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
outputs: type=image,"name=${{ env.PREVIEW_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=homarr-linux-amd64
|
||||
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Show Act publication result
|
||||
if: env.ACT
|
||||
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
|
||||
|
||||
preview-arm64:
|
||||
name: Build arm64 preview image
|
||||
needs: fast-gate
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -290,10 +320,11 @@ jobs:
|
||||
|
||||
publish-preview:
|
||||
name: Publish multi-platform preview image
|
||||
needs: [container, preview-arm64]
|
||||
needs: [preview-amd64, preview-arm64]
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -303,7 +334,7 @@ jobs:
|
||||
packages: write
|
||||
pull-requests: read
|
||||
env:
|
||||
PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
PREVIEW_TAG: ${{ github.event_name == 'push' && 'v2' || format('pr-{0}', github.event.pull_request.number || inputs.pr_number) }}
|
||||
steps:
|
||||
- name: Log in to GHCR
|
||||
if: "!env.ACT"
|
||||
@@ -340,24 +371,34 @@ jobs:
|
||||
- name: Publish preview manifest
|
||||
if: "!env.ACT"
|
||||
env:
|
||||
AMD64_DIGEST: ${{ needs.container.outputs.digest }}
|
||||
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||
run: |
|
||||
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
|
||||
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
||||
|
||||
- name: Verify preview manifest platforms
|
||||
if: "!env.ACT"
|
||||
run: |
|
||||
image="${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
tags=("${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=("pr-6545")
|
||||
fi
|
||||
for tag in "${tags[@]}"; do
|
||||
image="${PREVIEW_IMAGE}:${tag}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
|
||||
cleanup:
|
||||
name: Clean up preview image
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
permissions:
|
||||
statuses: write
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -16,12 +17,20 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
run: |
|
||||
gh api \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
|
||||
-f 'state=success' \
|
||||
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
|
||||
-f 'description=Skipped for non renovate branches' \
|
||||
-f 'context=renovate/stability-days'
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh api \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
|
||||
-f 'state=success' \
|
||||
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
|
||||
-f 'description=Skipped for non renovate branches' \
|
||||
-f 'context=renovate/stability-days' \
|
||||
--silent; then
|
||||
exit 0
|
||||
fi
|
||||
echo "status POST failed (attempt $attempt/5), retrying in 5s"
|
||||
sleep 5
|
||||
done
|
||||
exit 1
|
||||
@@ -27,37 +27,103 @@ jobs:
|
||||
- run: pnpm test:workshop
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish:
|
||||
publish-amd64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v6
|
||||
id: metadata
|
||||
with:
|
||||
images: ghcr.io/homarr-labs/workshop
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=v2,enable=${{ github.ref_name == 'v2' || github.ref_name == 'release/v2' }}
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=workshop-linux-amd64
|
||||
cache-to: type=gha,scope=workshop-linux-amd64,mode=max
|
||||
|
||||
publish-arm64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/arm64
|
||||
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=workshop-linux-arm64
|
||||
cache-to: type=gha,scope=workshop-linux-arm64,mode=max
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: [publish-amd64, publish-arm64]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
WORKSHOP_IMAGE: ghcr.io/homarr-labs/workshop
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish multi-platform image
|
||||
env:
|
||||
AMD64_DIGEST: ${{ needs.publish-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.publish-arm64.outputs.digest }}
|
||||
run: |
|
||||
tags=(-t "${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}")
|
||||
if [[ "${GITHUB_REF_NAME}" == "v2" || "${GITHUB_REF_NAME}" == "release/v2" ]]; then
|
||||
tags+=(-t "${WORKSHOP_IMAGE}:v2")
|
||||
fi
|
||||
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
|
||||
tags+=(-t "${WORKSHOP_IMAGE}:latest")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${WORKSHOP_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${WORKSHOP_IMAGE}@${ARM64_DIGEST}"
|
||||
- name: Verify multi-platform image
|
||||
run: |
|
||||
image="${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
@@ -25,6 +25,8 @@ packages/db/migrations/*/migrate.cjs
|
||||
|
||||
# production
|
||||
build
|
||||
tools/homarr-dev/homarr
|
||||
tools/homarr-dev/homarr-dev
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -92,3 +94,6 @@ homarr-backup*.zip
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
/scripts/benchmarks/
|
||||
|
||||
# PR description screenshots belong on the pull request, not in the repository
|
||||
.screenshots/
|
||||
@@ -30,6 +30,26 @@
|
||||
"options": {
|
||||
"reportUnusedDisableDirectives": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["apps/nextjs/src/**/*.ts", "apps/nextjs/src/**/*.tsx"],
|
||||
"excludeFiles": ["**/*.spec.ts", "**/*.spec.tsx"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "@homarr/widgets",
|
||||
"allowTypeImports": true,
|
||||
"message": "Import a narrow @homarr/widgets export to preserve widget lazy loading."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"**/*.config.js",
|
||||
"**/*.config.mjs",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,43 +1,175 @@
|
||||
# Developer Docker CLI
|
||||
|
||||
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
||||
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
||||
|
||||
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
|
||||
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features.
|
||||
|
||||
## Browse development images
|
||||
## Requirements
|
||||
|
||||
- Docker or OrbStack
|
||||
- Go 1.25+ (when building the CLI from source)
|
||||
- GitHub CLI authenticated with `gh auth login` (for pull-request and CI features)
|
||||
- A [Nerd Font](https://www.nerdfonts.com/) enabled in your terminal (recommended for icons, status indicators, and TUI glyphs)
|
||||
|
||||
## Interactive Development Browser
|
||||
|
||||
```bash
|
||||
pnpm dev:cli --
|
||||
# or with global binary:
|
||||
homarr
|
||||
```
|
||||
|
||||
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
|
||||
The main browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` (or `1`/`2`) to toggle between the image browser and the instance dashboard.
|
||||
|
||||
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
|
||||
A locally built PR image is folded into its remote PR row only when its recorded revision matches the current GitHub head SHA (marked `◆ local`). When the matching GHCR image is also available, pressing `Enter` opens a local/remote selector with local preselected. Outdated local builds remain separate rows.
|
||||
|
||||
## Build images
|
||||
### Multi-Source Sidebar
|
||||
|
||||
Press `Tab` to cycle between sidebar tabs:
|
||||
|
||||
- **`[logs]`**: Live container output streams (press `f` to follow/pause, `L` to pin to the selected container, `Page Up`/`Page Down` to scroll).
|
||||
- **`[build]`**: Real-time BuildKit step streaming for active local builds.
|
||||
- **`[ci]`**: GitHub Actions CI check statuses, run duration, and workflow links for pull requests.
|
||||
|
||||
### Background Task Overlay
|
||||
|
||||
Pulls and builds run asynchronously in the background. Press `t` to open the tasks overlay, showing:
|
||||
|
||||
- Layer-by-layer download progress and transfer speeds (via Docker Engine API).
|
||||
- Active build stages and logs.
|
||||
- Task cancellation and status.
|
||||
|
||||
### Browser Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `/` | Filter rows by PR number, title, author, branch, CI, image, state, or port |
|
||||
| `↑` / `↓` or `j` / `k` | Navigate rows |
|
||||
| `Enter` or `Space` | Start the available image; choose local or remote when both exist; stop if running |
|
||||
| `R` | Build locally in background (or rebuild local image) |
|
||||
| `p` | Pull remote PR image in background and start container |
|
||||
| `m` | Manage mode (`b` rebuild, `i` delete image, `d` delete data, `c` remove container, `p` prune) |
|
||||
| `t` | Toggle background tasks overlay |
|
||||
| `Tab` | Cycle sidebar tabs (`[logs]`, `[build]`, `[ci]`) |
|
||||
| `l` / `L` | Toggle sidebar visibility / Pin logs to selected instance |
|
||||
| `f` | Follow or pause logs |
|
||||
| `M` | Toggle demo mode for next launch |
|
||||
| `b` | Toggle bot pull requests |
|
||||
| `o` / `a` | Open PR on GitHub / Open running instance in browser |
|
||||
| `c` | Copy instance URL to clipboard |
|
||||
| `r` | Refresh data |
|
||||
| `1` / `2` / `d` | Switch screen (development browser / instance dashboard) |
|
||||
| `?` / `q` | Help overlay / Quit (cancels active background tasks) |
|
||||
|
||||
## Instance Dashboard
|
||||
|
||||
```bash
|
||||
pnpm dev:cli -- build <name>
|
||||
pnpm dev:cli -- build --pr <number> [name]
|
||||
homarr dash
|
||||
# or
|
||||
pnpm dev:cli -- dash
|
||||
```
|
||||
|
||||
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
|
||||
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
|
||||
|
||||
## Rebuild images
|
||||
| Key | Action |
|
||||
| ------------------ | ---------------------------------------- |
|
||||
| `Enter` or `Space` | Start or stop selected instance |
|
||||
| `s` / `S` | Stop / Restart instance |
|
||||
| `x` | Remove container (preserves data volume) |
|
||||
| `m` | Manage mode for selected instance |
|
||||
| `R` | Rebuild from recorded build provenance |
|
||||
|
||||
## CLI Commands
|
||||
|
||||
The commands below use the installed `homarr` binary. Without global installation, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- list`).
|
||||
|
||||
### Launch Instances
|
||||
|
||||
```bash
|
||||
pnpm dev:cli -- rebuild <name>
|
||||
homarr run dev
|
||||
homarr run --detach dev
|
||||
homarr run --pr 6441
|
||||
homarr run --pr 6441 --demo
|
||||
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
|
||||
```
|
||||
|
||||
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
|
||||
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
|
||||
- `--demo` (`-m`): Start with mock integrations and sample data.
|
||||
- `--detach`: Start container in background.
|
||||
- `--env` (`-e`): Pass additional environment variables (repeatable: `--env KEY=VALUE`).
|
||||
|
||||
## Run images
|
||||
### Build & Rebuild
|
||||
|
||||
```bash
|
||||
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
|
||||
homarr run <name>
|
||||
homarr run --pr <number>
|
||||
# Build current checkout as homarr:<name>
|
||||
homarr build feature
|
||||
|
||||
# Build a pull request locally in a temporary checkout
|
||||
homarr build --pr 6441
|
||||
|
||||
# Rebuild an existing image from recorded provenance
|
||||
homarr rebuild feature
|
||||
```
|
||||
|
||||
The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
|
||||
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
|
||||
Images built with the CLI record their source checkout, revision SHA, and optional PR number. `homarr rebuild` reads this provenance to reconstruct the build without manual configuration.
|
||||
|
||||
### GitHub CI Checks
|
||||
|
||||
```bash
|
||||
# View CI checks for a specific PR
|
||||
homarr ci 6441
|
||||
|
||||
# View CI checks for the current branch
|
||||
homarr ci
|
||||
|
||||
# Watch CI checks until completion
|
||||
homarr ci 6441 --watch
|
||||
```
|
||||
|
||||
### Interactive Shell
|
||||
|
||||
```bash
|
||||
# Open interactive shell in running container
|
||||
homarr shell homarr-dev
|
||||
|
||||
# Run specific command in container
|
||||
homarr shell homarr-dev pnpm db:migration:sqlite:run
|
||||
```
|
||||
|
||||
### Image & Volume Management
|
||||
|
||||
```bash
|
||||
# List local images with build provenance (source, revision, PR)
|
||||
homarr images
|
||||
|
||||
# Delete local image
|
||||
homarr images rm feature
|
||||
|
||||
# List data volumes with owner PR/tag and in-use status
|
||||
homarr data
|
||||
|
||||
# Delete data volume (safely stops and removes owning container first)
|
||||
homarr data rm homarr_feature_data
|
||||
|
||||
# Remove stopped containers (prompts without --force)
|
||||
homarr prune --force
|
||||
```
|
||||
|
||||
### Instance Operations & Diagnostics
|
||||
|
||||
```bash
|
||||
# List instances in a script-friendly table
|
||||
homarr list
|
||||
|
||||
# Follow container logs
|
||||
homarr logs homarr-dev
|
||||
|
||||
# Stop, restart, remove, or open container in browser
|
||||
homarr stop homarr-dev
|
||||
homarr restart homarr-dev
|
||||
homarr remove homarr-dev
|
||||
homarr open homarr-dev
|
||||
|
||||
# Verify Docker daemon, Engine API, GHCR access, and gh CLI authentication
|
||||
homarr doctor
|
||||
```
|
||||
@@ -25,3 +25,27 @@ You should also create a backup of the database, in case you break something.
|
||||
4. Click "Execute" in the top right of the Window. 
|
||||
5. Select /bin/bash and click Execute 
|
||||
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) 
|
||||
|
||||
## Build a local branch harness
|
||||
|
||||
The host-side operations CLI can build the current checkout and run it as a named, browser-ready Docker harness. This is separate from the recovery CLI described above and does not open the interactive developer TUI.
|
||||
|
||||
```sh
|
||||
pnpm cli harness setup feature-board
|
||||
```
|
||||
|
||||
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
|
||||
|
||||
```sh
|
||||
pnpm cli harness port feature-board
|
||||
```
|
||||
|
||||
Pass environment overrides by repeating `--env`:
|
||||
|
||||
```sh
|
||||
pnpm cli harness setup feature-board \
|
||||
--env WORKSHOP_WEB_URL=https://example.test \
|
||||
--env FEATURE_FLAG=true
|
||||
```
|
||||
|
||||
Other commands are `harness build <name>`, `harness run <name>`, and `harness stop <name>`. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`.
|
||||
@@ -58,11 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
## Docker
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_SOCKET_PATHS` | Comma-separated mounted Docker-compatible socket paths. Homarr discovers each path as a selectable Docker environment | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| `DOCKER_HOSTNAMES` | Comma-separated plaintext TCP hostnames. Pair each hostname with the port in the same position in `DOCKER_PORTS` | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Comma-separated plaintext TCP ports paired with `DOCKER_HOSTNAMES` | For example `2375,2376` | - |
|
||||
| `DOCKER_ENDPOINTS` | Optional advanced JSON configuration for custom names, TLS, or restricted capabilities. It takes precedence over the simple variables above. See the [Docker and Podman connection guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -6,19 +6,19 @@ tags:
|
||||
|
||||
Homarr has the following keyboard shortcuts:
|
||||
|
||||
| Shortcut | Description |
|
||||
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
|
||||
| Shortcut | Description |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
|
||||
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
|
||||
|
||||
## Widget advanced view
|
||||
|
||||
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press
|
||||
`Shift` + `Enter`, or use its menu, to keep the advanced view open.
|
||||
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
|
||||
on Windows/Linux or `Cmd` on macOS over the widget to keep the advanced view open, or use its menu.
|
||||
|
||||
## Board editing
|
||||
|
||||
|
||||
@@ -132,8 +132,8 @@ Here you have the following options in the header:
|
||||
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
|
||||
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
|
||||
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
|
||||
- **Board switcher**: Use this button to open another board that you can access.
|
||||
- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions.
|
||||
- **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open
|
||||
it directly. The current board is left out of the results.
|
||||
|
||||
When entering the edit mode, you'll get some additional buttons to work with:
|
||||
|
||||
@@ -151,7 +151,7 @@ When entering the edit mode, you'll get some additional buttons to work with:
|
||||
|
||||
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
|
||||
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
|
||||
stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
|
||||
stays visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
|
||||
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place.
|
||||
|
||||
To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main
|
||||
@@ -198,9 +198,13 @@ In the modal you can edit the app that you want to display:
|
||||
|
||||
<figure>
|
||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
||||
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
|
||||
<figcaption>
|
||||
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
When adding a widget, set its initial grid width and height directly in the preview. These size controls are only shown before the widget is added.
|
||||
|
||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
|
||||
Press and hold a handle, move the pointer, and release it when the tile has the size you want.
|
||||
@@ -369,7 +373,7 @@ Each page highlights the list view and the create action so you know where to fi
|
||||
|
||||
### Board tour
|
||||
|
||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
|
||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu.
|
||||
|
||||
### Tour controls
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 45 KiB |
@@ -24,12 +24,11 @@ import { dockerContainersWidget } from "@site/docs/widgets/docker-containers";
|
||||
]}
|
||||
/>
|
||||
|
||||
## Enabling the Docker integration
|
||||
## Connecting Docker to Homarr
|
||||
|
||||
This integration must first be enabled.
|
||||
If you directly run on the host system and you have docker installed, it will be enabled by default.
|
||||
Otherwise, you'll need to mount the [docker socket](https://docs.docker.com/engine/security/protect-access/) to the Homarr container,
|
||||
so Homarr can interact with the Docker service.
|
||||
Docker connections come directly from Homarr's environment variables. They are automatically available in the Docker management table and Docker widget; you do not create a Docker integration under **Management → Integrations**.
|
||||
|
||||
If Homarr runs directly on a host with Docker installed, it uses `/var/run/docker.sock` by default. When Homarr runs in a container, mount the [Docker socket](https://docs.docker.com/engine/security/protect-access/) into it so Homarr can interact with Docker.
|
||||
|
||||
In Docker Compose, you can simply add this line:
|
||||
|
||||
@@ -122,9 +121,89 @@ services:
|
||||
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
||||
```
|
||||
|
||||
For remote socket proxies, list the hostnames and matching ports in the same order:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DOCKER_HOSTNAMES: docker-proxy-home,docker-proxy-lab
|
||||
DOCKER_PORTS: 2375,2375
|
||||
```
|
||||
|
||||
Homarr automatically discovers each comma-separated entry as a separate Docker environment. All environments are selected by default; the Docker widget lets you select one or more without any additional setup.
|
||||
|
||||
:::warning[Plaintext TCP]
|
||||
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy on a private Docker network, or TLS.
|
||||
:::
|
||||
|
||||
### Advanced endpoint configuration
|
||||
|
||||
Most users should use the comma-separated variables above. Use `DOCKER_ENDPOINTS` only when you need custom names, TLS, or restricted capabilities. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./docker-ca.pem:/run/secrets/docker-ca.pem:ro
|
||||
environment:
|
||||
DOCKER_ENDPOINTS: >-
|
||||
[
|
||||
{
|
||||
"id": "local",
|
||||
"name": "Local Docker",
|
||||
"kind": "docker",
|
||||
"transport": { "type": "socket", "path": "/var/run/docker.sock" },
|
||||
"capabilities": ["inventory", "logs", "lifecycle", "remove"]
|
||||
},
|
||||
{
|
||||
"id": "remote-read-only",
|
||||
"name": "Production inventory",
|
||||
"kind": "docker",
|
||||
"transport": {
|
||||
"type": "tls",
|
||||
"host": "docker.example.com",
|
||||
"port": 2376,
|
||||
"caPath": "/run/secrets/docker-ca.pem"
|
||||
},
|
||||
"capabilities": ["inventory", "logs"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The stable `id` keeps containers from different endpoints distinct. `name` is shown in the interface, and `kind` can be `docker` or `podman`. Every endpoint must include the `inventory` capability; optionally add `logs`, `lifecycle`, and `remove`. Homarr hides or disables actions the endpoint does not allow and also enforces the same capabilities on the server.
|
||||
|
||||
Supported transports are:
|
||||
|
||||
- `socket`: an absolute in-container `path`
|
||||
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
|
||||
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
|
||||
|
||||
The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`. When `DOCKER_ENDPOINTS` is set, it takes precedence over `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS`.
|
||||
|
||||
### Managing your Docker containers within Homarr
|
||||
|
||||
Homarr allows you to interact with Docker containers running on your system.
|
||||
The collapsed **Assisted Docker setup** helper optionally compares visible containers with your existing Homarr apps and integrations. Open it only when you want setup suggestions; it never blocks normal container management. Every service stays distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
|
||||
|
||||
Homarr can show a service as:
|
||||
|
||||
- **Ready to connect** when its image or name matches a supported integration
|
||||
- **New service** when it can be added as an app but no integration is recognized
|
||||
- **Already represented** or **Linked** when an app or integration already matches
|
||||
- **Address changed** when a service appears to match an integration but its reachable address has changed
|
||||
|
||||
Set the usual server address once, then choose whether services normally use different ports, subdomains, or paths. Homarr uses that pattern to prefill every detected service. Each suggestion has one editable URL field with the generated and Docker-reported addresses available as autocomplete options, so you can select a suggestion or type any address directly.
|
||||
|
||||
:::info[Review before changing]
|
||||
Service discovery is read-only. Homarr never creates or changes an app or integration automatically. **Set up integration** opens the standard prefilled integration form, while **Create app** opens the existing Docker app form, so you can review and confirm the final setup.
|
||||
:::
|
||||
|
||||
Suggestions appear as a compact task list. Expand an item only when you want to edit its URL or inspect relevant setup statuses. Use the filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same helper. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
|
||||
|
||||
Compact status icons summarize the setup information Homarr can act on. Unknown and non-applicable runtime layers are omitted to keep the helper focused; use the integration form's connection test or the relevant board action for live diagnostics.
|
||||
|
||||
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
|
||||
|
||||
Each container row has an action menu with the following options:
|
||||
|
||||
- **View logs** - opens a dedicated logs page with a live terminal
|
||||
@@ -148,6 +227,10 @@ This opens a dedicated page with an xterm.js terminal that streams the container
|
||||
The Docker management table supports optional **CPU usage** and **Memory usage** columns.
|
||||
These columns are hidden by default and can be toggled on from the table column visibility settings.
|
||||
|
||||
#### Choosing Docker environments in the widget
|
||||
|
||||
The Docker widget can show one or more configured Docker environments. Leave **Docker environments** empty to include every endpoint, which is also the default for existing widgets. Selecting one or more entries limits container and statistics requests to those endpoints.
|
||||
|
||||
#### Add apps from containers
|
||||
|
||||
Homarr also lets you add apps from containers. You can simply select all containers you want to add an app for and then click the `Add to Homarr` button.
|
||||
|
||||
@@ -14,6 +14,8 @@ import { kubernetesIntegration } from ".";
|
||||
|
||||
Homarr provides a user-friendly Kubernetes dashboard that offers an overview of your cluster’s resources in a clear and structured interface. It allows users to view key Kubernetes objects, including `Pods`, `Services`, `Ingresses`, `Nodes`, `ConfigMaps`, `Secrets`, `Namespaces`, and `Volumes`, as well as basic metrics when the Kubernetes Metrics Server is enabled.
|
||||
|
||||
Docker service discovery does not convert Kubernetes resources into Homarr apps or integrations. Kubernetes remains a separate, read-only operational view, so a cluster resource is never changed or linked automatically.
|
||||
|
||||
:::warning[Read only]
|
||||
This integration is read-only, meaning Homarr does not modify or manage cluster resources but serves as an informational tool to enhance visibility into your Kubernetes environment.
|
||||
:::
|
||||
@@ -25,7 +27,7 @@ This integration is read-only, meaning Homarr does not modify or manage cluster
|
||||
Before deploying Homarr in Kubernetes, ensure the following prerequisites are met:
|
||||
|
||||
- Kubernetes (Version >= 1.24.0)
|
||||
- Metrics Server ([Install from here](https://github.com/kubernetes-sigs/metrics-server))
|
||||
- Metrics Server ([install from here](https://github.com/kubernetes-sigs/metrics-server)) is optional. Without it, Homarr still shows cluster inventory, node status, and reserved capacity, and identifies live CPU and memory usage as unavailable.
|
||||
- RBAC Enabled (Required for Homarr Helm chart)
|
||||
|
||||
### Configuring RBAC in Helm Chart
|
||||
@@ -45,6 +47,25 @@ Enabling RBAC in the Helm chart creates the necessary Kubernetes Role-Based Acce
|
||||
- ClusterRole & ClusterRoleBinding: Extends the same permissions cluster-wide for additional flexibility.
|
||||
:::
|
||||
|
||||
### Multiple Kubernetes contexts
|
||||
|
||||
When Homarr runs outside a cluster, set `KUBECONFIG` explicitly so Homarr loads the contexts from the referenced kubeconfig file or files. Mount the kubeconfig read-only and set the standard `KUBECONFIG` environment variable to its path inside the Homarr container:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
volumes:
|
||||
- ./kubeconfig:/app/config/kubeconfig:ro
|
||||
environment:
|
||||
KUBECONFIG: /app/config/kubeconfig
|
||||
```
|
||||
|
||||
The Kubernetes management pages include a context selector. The selected context is stored in the page URL, so each React Query cache entry and every API request remains context-specific. Resources with the same namespace and name in two contexts therefore remain separate.
|
||||
|
||||
Context status is checked independently. If one context is unavailable, the selector still lists healthy contexts. A context is shown as **Metrics unavailable** when its Kubernetes API is reachable but Metrics Server is not; inventory remains available without live CPU and memory usage.
|
||||
|
||||
When Homarr runs inside Kubernetes without `KUBECONFIG`, it keeps using the in-cluster service account as the single default context.
|
||||
|
||||
### Homarr Dashboard Overview
|
||||
|
||||
Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes:
|
||||
|
||||
@@ -72,6 +72,34 @@ API key authentication enables programmatic access for:
|
||||
- Custom dashboards and monitoring tools
|
||||
- Container orchestration systems that need to sync app definitions
|
||||
|
||||
## Permissions
|
||||
|
||||
API keys act as the user that created them, so every request is subject to that user's group
|
||||
permissions. A call that the user is not allowed to make returns `FORBIDDEN`, and list endpoints
|
||||
return only the records the user may see rather than failing.
|
||||
|
||||
### App endpoints require app management access
|
||||
|
||||
`GET /api/apps`, `GET /api/apps/paginated` and `GET /api/apps/search` (tRPC `app.all`,
|
||||
`app.getPaginated`, `app.search`) require the **modify all apps** permission. They previously
|
||||
returned the full app catalogue — including internal URLs — to any authenticated user.
|
||||
|
||||
If a script that used to work now returns `FORBIDDEN`, grant **modify all apps** to a group the key
|
||||
owner belongs to, or switch to `GET /api/apps/selectable`, which returns the reduced fields used by
|
||||
board pickers.
|
||||
|
||||
### Integration endpoints are filtered per key
|
||||
|
||||
`integration.all`, `integration.allThatSupportSearch`, `integration.allOfGivenCategory` and
|
||||
`integration.search` filter server side. Without a global integration permission, a key only
|
||||
receives the integrations its owner has been granted access to, so responses may contain fewer
|
||||
records than before rather than returning an error. `integration.search` applies its `limit` after
|
||||
filtering, so a scoped key still receives a full page of results.
|
||||
|
||||
Each returned integration carries a `permissions` object — check `hasUseAccess` before reading data
|
||||
and `hasInteractAccess` before performing actions; `false` means the key owner lacks that level for
|
||||
that integration, not that the call failed.
|
||||
|
||||
## Board Automation
|
||||
|
||||
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
|
||||
@@ -86,6 +114,34 @@ Board layout responses identify each layout with a `mobile`, `base`, or `custom`
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
## Docker action payload migration
|
||||
|
||||
Docker lifecycle and removal actions now identify each container with both its Docker endpoint and container ID. This is a
|
||||
breaking change for tRPC and MCP clients that previously sent an `ids` array to the start, stop, restart, or remove action.
|
||||
|
||||
Replace the old payload:
|
||||
|
||||
```json
|
||||
{ "ids": ["container-id"] }
|
||||
```
|
||||
|
||||
with endpoint-qualified targets:
|
||||
|
||||
```json
|
||||
{
|
||||
"targets": [{ "endpointId": "local", "id": "container-id" }]
|
||||
}
|
||||
```
|
||||
|
||||
Read `endpointId` and `id` from `docker.getContainers` (MCP tool `docker_getContainers`) immediately before the action.
|
||||
Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.restartAll`, or `docker.removeAll`; their MCP tool
|
||||
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
|
||||
Docker or Podman endpoints from targeting the wrong service.
|
||||
|
||||
`docker.getContainers` also accepts an optional `endpointIds` array. Omit it or pass an empty array to query every configured
|
||||
Docker environment, or pass one or more IDs returned by `docker.getEndpoints` to query only those Dockerode clients. The
|
||||
Docker widget uses the same behavior for its environment selector.
|
||||
|
||||
## Partial integration failures
|
||||
|
||||
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
|
||||
|
||||
@@ -51,6 +51,17 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
|
||||
|
||||
## List of apps
|
||||
|
||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app
|
||||
directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||
|
||||
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
|
||||
### Accessing the page
|
||||
|
||||
The `/manage/apps` page is reachable when you can manage all apps (`app-modify-all`) or create apps (`app-create`):
|
||||
|
||||
- **Manage all (`app-modify-all`)**: the full list of apps is shown and searchable, and each row can be edited.
|
||||
- **Create only (`app-create`)**: the page shows an empty "create an app" state instead of the list, so the names and
|
||||
URLs of existing apps are never disclosed. The create action is still available.
|
||||
- **Neither**: the page is not reachable and returns a not-found page.
|
||||
|
||||
Creating an app requires `app-create`, editing requires `app-modify-all`, and deleting requires `app-full-all`;
|
||||
board permissions are not required.
|
||||
@@ -44,12 +44,15 @@ host gateway address.
|
||||
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
|
||||
add Homarr items as context, `/` for shortcuts, and the paperclip for supported files.
|
||||
|
||||
Use **Conversations** to search, switch, rename, export, or delete threads.
|
||||
|
||||
Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small
|
||||
summary of accessible Homarr resources. Live status is fetched only when needed.
|
||||
|
||||
## Permissions and privacy
|
||||
|
||||
- Read-only actions can run automatically. Changes require approval.
|
||||
- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the
|
||||
current conversation, turn off when you switch conversations, and never answer questions for you.
|
||||
- Homarr permissions apply to every tool call and mentioned item.
|
||||
- API keys and custom headers are encrypted and never returned to the browser.
|
||||
- Conversations are stored per user and can be exported or deleted.
|
||||
|
||||
@@ -27,6 +27,11 @@ opening it. Containers include their visible widgets and app icons instead of ap
|
||||
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
|
||||
previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
|
||||
match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the
|
||||
arrow keys to move through the results and press `Enter` to open the selected board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
@@ -204,6 +209,19 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
While edit mode is active, **Add board content** opens a menu for adding a widget, app, integration, or Container. The
|
||||
available actions respect the current user's permissions. Widget setup offers the main canvas, enabled rails, and existing
|
||||
Containers as destinations before the widget is created, so the common flow does not require a second move step.
|
||||
|
||||
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
|
||||
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
|
||||
selection. A short tip appears after entering edit mode as a reminder of the modifier key. A move is applied only when every
|
||||
selected item fits in the destination.
|
||||
|
||||
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
|
||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||
saved integration changes apply everywhere that connection is used.
|
||||
|
||||
Pointer users can drag from the inert widget area; interactive controls are excluded. Containers use the grab button at their top-left corner.
|
||||
Widgets can be resized from any edge or corner handle. Activate a Container's chrome with its top-left grip before using
|
||||
its resize handles.
|
||||
@@ -317,8 +335,9 @@ This will disable the status check for apps.
|
||||
Widgets expose their routine controls without requiring edit mode:
|
||||
|
||||
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
|
||||
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
|
||||
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
|
||||
remain visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
|
||||
keep the view open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place
|
||||
instead.
|
||||
- When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect
|
||||
its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do
|
||||
not use this widget menu, and available controls follow your board and integration permissions.
|
||||
|
||||
@@ -41,6 +41,10 @@ needs attention.
|
||||
4. Write responsive JSX using `data`, `status`, `options`, and temporary `inputs`.
|
||||
5. Test and preview, inspect Data and the redacted request journal, then save.
|
||||
|
||||
While focus is inside a code editor, use `Ctrl+Z`/`Cmd+Z` to undo and `Ctrl+Shift+Z`/`Cmd+Shift+Z` to redo. The toolbar exposes the same actions. This history is local to that editor; builder-wide undo is not yet available.
|
||||
|
||||
Changing the definition, instance options, or credentials invalidates the previous preview and its live-action session. Run **Test and preview** again before treating the data or result as current. Automatic load queries are queued with up to four running at once so larger previews stay within the request limit.
|
||||
|
||||
<CanonicalCustomWidgetExample
|
||||
id="named-widget-requests"
|
||||
label="Named queries and actions"
|
||||
|
||||
@@ -17,6 +17,24 @@ To create an integration, choose **New integration** and select the supported ap
|
||||
|
||||
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
|
||||
|
||||
### Accessing the page
|
||||
|
||||
The `/manage/integrations` page is reachable when at least one of the following applies:
|
||||
|
||||
- you can manage all integrations (global `integration-full-all`),
|
||||
- you can create integrations (`integration-create`), or
|
||||
- you have been granted full access to at least one specific integration.
|
||||
|
||||
What the page shows depends on the case:
|
||||
|
||||
- **Manage all (`integration-full-all`)**: the full list of integrations is shown.
|
||||
- **Create only (`integration-create`)**: the page shows an empty "create an integration" state instead of the list, with the create action still available.
|
||||
- **Delegated (full access on one or more)**: only the integrations you have full access to are listed, above a note making clear that the view is scoped to what you can manage.
|
||||
- **None**: the page is not reachable and returns a not-found page.
|
||||
|
||||
Where you have a mix of access, the list combines the integrations you can manage. Integration lists are filtered
|
||||
server side, so a user never receives data about integrations they lack access to.
|
||||
|
||||
Next, you'll need to fill out the required fields to configure the integration:
|
||||
|
||||

|
||||
@@ -59,6 +77,22 @@ It is not allowed to create an integration without passing the test.
|
||||
This is to ensure that you minimize the amount of broken applications on your Homarr instance.
|
||||
The test and create button will perform a network request to the application and authenticate if required.
|
||||
|
||||
When you connect a service from a board, Homarr keeps you in the same setup flow. After the connection succeeds, it
|
||||
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
|
||||
widget returns to its settings with the new integration already selected.
|
||||
|
||||
Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration**
|
||||
tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page.
|
||||
Successful changes apply to every app and widget that uses the integration.
|
||||
|
||||
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
|
||||
create one.
|
||||
|
||||
When the connection unlocks useful widgets that are not on the current board yet, the confirmation also shows up to
|
||||
three explainable recommendations. A recommendation is based only on the services you can use and the widget kinds
|
||||
already present on that board. You can open the existing widget picker prefiltered to the recommendation, or dismiss
|
||||
recommendations for that service on that board.
|
||||
|
||||
## Managing permissions
|
||||
|
||||
On the edit page of an integration, you can manage which users / groups can
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags:
|
||||
- Administration
|
||||
- Logs
|
||||
- Troubleshooting
|
||||
---
|
||||
|
||||
# Logs
|
||||
|
||||
The logs page shows recent Homarr application logs and continues updating as new events occur. Use it to investigate
|
||||
widget, integration, and server errors without opening the container logs separately.
|
||||
|
||||
## Opening the logs page
|
||||
|
||||
Navigate to **Management** > **Tools** > **Logs**. The page and widget log links are only available to users with the
|
||||
**View logs** permission.
|
||||
|
||||
When a widget reports an error, **Check logs for more details** opens the logs page in a new tab and marks the time when
|
||||
the widget displayed the error. The related server error normally appears immediately above that marker. If those logs
|
||||
have already been removed from the retained history, the marker explains that they are no longer available.
|
||||
|
||||
## Retained and live logs
|
||||
|
||||
Homarr retains the latest 5,000 log events in Redis. Opening the page first loads this history and then continues with
|
||||
live events without a gap. The history is bounded, so older events are removed automatically.
|
||||
|
||||
The history survives normal application restarts when the configured Redis instance persists its data. For complete
|
||||
platform-level history, startup failures, or events older than the retained limit, use the logs provided by Docker or
|
||||
your deployment platform.
|
||||
|
||||
## Display controls
|
||||
|
||||
The severity selector applies to both retained and live events. Selecting a different level redraws the existing view
|
||||
using that threshold. The font-size control changes the terminal text size and is remembered in the browser.
|
||||
@@ -13,7 +13,7 @@ tags:
|
||||
The settings page applies options to the whole Homarr application.
|
||||
Some changes need a full page reload before they take effect.
|
||||
|
||||
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||
All settings sections remain visible in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||
|
||||
## Analytics
|
||||
|
||||
|
||||
@@ -163,19 +163,77 @@ Members can be removed from the group using the remove button at the end:
|
||||
#### Permissions tab
|
||||
|
||||
In this tab, you manage what permissions the members of this group will have.
|
||||
Currently, the following permissions are available:
|
||||
Permissions are shown as a matrix with one row per category (boards, apps, integrations, and so on). Each row has a
|
||||
segmented control that lets you pick a single **access level**, plus a checkbox for each **capability** the category
|
||||
has.
|
||||
|
||||
- Boards
|
||||
- **Create boards**: Allow members to create boards
|
||||
- **View all boards**: Allow members to view all boards
|
||||
- **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone)
|
||||
- **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone)
|
||||
- Integrations
|
||||
- **Create integrations**: Allow members to create integrations
|
||||
- **Use all integrations**: Allows members to add any integrations to their boards
|
||||
- **Interact with any integration**: Allow members to interact with any integration
|
||||
- **Full integration access**: Allow members to manage, use and interact with any integration
|
||||
- **Administrator**: Members with this permission have full access to all features and settings
|
||||
Levels in a category are cumulative: selecting a level grants that level and every level below it in the same
|
||||
category. Choosing **No access** grants no access level for that category.
|
||||
|
||||
##### Capabilities are separate from the access level
|
||||
|
||||
A capability is something you can grant on its own, independently of how much of everyone else's data the members
|
||||
can see. Creating is the usual example: you can let members create their own boards without letting them view or
|
||||
modify anyone else's boards. Apps additionally have **use all apps**, which lets members put any app on a board
|
||||
without being able to edit apps.
|
||||
|
||||
Some access levels already include a capability through permission inheritance (marked in the table below); the
|
||||
checkbox is then ticked, disabled, and explains which level includes it.
|
||||
|
||||
##### Administration
|
||||
|
||||
Selecting the **Administrator** level grants full access to everything. While it is selected, every other category
|
||||
is disabled and explains that administrators already have that access. Setting the administrator row back to
|
||||
**No access** re-enables the other categories with the selections they had before.
|
||||
|
||||
##### Access levels
|
||||
|
||||
The access levels below are cumulative, in ascending order, with the category's capabilities listed separately:
|
||||
|
||||
| Category | Access levels (ascending) | Capabilities | Already included at |
|
||||
| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------- |
|
||||
| Boards | view all boards → modify all boards → full board access | create boards | full board access |
|
||||
| Apps | modify all apps → full app access | use all apps, create apps | create apps: modify all apps and full app access; use all apps: full app access |
|
||||
| Integrations | use all integrations → interact with any integration → full integration access | create integrations | full integration access |
|
||||
| Search engines | modify all search engines → full search engine access | create search engines | modify all search engines, full search engine access |
|
||||
| Medias | view all medias → full media access | upload medias | full media access |
|
||||
| Other | view logs | — | — |
|
||||
| Administrator | administrator | — | — |
|
||||
|
||||
Apps are the one category where "use" is not a level. Being able to modify every app does **not** imply being able
|
||||
to put any app on a board — only full app access grants both — so the two are kept independent rather than stacked,
|
||||
and picking **Modify** never silently grants use of every app.
|
||||
|
||||
The levels grant the following:
|
||||
|
||||
- **Boards**: view all boards; modify all boards (not access control and danger zone); full board access (view, modify and delete every board, including access control and the danger zone).
|
||||
- **Apps**: modify all apps; full app access (manage, use and delete any app).
|
||||
- **Integrations**: use all integrations; interact with any integration; full integration access (manage, use and interact with any integration).
|
||||
- **Search engines**: modify all search engines; full search engine access (manage and delete any search engine).
|
||||
- **Medias**: view all medias; full media access (manage and delete any media).
|
||||
- **Other**: view logs.
|
||||
|
||||
##### Presets
|
||||
|
||||
Above the rows you can apply a preset to fill in a common configuration in one click:
|
||||
|
||||
- **Viewer**: read-only - view all boards and medias, use any app on a board, and use existing integrations.
|
||||
- **Editor**: day-to-day management - modify all boards and apps, create boards and integrations, interact with any integration, modify search engines, and upload medias, without deleting resources or managing access.
|
||||
- **Admin**: grants the administrator permission (full access to everything).
|
||||
|
||||
When the current selection does not match any preset, a **Custom** badge is shown.
|
||||
|
||||
##### Also grants
|
||||
|
||||
Some levels include additional permissions automatically. When a level implies extra permissions (for example,
|
||||
modifying all apps also allows creating apps), a dimmed **Also grants: …** hint is shown below the category row,
|
||||
listing the implied permissions.
|
||||
|
||||
##### Effective permissions
|
||||
|
||||
Below the rows, the **Effective permissions** panel can be expanded to review everything members of this group will
|
||||
be able to do - the full, de-duplicated set of permissions, including everything granted indirectly through the
|
||||
levels - grouped by category, with a count for each.
|
||||
|
||||
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
|
||||
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "Air Quality & UV"
|
||||
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { airQualityWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={airQualityWidget} categories={["Health", "Weather"]} />
|
||||
|
||||
The Air Quality & UV widget shows the current air quality index for a selected location. It adds the UV index,
|
||||
dominant pollutant, particulate matter and the highest available pollen reading as space allows. Open the advanced
|
||||
view for a 24-hour AQI and UV chart, all available pollutant and pollen readings, and seven-day daily maxima.
|
||||
|
||||
Select the European or US AQI scale, or use the automatic setting. Automatic uses the US scale for viewers whose
|
||||
locale region is the United States and the European scale everywhere else. The selected scale stays in widget options
|
||||
instead of being repeated beside every reading. Pollen values are individual measured concentrations; the widget does
|
||||
not combine them into a single pollen index.
|
||||
|
||||
The categories and colors summarize published index bands and are not medical advice. Pollutant concentrations use
|
||||
micrograms per cubic meter (`µg/m³`), and pollen uses grains per cubic meter.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={airQualityWidget.configuration} />
|
||||
|
||||
### Data source
|
||||
|
||||
Homarr uses the free Open-Meteo Air Quality API. The widget refreshes active data every 15 minutes and requests a
|
||||
seven-day forecast for European AQI, US AQI, UV, particulate matter, ozone, nitrogen dioxide, sulphur dioxide, carbon
|
||||
monoxide, and available alder, birch, grass, mugwort, olive and ragweed pollen. Identical location requests reuse the
|
||||
server response for 15 minutes. If a refresh fails, Homarr can keep a recent successful response visible for up to 24
|
||||
hours instead of replacing it with an empty state.
|
||||
|
||||
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. If you need to allowlist the
|
||||
service, use `https://air-quality-api.open-meteo.com`.
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { WidgetDefinition } from "@site/src/types";
|
||||
import { IconWind } from "@tabler/icons-react";
|
||||
|
||||
export const airQualityWidget: WidgetDefinition = {
|
||||
icon: IconWind,
|
||||
name: "Air Quality & UV",
|
||||
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location.",
|
||||
path: "../../widgets/air-quality",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Air quality location",
|
||||
description: "Location used for air quality, UV and pollen data",
|
||||
values: "Select a location through search or longitude/latitude",
|
||||
defaultValue: "Paris / 48.85341, 2.3488",
|
||||
},
|
||||
{
|
||||
name: "AQI standard",
|
||||
description: "Choose the European or US air quality scale, or select one from the viewer's locale",
|
||||
values: { type: "select", options: ["Automatic for locale", "European AQI", "US AQI"] },
|
||||
defaultValue: "Automatic for locale",
|
||||
},
|
||||
{
|
||||
name: "Show UV index",
|
||||
description: "Display the current UV index and its forecast",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show pollutant concentrations",
|
||||
description: "Display particulate matter and gaseous pollutant concentrations",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show pollen concentrations",
|
||||
description: "Display available pollen concentrations",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import beszelSystemGrid from "./img/beszel-system-grid.png";
|
||||
|
||||
<WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} />
|
||||
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Resizing the widget adjusts its rows and columns to keep the cards balanced. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
|
||||
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Bookmarks"
|
||||
description: "Displays multiple app links"
|
||||
description: "Keeps useful links together"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
@@ -13,11 +13,12 @@ import { bookmarksWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The bookmarks widget allows you to display multiple links in a single widget.
|
||||
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
|
||||
For links that do not need an App, add their URLs under **Custom links** instead.
|
||||
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
|
||||
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
|
||||
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
|
||||
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
|
||||
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one.
|
||||
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
|
||||
|
||||
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
|
||||
export const bookmarksWidget: WidgetDefinition = {
|
||||
icon: IconBookmark,
|
||||
name: "Bookmarks",
|
||||
description: "Displays multiple app links",
|
||||
description: "Keeps useful links together",
|
||||
path: "../../widgets/bookmarks",
|
||||
configuration: {
|
||||
items: [
|
||||
@@ -16,9 +16,21 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Layout",
|
||||
description: "Layout in which the bookmarks are displayed.",
|
||||
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
|
||||
defaultValue: "Vertical",
|
||||
description: "How bookmarks use the available widget space.",
|
||||
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
||||
defaultValue: "Adaptive",
|
||||
},
|
||||
{
|
||||
name: "Appearance",
|
||||
description: "Card surface style.",
|
||||
values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] },
|
||||
defaultValue: "Soft",
|
||||
},
|
||||
{
|
||||
name: "Card spacing",
|
||||
description: "Space between bookmark cards.",
|
||||
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
|
||||
defaultValue: "Extra small",
|
||||
},
|
||||
{
|
||||
name: "Hide title",
|
||||
@@ -46,16 +58,10 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Bookmarks",
|
||||
description: "List of bookmarks to display in the widget.",
|
||||
values: "Select one or more Apps directly, then order them as you like.",
|
||||
description: "URLs and existing Apps shown by the widget.",
|
||||
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
|
||||
defaultValue: "No bookmarks selected",
|
||||
},
|
||||
{
|
||||
name: "Custom links",
|
||||
description: "URLs to display without first creating an App.",
|
||||
values: "List of URLs.",
|
||||
defaultValue: "No custom links",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -69,6 +69,7 @@ import advancedView from "./img/advanced-view.png";
|
||||
|
||||
### Interactions
|
||||
|
||||
- Colored dots directly below a date mark its events. They scale with the widget and are hidden when the calendar is too short.
|
||||
- Hover over a marked date to preview its events in a popover.
|
||||
- The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
|
||||
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
|
||||
|
||||
@@ -15,6 +15,26 @@ import { clockWidget } from ".";
|
||||
|
||||
The clock widget displays the current date and time in a customizable format.
|
||||
|
||||
Advanced view turns it into a World Clock. The configured primary timezone stays prominent while New York, Paris, and
|
||||
Tokyo are included as secondary defaults. You can add, rename, remove, and reorder up to six cities in widget options;
|
||||
London, Sydney, and UTC are also available as quick presets.
|
||||
|
||||
The digital advanced view keeps the primary timezone prominent and presents every secondary city as a concise,
|
||||
comparable card. The primary clock includes a segmented 24-hour rail with a current-time marker, elapsed and remaining
|
||||
time, six-hour ticks, and a legend for night, dawn, day, and dusk. These phases are based on local clock time and are not
|
||||
astronomical sunrise or sunset calculations. Secondary cards stop after their localized date and timezone offsets.
|
||||
|
||||
The compact widget remains a single numeric clock. The time-format selector shows common Day.js presets with live
|
||||
examples and applies to the primary clock. Choosing a format with seconds enables second-level updates; 12-hour and
|
||||
24-hour display are selected through those same formats. The date-format selector likewise
|
||||
shows live Day.js examples and their tokens. Secondary cities always include a short localized date and a
|
||||
Yesterday, Today, or Tomorrow label relative to the primary timezone. UTC and relative offsets follow current timezone
|
||||
rules, including daylight-saving changes.
|
||||
|
||||
Weather remains optional and uses its own configured location rather than a World Clock city. Its icon is static by
|
||||
default and can be animated explicitly; reduced-motion preferences always disable animation. An unsupported saved
|
||||
timezone is identified without preventing valid clocks from rendering.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import localClock from "./img/local.png";
|
||||
@@ -27,7 +47,7 @@ import tokyoClock from "./img/tokyo-12-hour.png";
|
||||
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
|
||||
</div>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -20,18 +20,6 @@ export const clockWidget: WidgetDefinition = {
|
||||
values: { type: "string" },
|
||||
defaultValue: "-",
|
||||
},
|
||||
{
|
||||
name: "24-hour format",
|
||||
description: "Use 24-hour format instead of 12-hour format.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Display seconds",
|
||||
description: "Whether to show seconds in the clock.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Use fixed timezone",
|
||||
description: "Select custom timezone to display instead of the client timezone.",
|
||||
@@ -52,21 +40,46 @@ export const clockWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Date format",
|
||||
description: "Format of the date displayed below the clock.",
|
||||
values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.",
|
||||
description: "Choose a common Day.js date format with a live example and its tokens.",
|
||||
values: "YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, or common long and short date formats",
|
||||
defaultValue: "dddd, MMMM D",
|
||||
},
|
||||
{
|
||||
name: "Custom time format",
|
||||
description: "Format of the time displayed in the clock.",
|
||||
values: 'ISO 8601 format, e.g. "HH:mm:ss", "hh:mm A", etc.',
|
||||
defaultValue: "-",
|
||||
name: "Time format",
|
||||
description:
|
||||
"Choose a common Day.js time format with a live example. The format controls 12/24-hour display and seconds.",
|
||||
values: "HH:mm, HH:mm:ss, h:mm A, or h:mm:ss A",
|
||||
defaultValue: "HH:mm",
|
||||
},
|
||||
{
|
||||
name: "Custom date format",
|
||||
description: "Format of the date displayed below the clock.",
|
||||
values: 'ISO 8601 format, e.g. "YYYY-MM-DD", "DD/MM/YYYY", etc.',
|
||||
defaultValue: "-",
|
||||
name: "Show weather",
|
||||
description: "Show a compact weather summary from an independently configured location.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Weather location",
|
||||
description: 'Location used by the weather summary. Only shown if "Show weather" is enabled.',
|
||||
values: "A searched city or custom latitude and longitude",
|
||||
defaultValue: "Brisbane",
|
||||
},
|
||||
{
|
||||
name: "Use Fahrenheit",
|
||||
description: 'Use Fahrenheit in the weather summary. Only shown if "Show weather" is enabled.',
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Animate weather icon",
|
||||
description: "Animate the weather icon unless the device requests reduced motion.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "World clock cities",
|
||||
description: "Add, label, and order up to six IANA timezones for advanced view.",
|
||||
values: "Up to six labeled IANA timezones",
|
||||
defaultValue: "New York, Paris, and Tokyo",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: "Countdowns"
|
||||
description: "Tracks upcoming events and recurring anniversaries."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { countdownWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={countdownWidget} categories={["Time"]} />
|
||||
|
||||
The Countdowns widget tracks up to 20 events without an integration. Add each event in widget options, choose its local
|
||||
date and time, and select an IANA timezone. Events can be reordered by dragging them.
|
||||
|
||||
The compact widget shows the first configured event, so dragging an event to the top selects the countdown displayed on
|
||||
the board. Advanced view uses that same event for its larger summary and preserves the configured order within its
|
||||
upcoming and completed groups. List cards keep their occurrence date at the bottom, below the optional progress bar.
|
||||
|
||||
One-time events remain completed after their target. A yearly event keeps the same local date and wall-clock time in its
|
||||
configured timezone, including when daylight-saving offsets change. A February 29 event waits until the next leap year.
|
||||
If a configured local time falls in a daylight-saving gap, the first valid time afterward is used; if it occurs twice,
|
||||
the earlier occurrence is used.
|
||||
|
||||
Progress is optional. One-time events need a start date before a percentage can be calculated. Yearly events show
|
||||
progress from the previous occurrence to the next. When enabled, seconds remain visible for every upcoming event.
|
||||
|
||||
Browser notifications are an optional local preference in advanced view and are disabled by default. Permission is
|
||||
requested only after you enable notifications, and alerts can fire once per occurrence while Homarr is open. Browser
|
||||
storage uses the board and widget identifiers to keep preferences separate and prevent duplicate alerts; clearing site
|
||||
data resets them. Widget previews do not persist alert preferences.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={countdownWidget.configuration} />
|
||||
@@ -0,0 +1,31 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconHourglass } from "@tabler/icons-react";
|
||||
|
||||
export const countdownWidget: WidgetDefinition = {
|
||||
icon: IconHourglass,
|
||||
name: "Countdowns",
|
||||
description: "Tracks upcoming events and recurring anniversaries.",
|
||||
path: "../../widgets/countdown",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Events",
|
||||
description: "Add, edit, and order one-time or yearly events with a date, time, and IANA timezone.",
|
||||
values: "Up to 20 events",
|
||||
defaultValue: "No events",
|
||||
},
|
||||
{
|
||||
name: "Show progress",
|
||||
description: "Show progress from an optional start date, or between yearly occurrences.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show seconds",
|
||||
description: "Keep seconds visible for every upcoming event, including events more than a day away.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -7,20 +7,15 @@ 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 { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
import { IconAd } from "@tabler/icons-react";
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { dockerContainersWidget } from ".";
|
||||
import { dockerIntegration } from "@site/docs/integrations/docker";
|
||||
|
||||
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
|
||||
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them.
|
||||
|
||||
:::info
|
||||
This widget can only be used with administrator privileges.
|
||||
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
|
||||
:::
|
||||
|
||||
### Screenshots
|
||||
@@ -29,16 +24,6 @@ import dockerTable from "./img/table.png";
|
||||
|
||||
<img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dockerIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Interactions
|
||||
|
||||
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
|
||||
|
||||
@@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = {
|
||||
path: "../../widgets/docker-containers",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Docker environments",
|
||||
description: "Select one or more automatically discovered Docker environments, or leave empty to show all",
|
||||
values: "Any Docker socket or host configured for Homarr",
|
||||
defaultValue: "All Docker environments",
|
||||
},
|
||||
{
|
||||
name: "Columns to show",
|
||||
description: "Select which columns are visible in the table",
|
||||
|
||||
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||
|
||||
The media server widget shows active sessions across your media servers.
|
||||
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
|
||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: "Timer / Pomodoro"
|
||||
description: "Runs a simple timer or a configurable Pomodoro sequence."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { timerWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={timerWidget} categories={["Productivity", "Time"]} />
|
||||
|
||||
The Timer / Pomodoro widget runs entirely in your browser and does not require an integration. The compact widget
|
||||
always keeps its start or pause button and reset button visible. A running timer uses a saved deadline, so reopening
|
||||
the board or returning to a background tab restores the correct remaining time instead of restarting the countdown.
|
||||
|
||||
Timer mode uses one configured duration. Pomodoro mode starts with a focus session, follows most completed focus
|
||||
sessions with a short break, and uses a long break after the configured number of focus sessions. Automatic break and
|
||||
focus starts are separate options and are disabled by default. When automatic starts are enabled, elapsed phases are
|
||||
reconciled from their original deadlines after the board returns to the foreground.
|
||||
|
||||
Advanced view shows the current sequence, configured durations, and local alert preferences. Browser notifications are
|
||||
requested only when enabled. Notification and sound preferences, live progress, deadlines, and cycle position are
|
||||
stored locally using the board and widget identifiers. They are separate for each browser or device, are available to
|
||||
guest viewers, and are removed by clearing site data. Widget previews are ephemeral and do not write local timer state.
|
||||
|
||||
Timer sounds are enabled by default and use the same low-volume Tiks sound system as onboarding: start and pause use
|
||||
rising and falling soft-theme cues, reset uses a short click, and phase completion uses the glass theme's resonant
|
||||
notification ding. You can disable them in advanced view. The cues respect the device's reduced-motion preference and
|
||||
supplement the visible timer state rather than replacing it.
|
||||
|
||||
When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the
|
||||
board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at
|
||||
300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border.
|
||||
The current phase stays pinned to the compact widget's top-left corner and wraps when needed. Timer text and controls
|
||||
use the dashboard theme's responsive size scale, with secondary details hidden when the widget preview is narrow or
|
||||
short.
|
||||
|
||||
Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them.
|
||||
The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited
|
||||
from the widget context menu.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={timerWidget.configuration} />
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { WidgetDefinition } from "@site/src/types";
|
||||
import { IconClockPlay } from "@tabler/icons-react";
|
||||
|
||||
export const timerWidget: WidgetDefinition = {
|
||||
icon: IconClockPlay,
|
||||
name: "Timer / Pomodoro",
|
||||
description: "Runs a simple timer or a configurable Pomodoro sequence.",
|
||||
path: "../../widgets/timer",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Mode",
|
||||
description: "Run one reusable timer or a repeating focus and break sequence",
|
||||
values: { type: "select", options: ["Timer", "Pomodoro"] },
|
||||
defaultValue: "Pomodoro",
|
||||
},
|
||||
{
|
||||
name: "Timer minutes",
|
||||
description: "Duration of the reusable timer",
|
||||
values: "1-1440 minutes",
|
||||
defaultValue: "10",
|
||||
},
|
||||
{
|
||||
name: "Focus minutes",
|
||||
description: "Duration of each Pomodoro focus session",
|
||||
values: "1-180 minutes",
|
||||
defaultValue: "25",
|
||||
},
|
||||
{
|
||||
name: "Short break minutes",
|
||||
description: "Duration of breaks between most focus sessions",
|
||||
values: "1-60 minutes",
|
||||
defaultValue: "5",
|
||||
},
|
||||
{
|
||||
name: "Long break minutes",
|
||||
description: "Duration of the break at the end of a Pomodoro cycle",
|
||||
values: "1-180 minutes",
|
||||
defaultValue: "15",
|
||||
},
|
||||
{
|
||||
name: "Focus sessions before a long break",
|
||||
description: "Number of completed focus sessions in one Pomodoro cycle",
|
||||
values: "1-12 sessions",
|
||||
defaultValue: "4",
|
||||
},
|
||||
{
|
||||
name: "Automatically start breaks",
|
||||
description: "Start the next short or long break when a focus session completes",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Automatically start focus sessions",
|
||||
description: "Start the next focus session when a break completes",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -13,23 +13,21 @@ import { weatherWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
|
||||
|
||||
The Weather Widget uses your location to display the current, highest and lowest temperature.
|
||||
In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch.
|
||||
The compact forecast shows up to the configured number of days. The advanced view combines current conditions with the
|
||||
available daily forecast, including humidity, wind, gusts, sunrise and sunset. Both views reuse the same cached Open
|
||||
Meteo response.
|
||||
The Weather Widget displays current conditions and a responsive forecast for a selected location. New widgets start
|
||||
with the city and a three-day forecast visible. As the widget gets smaller, it keeps the current condition and
|
||||
temperature readable before removing secondary details or forecast days.
|
||||
|
||||
### Screenshots
|
||||
Select a forecast day to inspect precipitation, apparent temperature, humidity, wind, UV index, sunrise, sunset and
|
||||
daylight duration with a mouse, keyboard or touch. Open the advanced view for a current-condition summary, a 24-hour
|
||||
temperature and precipitation chart, and the complete seven-day forecast. The chart has an equivalent data table for
|
||||
assistive technologies.
|
||||
|
||||
import forecastWeather from "./img/forecast.png";
|
||||
import currentWeather from "./img/current.png";
|
||||
import advancedWeather from "./img/advanced-view.png";
|
||||
The **Show humidity** option controls humidity across the compact current-conditions view, advanced summary, hourly
|
||||
details, and daily forecast details. Forecast cards use a roomier adaptive grid in advanced view, with four columns on
|
||||
the standard desktop surface and fewer columns as the surface narrows.
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<img src={forecastWeather} alt="Weather compact forecast" width={238} height={112} />
|
||||
<img src={currentWeather} alt="Weather compact current conditions" width={198} height={93} />
|
||||
<img src={advancedWeather} alt="Weather advanced view with seven-day forecast details" width={800} height={484} />
|
||||
</div>
|
||||
Weather icons are static by default. Animation can be enabled in the widget configuration, but the operating system's
|
||||
reduced-motion preference always disables it.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
@@ -43,5 +41,10 @@ import advancedWeather from "./img/advanced-view.png";
|
||||
|
||||
### Data source
|
||||
|
||||
We use the Open Meteo API to fetch the weather and locations.
|
||||
We use the Open Meteo API to fetch the weather and locations. Active weather data refreshes every ten minutes, while
|
||||
identical location requests can reuse a server response for five minutes. A manual refresh may therefore return the
|
||||
same still-current server response. If a refresh fails, Homarr keeps the last successful data visible and shows an
|
||||
error indicator.
|
||||
|
||||
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests.
|
||||
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
|
||||
@@ -28,8 +28,24 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show humidity",
|
||||
description: "Display humidity in current conditions, hourly details, and daily forecasts",
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Animate weather icons",
|
||||
description: "Animate condition icons unless the operating system requests reduced motion",
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Use imperial speed units",
|
||||
description: "Display wind speed in mph instead of km/h",
|
||||
@@ -59,7 +75,7 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Has forecast",
|
||||
@@ -67,13 +83,13 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Amount of forecast days",
|
||||
description: "Number of days to show in the forecast",
|
||||
values: "1-7",
|
||||
defaultValue: "5",
|
||||
defaultValue: "3",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
|
||||
import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
|
||||
import { CommonWidgetProps, WidgetCard } from "./card";
|
||||
import clsx from "clsx";
|
||||
|
||||
@@ -6,21 +6,27 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => {
|
||||
return (
|
||||
<WidgetCard width={1} className={clsx("gap-2", className)}>
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
<IconCloud size={20} />
|
||||
<span className="text-xl font-bold">10.8°C</span>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
<IconWind size={14} />
|
||||
<span className="text-sm">5 km/h</span>
|
||||
</div>
|
||||
<div className="flex gap-1 justify-center items-center">
|
||||
<div className="flex justify-center items-center">
|
||||
<IconArrowUpRight size={14} />
|
||||
<span className="text-sm">13.5°C</span>
|
||||
<IconSun size={22} />
|
||||
<div className="flex flex-col leading-none">
|
||||
<span className="text-xl font-bold">18.4°C</span>
|
||||
<span className="text-xs text-muted-foreground">Paris</span>
|
||||
</div>
|
||||
<div className="flex justify-center items-center">
|
||||
<IconArrowDownRight size={14} />
|
||||
<span className="text-sm">6.4°C</span>
|
||||
</div>
|
||||
<div className="flex justify-evenly items-center w-full">
|
||||
<div className="flex flex-col items-center text-xs">
|
||||
<span>Mon</span>
|
||||
<IconSun size={16} />
|
||||
<span>21°</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-xs">
|
||||
<span>Tue</span>
|
||||
<IconCloud size={16} />
|
||||
<span>19°</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-xs">
|
||||
<span>Wed</span>
|
||||
<IconCloudRain size={16} />
|
||||
<span>16°</span>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetCard>
|
||||
|
||||
+10
-1
@@ -1266,9 +1266,15 @@
|
||||
"properties": {
|
||||
"boardId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"layoutId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["boardId"],
|
||||
"required": ["boardId", "name", "layoutId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -2100,6 +2106,9 @@
|
||||
"enum": [
|
||||
"clock",
|
||||
"weather",
|
||||
"airQuality",
|
||||
"countdown",
|
||||
"timer",
|
||||
"app",
|
||||
"iframe",
|
||||
"video",
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"@mantine/form": "catalog:",
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@mantine/modals": "catalog:",
|
||||
"@mantine/notifications": "catalog:",
|
||||
"@mantine/tiptap": "catalog:",
|
||||
"@modelcontextprotocol/server": "catalog:",
|
||||
"@rexa-developer/tiks": "catalog:",
|
||||
@@ -75,6 +76,7 @@
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "catalog:",
|
||||
"@tanstack/react-query-next-experimental": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/next": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" role="img" aria-labelledby="title description">
|
||||
<title id="title">Abstract homelab network</title>
|
||||
<description id="description">A dark indigo field with subtle connected nodes and cyan signal paths.</description>
|
||||
<defs>
|
||||
<radialGradient id="base" cx="50%" cy="40%" r="80%">
|
||||
<stop offset="0" stop-color="#172047"/>
|
||||
<stop offset="0.48" stop-color="#0b1028"/>
|
||||
<stop offset="1" stop-color="#060814"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="indigoGlow">
|
||||
<stop offset="0" stop-color="#748ffc" stop-opacity="0.32"/>
|
||||
<stop offset="1" stop-color="#748ffc" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="cyanGlow">
|
||||
<stop offset="0" stop-color="#3bc9db" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#3bc9db" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop stop-color="#748ffc" stop-opacity="0.05"/>
|
||||
<stop offset="0.5" stop-color="#748ffc" stop-opacity="0.34"/>
|
||||
<stop offset="1" stop-color="#3bc9db" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur">
|
||||
<feGaussianBlur stdDeviation="55"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="1920" height="1080" fill="url(#base)"/>
|
||||
<ellipse cx="330" cy="90" rx="560" ry="430" fill="url(#indigoGlow)" filter="url(#blur)"/>
|
||||
<ellipse cx="1680" cy="940" rx="620" ry="400" fill="url(#cyanGlow)" filter="url(#blur)"/>
|
||||
<g fill="none" stroke="url(#signal)" stroke-width="1.5">
|
||||
<path d="M-80 825C240 570 475 760 720 510s470-165 650-330 330-85 630-250"/>
|
||||
<path d="M-120 1010C220 740 470 920 780 660s520-105 700-270 300-125 560-180"/>
|
||||
<path d="M110 40c180 190 350 220 545 130s390-55 520 80 320 155 620 60" opacity=".45"/>
|
||||
</g>
|
||||
<g fill="#9aa8ff" opacity=".48">
|
||||
<circle cx="248" cy="660" r="3"/>
|
||||
<circle cx="718" cy="511" r="4"/>
|
||||
<circle cx="1118" cy="287" r="3"/>
|
||||
<circle cx="1450" cy="110" r="4"/>
|
||||
</g>
|
||||
<g fill="#5ee0ee" opacity=".42">
|
||||
<circle cx="435" cy="850" r="3"/>
|
||||
<circle cx="908" cy="604" r="4"/>
|
||||
<circle cx="1350" cy="510" r="3"/>
|
||||
<circle cx="1715" cy="285" r="4"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#aeb8ff" stroke-opacity=".055">
|
||||
<circle cx="330" cy="90" r="260"/>
|
||||
<circle cx="330" cy="90" r="345"/>
|
||||
<circle cx="1680" cy="940" r="250"/>
|
||||
<circle cx="1680" cy="940" r="335"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,350 @@
|
||||
import { dehydrate, QueryClient } from "@tanstack/react-query";
|
||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import { persistQueryClientRestore } from "@tanstack/react-query-persist-client";
|
||||
import { parse, stringify } from "superjson";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createSessionQueryPersister,
|
||||
getQueryPersistenceStorageKey,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxDataBytes,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
|
||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKey, data, { updatedAt });
|
||||
const query = queryClient.getQueryCache().find({ queryKey });
|
||||
if (!query) throw new Error("Expected query to be present");
|
||||
return { query, queryClient };
|
||||
};
|
||||
|
||||
const createMemoryStorage = (): Storage => {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("widget query persistence", () => {
|
||||
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
|
||||
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
|
||||
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
|
||||
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
|
||||
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
||||
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
|
||||
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
|
||||
});
|
||||
const pendingClient = new QueryClient();
|
||||
const pending = pendingClient.getQueryCache().build(pendingClient, {
|
||||
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
});
|
||||
|
||||
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
|
||||
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
|
||||
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
||||
});
|
||||
|
||||
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
|
||||
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
|
||||
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
|
||||
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
|
||||
|
||||
const userAKey = getQueryPersistenceStorageKey("user-a");
|
||||
window.sessionStorage.setItem(userAKey, "a");
|
||||
createSessionQueryPersister("user-a").removeClient();
|
||||
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
||||
});
|
||||
|
||||
test("evicts every other scope so signing out cannot leave data behind", () => {
|
||||
const userAKey = getQueryPersistenceStorageKey("user-a");
|
||||
const anonymousKey = getQueryPersistenceStorageKey(null);
|
||||
window.sessionStorage.setItem(userAKey, "a");
|
||||
window.sessionStorage.setItem(anonymousKey, "anonymous");
|
||||
window.sessionStorage.setItem("unrelated", "kept");
|
||||
|
||||
createSessionQueryPersister("user-b");
|
||||
|
||||
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
||||
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
|
||||
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
|
||||
});
|
||||
|
||||
test("never restores another session scope", async () => {
|
||||
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
||||
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
|
||||
const userAPersister = createSessionQueryPersister("user-a");
|
||||
userAPersister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
userAPersister.flush();
|
||||
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
|
||||
|
||||
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("stops persisting once the provider tears the cache down", async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const persister = createSessionQueryPersister("user-a", storage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
||||
temp: 21,
|
||||
});
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
persister.flush();
|
||||
persister.stop();
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: { mutations: [], queries: [] },
|
||||
});
|
||||
persister.flush();
|
||||
|
||||
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("removes the cache instead of storing an empty one", () => {
|
||||
const scope = "user-a";
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
window.sessionStorage.setItem(key, "stale");
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: { mutations: [], queries: [] },
|
||||
});
|
||||
persister.flush();
|
||||
|
||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scope = "user-a";
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||
});
|
||||
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
|
||||
expect(shouldPersistDashboardQuery(query)).toBe(true);
|
||||
const persistedClient: PersistedClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
|
||||
};
|
||||
|
||||
persister.persistClient(persistedClient);
|
||||
persister.flush();
|
||||
const restored = await persister.restoreClient();
|
||||
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
|
||||
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
|
||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
||||
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
||||
|
||||
window.sessionStorage.setItem(key, "not-valid-superjson");
|
||||
expect(persister.restoreClient()).toBeUndefined();
|
||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
|
||||
const scope = "user-a";
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
const { queryClient } = createSuccessfulQuery(
|
||||
[["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||
[{ title: "safe" }],
|
||||
);
|
||||
const validQuery = dehydrate(queryClient).queries[0];
|
||||
if (!validQuery) throw new Error("Expected a dehydrated query");
|
||||
|
||||
window.sessionStorage.setItem(
|
||||
key,
|
||||
stringify({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: {
|
||||
mutations: [{ state: { status: "pending" } }],
|
||||
queries: [
|
||||
{
|
||||
...validQuery,
|
||||
state: {
|
||||
...validQuery.state,
|
||||
error: new Error("private refresh error"),
|
||||
fetchStatus: "fetching",
|
||||
status: "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
...validQuery,
|
||||
queryHash: "unrelated",
|
||||
queryKey: [["board", "getBoardByName"], { type: "query" }],
|
||||
},
|
||||
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
|
||||
{
|
||||
...validQuery,
|
||||
queryHash: "missing-timestamp",
|
||||
state: { ...validQuery.state, dataUpdatedAt: undefined },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const restored = await createSessionQueryPersister(scope).restoreClient();
|
||||
expect(restored?.clientState.mutations).toEqual([]);
|
||||
expect(restored?.clientState.queries).toHaveLength(1);
|
||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
|
||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["expired", 1, queryPersistenceBuster],
|
||||
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
|
||||
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
|
||||
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
|
||||
const scope = "user-a";
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
||||
temp: 21,
|
||||
});
|
||||
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
|
||||
persister.flush();
|
||||
|
||||
const restoredClient = new QueryClient();
|
||||
await persistQueryClientRestore({
|
||||
queryClient: restoredClient,
|
||||
persister,
|
||||
maxAge: 5 * 60 * 1_000,
|
||||
buster: queryPersistenceBuster,
|
||||
});
|
||||
|
||||
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
|
||||
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
|
||||
});
|
||||
|
||||
test("quota retry writes the cache after evicting the oldest query", async () => {
|
||||
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
|
||||
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
|
||||
const storage = createMemoryStorage();
|
||||
const setItem = storage.setItem.bind(storage);
|
||||
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
|
||||
const client = parse<PersistedClient>(value);
|
||||
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
|
||||
setItem(key, value);
|
||||
});
|
||||
const persistedClient: PersistedClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
|
||||
},
|
||||
};
|
||||
const persister = createSessionQueryPersister("user-a", storage);
|
||||
|
||||
persister.persistClient(persistedClient);
|
||||
persister.flush();
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledTimes(2);
|
||||
const restored = await persister.restoreClient();
|
||||
expect(restored?.clientState.queries).toHaveLength(1);
|
||||
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
|
||||
});
|
||||
|
||||
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
|
||||
vi.useFakeTimers();
|
||||
const storage = createMemoryStorage();
|
||||
const setItem = vi.spyOn(storage, "setItem");
|
||||
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
||||
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
|
||||
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
|
||||
const persister = createSessionQueryPersister("user-a", storage);
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(first.queryClient),
|
||||
});
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(latest.queryClient),
|
||||
});
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
persister.flush();
|
||||
expect(setItem).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(setItem).toHaveBeenCalledOnce();
|
||||
|
||||
const restored = await persister.restoreClient();
|
||||
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
|
||||
});
|
||||
|
||||
test("treats blocked storage operations as an empty cache", async () => {
|
||||
const blockedStorage = {
|
||||
getItem: () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
},
|
||||
} as unknown as Storage;
|
||||
const persister = createSessionQueryPersister("blocked", blockedStorage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
|
||||
|
||||
expect(persister.restoreClient()).toBeUndefined();
|
||||
expect(() => {
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
persister.flush();
|
||||
}).not.toThrow();
|
||||
expect(() => persister.removeClient()).not.toThrow();
|
||||
});
|
||||
|
||||
test("treats an unavailable session storage getter as an empty cache", async () => {
|
||||
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
});
|
||||
|
||||
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
|
||||
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
||||
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
||||
import { parse, stringify } from "superjson";
|
||||
|
||||
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||
|
||||
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
||||
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
||||
export const queryPersistenceBuster = "v2-session-superjson";
|
||||
|
||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||
|
||||
export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
||||
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
|
||||
|
||||
interface PersistableQuery {
|
||||
queryKey: QueryKey;
|
||||
state: {
|
||||
data: unknown;
|
||||
dataUpdatedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const budgetVerdictCache = new WeakMap<object, boolean>();
|
||||
|
||||
const measureFitsBudget = (data: unknown) => {
|
||||
try {
|
||||
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const fitsStorageBudget = (data: unknown) => {
|
||||
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
|
||||
|
||||
const cached = budgetVerdictCache.get(data);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const verdict = measureFitsBudget(data);
|
||||
budgetVerdictCache.set(data, verdict);
|
||||
return verdict;
|
||||
};
|
||||
|
||||
const holdsDashboardData = (query: PersistableQuery) =>
|
||||
query.state.data !== undefined &&
|
||||
Number.isFinite(query.state.dataUpdatedAt) &&
|
||||
query.state.dataUpdatedAt > 0 &&
|
||||
isWidgetDataQueryKey(query.queryKey);
|
||||
|
||||
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
|
||||
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
|
||||
|
||||
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
|
||||
|
||||
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
||||
...query,
|
||||
state: {
|
||||
...query.state,
|
||||
error: null,
|
||||
errorUpdateCount: 0,
|
||||
errorUpdatedAt: 0,
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
fetchStatus: "idle",
|
||||
isInvalidated: true,
|
||||
status: "success",
|
||||
},
|
||||
});
|
||||
|
||||
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
|
||||
...client,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: client.clientState.queries.map(sanitizePersistedQuery),
|
||||
},
|
||||
});
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
|
||||
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
|
||||
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
|
||||
|
||||
return holdsDashboardData(value as unknown as PersistableQuery);
|
||||
};
|
||||
|
||||
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
|
||||
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
|
||||
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
|
||||
|
||||
return {
|
||||
timestamp: value.timestamp as number,
|
||||
buster: value.buster,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getSessionStorage = (): Storage | undefined => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const removeStorageItem = (storage: Storage | undefined, key: string) => {
|
||||
try {
|
||||
storage?.removeItem(key);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
const foreignKeys: string[] = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const storedKey = storage.key(index);
|
||||
if (storedKey === null || storedKey === activeKey) continue;
|
||||
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
|
||||
}
|
||||
|
||||
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const createSessionQueryPersister = (
|
||||
scope: string | null,
|
||||
storage: Storage | undefined = getSessionStorage(),
|
||||
): SessionQueryPersister => {
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
removeForeignScopes(storage, key);
|
||||
let pendingClient: PersistedClient | undefined;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopped = false;
|
||||
|
||||
const persistNow = (client: PersistedClient) => {
|
||||
if (!storage) return;
|
||||
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
|
||||
let errorCount = 0;
|
||||
|
||||
if (candidate.clientState.queries.length === 0) {
|
||||
removeStorageItem(storage, key);
|
||||
return;
|
||||
}
|
||||
|
||||
while (candidate) {
|
||||
try {
|
||||
storage.setItem(key, stringify(candidate));
|
||||
return;
|
||||
} catch (error) {
|
||||
errorCount += 1;
|
||||
candidate = removeOldestQuery({
|
||||
persistedClient: candidate,
|
||||
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
|
||||
errorCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearPersistTimer = () => {
|
||||
if (persistTimer === undefined) return;
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
clearPersistTimer();
|
||||
const client = pendingClient;
|
||||
pendingClient = undefined;
|
||||
if (client) persistNow(client);
|
||||
};
|
||||
|
||||
return {
|
||||
persistClient(client) {
|
||||
if (!storage || stopped) return;
|
||||
pendingClient = client;
|
||||
if (persistTimer !== undefined) return;
|
||||
persistTimer = setTimeout(flush, 0);
|
||||
},
|
||||
restoreClient() {
|
||||
try {
|
||||
const storedValue = storage?.getItem(key);
|
||||
if (!storedValue) return undefined;
|
||||
|
||||
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
|
||||
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
|
||||
removeStorageItem(storage, key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return client;
|
||||
} catch {
|
||||
removeStorageItem(storage, key);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
removeClient() {
|
||||
clearPersistTimer();
|
||||
pendingClient = undefined;
|
||||
removeStorageItem(storage, key);
|
||||
},
|
||||
flush,
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearPersistTimer();
|
||||
pendingClient = undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
@@ -35,6 +36,13 @@ import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import {
|
||||
createSessionQueryPersister,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxAgeMs,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
import type { SessionQueryPersister } from "./query-persistence";
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const DevelopmentTools =
|
||||
@@ -67,21 +75,29 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
||||
const handleScopeChange = useCallback(() => {
|
||||
void queryPersister.removeClient();
|
||||
reloadPage();
|
||||
}, [queryPersister]);
|
||||
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
initialScope={initialSessionQueryScope}
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={reloadPage}
|
||||
onScopeChange={handleScopeChange}
|
||||
>
|
||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
||||
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
const ScopedTRPCReactProvider = ({
|
||||
children,
|
||||
queryPersister,
|
||||
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
@@ -136,6 +152,15 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
return client;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const flush = () => queryPersister.flush();
|
||||
window.addEventListener("pagehide", flush);
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", flush);
|
||||
flush();
|
||||
queryPersister.stop();
|
||||
};
|
||||
}, [queryPersister]);
|
||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||
|
||||
const trpcClient = useMemo(() => {
|
||||
@@ -186,10 +211,18 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: queryPersister,
|
||||
maxAge: queryPersistenceMaxAgeMs,
|
||||
buster: queryPersistenceBuster,
|
||||
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</clientApi.Provider>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
import { userRegistrationSchema } from "@homarr/validation/user";
|
||||
|
||||
@@ -19,7 +19,7 @@ interface RegistrationFormProps {
|
||||
}
|
||||
|
||||
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
||||
const t = useScopedI18n("user");
|
||||
const t = useI18n("user");
|
||||
const router = useRouter();
|
||||
const { mutate, isPending } = clientApi.user.register.useMutation();
|
||||
const form = useZodForm(userRegistrationSchema, {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { and, db, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { RegistrationForm } from "./_registration-form";
|
||||
|
||||
@@ -45,7 +45,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
|
||||
if (!invite || invite.expirationDate < new Date()) notFound();
|
||||
|
||||
const t = await getScopedI18n("user.page.invite");
|
||||
const t = await getI18n("user.page.invite");
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useDisclosure, useMounted } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
@@ -12,7 +12,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
import { userSignInSchema } from "@homarr/validation/user";
|
||||
|
||||
@@ -28,11 +28,10 @@ interface LoginFormProps {
|
||||
const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) });
|
||||
|
||||
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
|
||||
const t = useScopedI18n("user");
|
||||
const t = useI18n("user");
|
||||
const searchParams = useSearchParams();
|
||||
const isError = searchParams.has("error");
|
||||
const router = useRouter();
|
||||
const isMounted = useMounted();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const form = useZodForm(extendedValidation, {
|
||||
initialValues: {
|
||||
@@ -140,7 +139,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
|
||||
{providers.includes("credentials") && (
|
||||
<Stack gap="sm">
|
||||
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="credentials">
|
||||
<SubmitButton isPending={isPending} form={form} provider="credentials">
|
||||
{t("action.login.label")}
|
||||
</SubmitButton>
|
||||
<PasswordForgottenCollapse username={form.values.name} />
|
||||
@@ -148,7 +147,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("ldap") && (
|
||||
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="ldap">
|
||||
<SubmitButton isPending={isPending} form={form} provider="ldap">
|
||||
{t("action.login.labelWith", { provider: "LDAP" })}
|
||||
</SubmitButton>
|
||||
)}
|
||||
@@ -159,13 +158,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("oidc") && (
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
variant="light"
|
||||
disabled={!isMounted || isPending}
|
||||
onClick={() => void signInAsync("oidc")}
|
||||
>
|
||||
<Button type="button" fullWidth variant="light" disabled={isPending} onClick={() => void signInAsync("oidc")}>
|
||||
{t("action.login.labelWith", { provider: oidcClientName })}
|
||||
</Button>
|
||||
)}
|
||||
@@ -175,13 +168,12 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
};
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isMounted: boolean;
|
||||
isPending: boolean;
|
||||
form: UseFormReturnType<FormType>;
|
||||
provider: "credentials" | "ldap";
|
||||
}
|
||||
|
||||
const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||
const isCurrentProviderActive = form.getValues().provider === provider;
|
||||
|
||||
return (
|
||||
@@ -192,7 +184,7 @@ const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsW
|
||||
fullWidth
|
||||
onClick={() => form.setFieldValue("provider", provider)}
|
||||
loading={isPending && isCurrentProviderActive}
|
||||
disabled={!isMounted || (isPending && !isCurrentProviderActive)}
|
||||
disabled={isPending && !isCurrentProviderActive}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
@@ -204,7 +196,7 @@ interface PasswordForgottenCollapseProps {
|
||||
}
|
||||
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
|
||||
const [visible, { toggle }] = useDisclosure(false);
|
||||
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
|
||||
const tForgotPassword = useI18n("user.action.login.forgotPassword");
|
||||
|
||||
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconLogin } from "@tabler/icons-react";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
|
||||
import { env as appEnv } from "~/env";
|
||||
@@ -26,7 +26,7 @@ export default async function Login(props: LoginProps) {
|
||||
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("user.page.login");
|
||||
const t = await getI18n("user.page.login");
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { IconLayoutOff } from "@tabler/icons-react";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { BoardNotFound } from "~/components/board/not-found";
|
||||
|
||||
export default async function BoardNotFoundPage() {
|
||||
const tNotFound = await getScopedI18n("board.error.notFound");
|
||||
const tNotFound = await getI18n("board.error.notFound");
|
||||
return (
|
||||
<BoardNotFound
|
||||
icon={IconLayoutOff}
|
||||
|
||||
@@ -19,35 +19,11 @@
|
||||
align-self: start;
|
||||
box-sizing: border-box;
|
||||
height: var(--board-gutter-viewport-height);
|
||||
padding-top: 24px;
|
||||
margin-top: -24px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"] {
|
||||
outline: 1px dashed var(--mantine-color-default-border);
|
||||
outline-offset: -1px;
|
||||
outline: 0.0625rem solid var(--mantine-color-default-border);
|
||||
outline-offset: -0.0625rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"]::before {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
height: 24px;
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
margin-top: -24px;
|
||||
overflow: hidden;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
line-height: 24px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
content: attr(aria-label);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
@@ -1,34 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconBulb } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { FloatingTip } from "@homarr/ui";
|
||||
|
||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||
import { BoardEmptyState } from "~/components/board/board-empty-state";
|
||||
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalTrackSize,
|
||||
getLogicalGridSize,
|
||||
LOGICAL_GRID_GAP,
|
||||
getRootSectionForLane,
|
||||
} from "~/components/board/layout";
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
||||
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
|
||||
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
|
||||
import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
|
||||
import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
|
||||
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
|
||||
<GridEditorRegistryProvider>
|
||||
<BoardSelectionProvider>{children}</BoardSelectionProvider>
|
||||
</GridEditorRegistryProvider>
|
||||
);
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
const tPreview = useScopedI18n("board.setting.section.layout.preview");
|
||||
const t = useI18n("board.landmark");
|
||||
const tPreview = useI18n("board.setting.section.layout.preview");
|
||||
const tTips = useI18n("tips");
|
||||
const [isEditMode] = useEditMode();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const initialViewportWidth = useInitialViewportWidth();
|
||||
@@ -46,84 +61,98 @@ export const ClientBoard = () => {
|
||||
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
||||
.filter((columnCount) => columnCount > 0)
|
||||
.map(getLogicalTrackSize);
|
||||
.map(getLogicalGridSize);
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||
const gridTemplateColumns = [
|
||||
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalTrackSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
|
||||
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalGridSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
|
||||
]
|
||||
.filter((value) => value !== null)
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<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>
|
||||
)}
|
||||
<BoardSelectionGridProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
<BoardSelectionToolbar />
|
||||
<FloatingTip
|
||||
opened={isEditMode}
|
||||
showDelay={2_000}
|
||||
dismissAfter={3_000}
|
||||
transitionDuration={200}
|
||||
closable={false}
|
||||
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
|
||||
>
|
||||
{tTips("multiSelectApps")}
|
||||
</FloatingTip>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</BoardSelectionGridProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
@@ -104,10 +104,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
try {
|
||||
const board = await getInitialBoard(await params);
|
||||
const t = await getI18n();
|
||||
const t = await getI18n("board");
|
||||
|
||||
return {
|
||||
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
||||
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
|
||||
icons: {
|
||||
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
@@ -27,7 +29,24 @@ const AddMenu = () => {
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const tIntegration = useI18n("integration");
|
||||
const tContainer = useI18n("section.container");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const handledInitialAdd = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
||||
handledInitialAdd.current = true;
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("add");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [board.id, openItemSelectModal, pathname, router, searchParams]);
|
||||
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
@@ -41,18 +60,26 @@ const AddMenu = () => {
|
||||
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({});
|
||||
openIntegrationSelectModal({ completionBoardId: board.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<HeaderButton w="auto" px={4} aria-label={tBoard("action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
@@ -61,17 +88,19 @@ const AddMenu = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
{tBoard("emptyState.addWidget")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
{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}>
|
||||
{t("section.container.action.create")}
|
||||
{tContainer("action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -3,17 +3,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconReplace,
|
||||
IconSettings,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
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";
|
||||
@@ -23,9 +15,7 @@ import { env } from "@homarr/common/env";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
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 { HeaderButton } from "~/components/layout/header/button";
|
||||
@@ -52,12 +42,10 @@ const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
return <SelectBoardsMenu />;
|
||||
}
|
||||
if (!hasChangeAccess) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,13 +55,11 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
{!demoReadOnly && (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -87,8 +73,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const commonT = useI18n();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
const tCommon = useI18n("common");
|
||||
const tBoardSetting = useI18n("board.setting");
|
||||
const t = useI18n("board.action.edit");
|
||||
const returnToLayoutSettings =
|
||||
isEditMode &&
|
||||
searchParams.get("edit") === "true" &&
|
||||
@@ -98,9 +85,12 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
|
||||
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: t("notification.success.title"),
|
||||
title: tCommon("notification.update.success"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
@@ -111,7 +101,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
title: tCommon("notification.update.error"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
@@ -174,9 +164,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
|
||||
const cancelLayoutEdit = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: commonT("board.setting.section.layout.edit.cancel.title"),
|
||||
children: commonT("board.setting.section.layout.edit.cancel.message"),
|
||||
confirmProps: { children: commonT("common.action.discard"), color: "red" },
|
||||
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 });
|
||||
@@ -185,7 +175,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
})();
|
||||
},
|
||||
});
|
||||
}, [board.name, close, commonT, openConfirmModal, router, utils.board.getBoardByName]);
|
||||
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
@@ -196,14 +186,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
<HeaderButton
|
||||
onClick={() => saveBoard(board)}
|
||||
loading={isPending}
|
||||
aria-label={commonT("board.setting.section.layout.edit.save")}
|
||||
aria-label={tBoardSetting("section.layout.edit.save")}
|
||||
>
|
||||
<IconDeviceFloppy stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<HeaderButton
|
||||
onClick={cancelLayoutEdit}
|
||||
disabled={isPending}
|
||||
aria-label={commonT("board.setting.section.layout.edit.cancel.action")}
|
||||
aria-label={tBoardSetting("section.layout.edit.cancel.action")}
|
||||
>
|
||||
<IconX stroke={1.5} />
|
||||
</HeaderButton>
|
||||
@@ -230,50 +220,10 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const t = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
||||
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
|
||||
|
||||
return (
|
||||
<TourTarget id="board-switcher">
|
||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{isPending && (
|
||||
<Center p="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const anchorSelector = "a[href]:not([target='_blank'])";
|
||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -289,13 +239,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
event.stopPropagation();
|
||||
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
children: t("board.action.edit.confirmLeave.message"),
|
||||
title: tBoard("action.edit.confirmLeave.title"),
|
||||
children: tBoard("action.edit.confirmLeave.message"),
|
||||
onConfirm() {
|
||||
router.push(target.href);
|
||||
},
|
||||
confirmProps: {
|
||||
children: t("common.action.discard"),
|
||||
children: tCommon("action.discard"),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -321,5 +271,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [isDirty, openConfirmModal, router, t]);
|
||||
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
|
||||
};
|
||||
@@ -16,15 +16,15 @@ export default async function NotFoundBoardHomePage() {
|
||||
|
||||
const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
|
||||
const boardCount = await db.$count(boards);
|
||||
const t = await getI18n();
|
||||
const t = await getI18n("board");
|
||||
|
||||
if (boardCount === 0) {
|
||||
return {
|
||||
icon: { src: "/favicon.ico", alt: "Homarr logo" },
|
||||
title: t("board.error.noBoard.title"),
|
||||
description: t("board.error.noBoard.description"),
|
||||
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
|
||||
notice: t("board.error.noBoard.notice"),
|
||||
title: t("error.noBoard.title"),
|
||||
description: t("error.noBoard.description"),
|
||||
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
|
||||
notice: t("error.noBoard.notice"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
|
||||
|
||||
return {
|
||||
icon: IconHomeOff,
|
||||
title: t(`board.error.homeBoard.title`),
|
||||
description: t(`board.error.homeBoard.${type}.description`),
|
||||
link: { label: t(`board.error.homeBoard.${type}.link`), href },
|
||||
notice: t(`board.error.homeBoard.${type}.notice`),
|
||||
title: t("error.homeBoard.title"),
|
||||
description: t(`error.homeBoard.${type}.description` as never),
|
||||
link: { label: t(`error.homeBoard.${type}.link` as never), href },
|
||||
notice: t(`error.homeBoard.${type}.notice` as never),
|
||||
};
|
||||
};
|
||||
@@ -37,22 +37,23 @@ const progressPercentageLabel = (value: number) => `${value}%`;
|
||||
|
||||
export const ColorSettingsContent = ({ form }: Props) => {
|
||||
const [showPreview, { toggle }] = useDisclosure(false);
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.appearance.title")}>
|
||||
<SectionCard title={tBoard("setting.section.appearance.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<BoardColorInput label={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<BoardColorInput label={t("board.field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
|
||||
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
<Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
@@ -63,7 +64,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<InputWrapper label={tBoard("field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
@@ -77,7 +78,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
label={tBoard("field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
@@ -90,20 +91,20 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{t("board.field.clearColor.label")}
|
||||
{tBoard("field.clearColor.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
label={tBoard("field.itemRadius.label")}
|
||||
description={tBoard("field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
{ 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")}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
|
||||
@@ -44,7 +44,7 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.background.title")}>
|
||||
<SectionCard title={tBoard("setting.section.background.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
@@ -67,8 +67,8 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
label={tBoard("field.backgroundImageUrl.label")}
|
||||
placeholder={`${tBoard("field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
@@ -87,13 +87,13 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
group: tBoard("field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
group: tBoard("field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
@@ -123,21 +123,21 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
label={tBoard("field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
label={tBoard("field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
label={tBoard("field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
@@ -176,19 +176,20 @@ const useBackgroundOptionData = <
|
||||
defaultValue: keyof TOptions;
|
||||
},
|
||||
) => {
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
|
||||
return data.values.map(
|
||||
(value) =>
|
||||
({
|
||||
label: t(`board.field.${key}.option.${value as string}.label` as never),
|
||||
description: t(`board.field.${key}.option.${value as string}.description` as never),
|
||||
label: tBoard(`field.${key}.option.${value as string}.label` as never),
|
||||
description: tBoard(`field.${key}.option.${value as string}.description` as never),
|
||||
value: value as string,
|
||||
badge:
|
||||
data.defaultValue === value
|
||||
? {
|
||||
color: "blue",
|
||||
label: t("common.select.badge.recommended"),
|
||||
label: tCommon("select.badge.recommended"),
|
||||
}
|
||||
: undefined,
|
||||
}) satisfies SelectItemWithDescriptionBadge,
|
||||
|
||||
@@ -13,13 +13,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export const BehaviorSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("board");
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.behavior.title")}>
|
||||
<SectionCard title={t("setting.section.behavior.title")}>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
label={t("field.disableStatus.label")}
|
||||
description={t("field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
|
||||
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
|
||||
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
|
||||
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
||||
{
|
||||
@@ -52,7 +52,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
|
||||
mutate: userMutation.mutate,
|
||||
isPending: userMutation.isPending,
|
||||
}}
|
||||
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
|
||||
translate={(key) => tBoard(`setting.section.access.permission.item.${key}.label` as never)}
|
||||
permission={{
|
||||
items: boardPermissions,
|
||||
default: "view",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
|
||||
@@ -24,11 +24,11 @@ interface Props {
|
||||
export const CustomCssSettingsContent = ({ form }: Props) => {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const t = useI18n("board");
|
||||
const customCssT = useI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.customCss.title")}>
|
||||
<SectionCard title={t("setting.section.customCss.title")}>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
@@ -46,7 +46,7 @@ interface CustomCssInputProps {
|
||||
}
|
||||
|
||||
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const customCssT = useI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
|
||||
@@ -7,14 +7,15 @@ import { Button } from "@mantine/core";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
|
||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.setting");
|
||||
const t = useI18n("board.setting");
|
||||
const tCommonAction = useI18n("common.action");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal } = useModalAction(BoardRenameModal);
|
||||
@@ -104,11 +105,11 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
/>
|
||||
)}
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.delete.label")}
|
||||
label={tCommonAction("delete")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||
{t("section.dangerZone.action.delete.button")}
|
||||
{tCommonAction("delete")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -20,25 +20,21 @@ interface Props {
|
||||
}
|
||||
|
||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("board");
|
||||
|
||||
useLogoPreview(form.values.logoImageUrl);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.general.title")}>
|
||||
<SectionCard title={t("setting.section.general.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.pageTitle.label")}
|
||||
placeholder="Homarr"
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||
label={t("field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
@@ -46,7 +42,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
label={t("field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
@@ -54,7 +50,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
label={t("field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
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 } from "@homarr/definitions";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
@@ -23,6 +23,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tSection = useI18n("section");
|
||||
const t = useI18n();
|
||||
const elements = projectBoardLayout(board, sourceLayout, layout);
|
||||
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
|
||||
@@ -42,13 +44,13 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
|
||||
{representativeWidth}px
|
||||
</Badge>
|
||||
<Text size="xs">
|
||||
{layout.columnCount} {t("board.setting.section.layout.preview.columns")}
|
||||
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
|
||||
</Text>
|
||||
</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">
|
||||
{t("board.setting.section.layout.preview.empty")}
|
||||
{tBoard("setting.section.layout.preview.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
@@ -76,8 +78,8 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
|
||||
const containerLabel =
|
||||
container?.kind === "container" && container.options.title
|
||||
? container.options.title
|
||||
: t("section.container.untitled");
|
||||
const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : containerLabel);
|
||||
: tSection("container.untitled");
|
||||
const label = app?.name ?? (item ? getWidgetName(item.kind, t) : containerLabel);
|
||||
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,7 +41,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
|
||||
const t = useI18n();
|
||||
const tBoard = useI18n("board");
|
||||
const tLayout = useI18n("layout");
|
||||
const tCommon = useI18n("common");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -50,8 +52,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
title: tCommon("notification.update.error"),
|
||||
message: tCommon("notification.update.error"),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -84,15 +86,14 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
|
||||
const confirmReset = (layout: FormValues["layouts"][number]) => {
|
||||
openConfirmModal({
|
||||
title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
|
||||
children: t("board.setting.section.layout.reset.confirm.message"),
|
||||
confirmProps: { children: t("board.setting.section.layout.reset.action"), color: "red" },
|
||||
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
|
||||
children: tBoard("setting.section.layout.reset.confirm.message"),
|
||||
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
|
||||
onConfirm() {
|
||||
setResettingLayoutId(layout.id);
|
||||
void (async () => {
|
||||
try {
|
||||
const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values;
|
||||
const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
|
||||
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
if (!canonicalLayout) return;
|
||||
|
||||
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
|
||||
@@ -110,17 +111,14 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
|
||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||
const sortedLayouts = form.values.layouts
|
||||
.map((layout, index) => ({ layout, index }))
|
||||
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.layout.title")}>
|
||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2} maw="52rem">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Text size="sm">{t("board.setting.section.layout.responsive.description")}</Text>
|
||||
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
|
||||
<Text size="sm">{tBoard("setting.section.layout.responsive.description")}</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -128,25 +126,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
disabled={nextBreakpoint === null || !baseLayout}
|
||||
onClick={() => {
|
||||
if (nextBreakpoint === null || !baseLayout) return;
|
||||
form.setFieldValue("layouts", [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: t("board.setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
breakpoint: nextBreakpoint,
|
||||
role: "custom",
|
||||
},
|
||||
]);
|
||||
const newLayout: FormValues["layouts"][number] = {
|
||||
id: createId(),
|
||||
name: tBoard("setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
breakpoint: nextBreakpoint,
|
||||
role: "custom",
|
||||
};
|
||||
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
|
||||
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
|
||||
form.insertListItem("layouts", newLayout, insertionIndex);
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
{tBoard("setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{sortedLayouts.map(({ layout, index }) => {
|
||||
{form.values.layouts.map((layout, index) => {
|
||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||
const customBreakpoints = form.values.layouts
|
||||
@@ -159,9 +157,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
key={layout.id}
|
||||
legend={
|
||||
<Group gap="xs">
|
||||
<Text>{layout.name || t(`board.setting.section.layout.role.${layout.role}`)}</Text>
|
||||
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
|
||||
<Badge size="sm" variant="default">
|
||||
{t(`board.setting.section.layout.role.${layout.role}`)}
|
||||
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
@@ -170,10 +168,10 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
<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={t("layout.field.name.label")} />
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
|
||||
<Input.Wrapper label={tLayout("field.columnCount.label")}>
|
||||
<Slider
|
||||
thumbLabel={`${t("layout.field.columnCount.label")} — ${layout.name}`}
|
||||
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
@@ -194,11 +192,11 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
</Input.Wrapper>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
label={tLayout("field.breakpoint.label")}
|
||||
description={
|
||||
layout.role === "mobile"
|
||||
? t("board.setting.section.layout.mobile.breakpointDescription")
|
||||
: t("layout.field.breakpoint.description")
|
||||
? tBoard("setting.section.layout.mobile.breakpointDescription")
|
||||
: tLayout("field.breakpoint.description")
|
||||
}
|
||||
disabled={layout.role === "mobile"}
|
||||
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
||||
@@ -246,7 +244,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
disabled={isSaving}
|
||||
onClick={() => confirmReset(layout)}
|
||||
>
|
||||
{t("board.setting.section.layout.reset.action")}
|
||||
{tBoard("setting.section.layout.reset.action")}
|
||||
</Button>
|
||||
)}
|
||||
{layout.role === "custom" && (
|
||||
@@ -262,7 +260,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
{tCommon("action.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -275,8 +273,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
onClick={() => void openLayoutEditorAsync(layout)}
|
||||
>
|
||||
{form.isDirty() || !persistedLayout
|
||||
? t("board.setting.section.layout.edit.saveAndEdit")
|
||||
: t("board.setting.section.layout.edit.action")}
|
||||
? tBoard("setting.section.layout.edit.saveAndEdit")
|
||||
: tBoard("setting.section.layout.edit.action")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Fieldset>
|
||||
@@ -322,16 +320,16 @@ const gutterMarks = [
|
||||
];
|
||||
|
||||
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("layout");
|
||||
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
|
||||
|
||||
return (
|
||||
<Paper className={classes.gutterSettings} p="md">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={600}>{t("layout.field.gutters.label")}</Text>
|
||||
<Text fw={600}>{t("field.gutters.label")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("layout.field.gutters.description")}
|
||||
{t("field.gutters.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -371,7 +369,7 @@ const GutterControl = ({
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("layout");
|
||||
const enabled = value > 0;
|
||||
|
||||
return (
|
||||
@@ -380,10 +378,10 @@ const GutterControl = ({
|
||||
checked={enabled}
|
||||
disabled={max < 1}
|
||||
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
|
||||
label={t(`layout.field.gutters.${side}.label`)}
|
||||
label={t(`field.gutters.${side}.label` as never)}
|
||||
/>
|
||||
{enabled && (
|
||||
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
|
||||
<Input.Wrapper label={t("field.gutters.width.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
@@ -391,7 +389,7 @@ const GutterControl = ({
|
||||
value={Math.min(value, max)}
|
||||
marks={gutterMarks.filter((mark) => mark.value <= max)}
|
||||
restrictToMarks
|
||||
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
|
||||
thumbLabel={t(`field.gutters.${side}.thumbLabel` as never)}
|
||||
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
@@ -401,7 +399,7 @@ const GutterControl = ({
|
||||
};
|
||||
|
||||
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("layout");
|
||||
const safeColumnCount = Math.max(1, columnCount);
|
||||
const main = Math.max(1, safeColumnCount - left - right);
|
||||
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
|
||||
@@ -411,7 +409,7 @@ const GutterPreview = ({ left, right, columnCount }: { left: number; right: numb
|
||||
return (
|
||||
<Box
|
||||
className={classes.gutterPreview}
|
||||
aria-label={t("layout.field.gutters.preview.label")}
|
||||
aria-label={t("field.gutters.preview.label")}
|
||||
style={{ gridTemplateColumns: tracks }}
|
||||
>
|
||||
{left > 0 && <Box className={classes.previewGutter} data-side="left" />}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
@@ -86,8 +86,8 @@ interface BoardSettingsFormProps {
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSection = useScopedI18n("board.setting.section");
|
||||
const t = useI18n("common");
|
||||
const tSection = useI18n("board.setting.section");
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||
const saveLayouts = useSaveLayoutsMutation(board);
|
||||
@@ -156,14 +156,14 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
form.resetDirty(canonicalValues);
|
||||
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("common.notification.update.success"),
|
||||
title: t("notification.update.success"),
|
||||
message: t("notification.update.success"),
|
||||
});
|
||||
return canonicalValues;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
title: t("notification.update.error"),
|
||||
message: t("notification.update.error"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -200,10 +200,10 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
{t("action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
{t("action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api } from "@homarr/api/server";
|
||||
import { capitalize } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { BoardSettingsForm } from "./_settings-form";
|
||||
@@ -46,7 +46,7 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
|
||||
const t = await getScopedI18n("board.setting");
|
||||
const t = await getI18n("board.setting");
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
notFound();
|
||||
|
||||
+4
-3
@@ -16,7 +16,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface RequestDetails {
|
||||
widgetName: string;
|
||||
@@ -31,7 +31,8 @@ interface RequestDetails {
|
||||
}
|
||||
|
||||
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
||||
const t = useScopedI18n("customWidget.secretEntry");
|
||||
const t = useI18n("customWidget.secretEntry");
|
||||
const tSecret = useI18n("customWidget.secret");
|
||||
const [details, setDetails] = useState<RequestDetails | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
@@ -120,7 +121,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
||||
return (
|
||||
<Input
|
||||
key={kind}
|
||||
label={t(`field.${kind}`)}
|
||||
label={tSecret(kind)}
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={values[kind] ?? ""}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
|
||||
|
||||
/**
|
||||
* Session-aware wrappers around the shared management access rules in `@homarr/definitions`, so the
|
||||
* navigation and the pages resolve access exactly the same way the API does.
|
||||
*/
|
||||
|
||||
export const getAppsSectionAccess = (session: Session | null) =>
|
||||
getAppManagementAccess(session?.user.permissions ?? []);
|
||||
|
||||
/**
|
||||
* Pure variant for callers that already fetched the integrations the user can see, so the list is
|
||||
* only queried once per request.
|
||||
*/
|
||||
export const getIntegrationsSectionAccess = (
|
||||
session: Session | null,
|
||||
visibleIntegrations: { permissions: { hasFullAccess: boolean } }[],
|
||||
) =>
|
||||
getIntegrationManagementAccess(
|
||||
session?.user.permissions ?? [],
|
||||
visibleIntegrations.some((integration) => integration.permissions.hasFullAccess),
|
||||
);
|
||||
|
||||
export const getIntegrationsSectionAccessAsync = async (session: Session | null) => {
|
||||
const permissions = session?.user.permissions ?? [];
|
||||
|
||||
// Only pay for the lookup when the answer is not already known from the global permissions.
|
||||
const needsDelegatedLookup =
|
||||
session !== null && !permissions.includes("integration-full-all") && !permissions.includes("integration-create");
|
||||
|
||||
return getIntegrationsSectionAccess(session, needsDelegatedLookup ? await api.integration.all() : []);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Box, Group, Image, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { IntegrationMarquee } from "@homarr/ui";
|
||||
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
export const HeroBanner = async () => {
|
||||
const t = await getScopedI18n("management.page.home");
|
||||
const t = await getI18n("management.page.home");
|
||||
|
||||
return (
|
||||
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
|
||||
import { capitalize, objectEntries } from "@homarr/common";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
@@ -47,7 +47,7 @@ import crowdinContributors from "@static-data/translators.json";
|
||||
import classes from "./about.module.css";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
@@ -55,7 +55,7 @@ export async function generateMetadata() {
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const t = await getI18n("management.page.about");
|
||||
const version = getPackageVersion();
|
||||
const dependencies = await getDependenciesAsync();
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface AppDeleteButtonProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
}
|
||||
|
||||
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
const t = useScopedI18n("app.page.delete");
|
||||
const tList = useScopedI18n("app.page.list.action");
|
||||
const t = useI18n("app.page.delete");
|
||||
const tCommon = useI18n("common");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
||||
|
||||
@@ -33,14 +33,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
title: tCommon("notification.delete.success"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
title: tCommon("notification.delete.error"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
@@ -48,7 +48,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [app, mutate, t, openConfirmModal]);
|
||||
}, [app, mutate, t, tCommon, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
@@ -57,7 +57,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tList("delete", { name: app.name })}
|
||||
aria-label={tCommon("action.deleteNamed", { name: app.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
interface AppEditFormProps {
|
||||
@@ -17,14 +17,14 @@ interface AppEditFormProps {
|
||||
}
|
||||
|
||||
export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
const tScoped = useScopedI18n("app.page.edit.notification");
|
||||
const t = useI18n();
|
||||
const tScoped = useI18n("app.page.edit.notification");
|
||||
const tCommon = useI18n("common");
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, isPending } = clientApi.app.update.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: tScoped("success.title"),
|
||||
title: tCommon("notification.update.success"),
|
||||
message: tScoped("success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps").then(() => {
|
||||
@@ -33,7 +33,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tScoped("error.title"),
|
||||
title: tCommon("notification.update.error"),
|
||||
message: tScoped("error.message"),
|
||||
});
|
||||
},
|
||||
@@ -52,7 +52,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
return (
|
||||
<AppForm
|
||||
buttonLabels={{
|
||||
submit: t("common.action.save"),
|
||||
submit: tCommon("action.save"),
|
||||
}}
|
||||
initialValues={app}
|
||||
handleSubmit={handleSubmit}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -9,13 +9,14 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getSafeAppHref } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getAppsSectionAccess } from "../_access";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
@@ -41,21 +42,33 @@ interface AppsPageProps {
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/auth/login");
|
||||
const { canManageAll, canCreate, canAccess } = getAppsSectionAccess(session);
|
||||
if (!canAccess) notFound();
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||
const t = await getScopedI18n("app");
|
||||
const canCreate = session.user.permissions.includes("app-create");
|
||||
const canModify = session.user.permissions.includes("app-modify-all");
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
const hasSearch = Boolean(searchParams.search?.trim());
|
||||
// Without app-modify-all the user may add apps but not browse the ones they cannot manage,
|
||||
// so the list stays empty instead of leaking every app name and URL.
|
||||
const { items: apps, totalCount } = canManageAll
|
||||
? await api.app.getPaginated(searchParams)
|
||||
: { items: [], totalCount: 0 };
|
||||
const t = await getI18n("app");
|
||||
const tCommon = await getI18n("common");
|
||||
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
|
||||
|
||||
const emptyState = hasSearch ? (
|
||||
const emptyState = !canManageAll ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.createOnlyTitle")}
|
||||
description={t("page.list.noResults.createOnlyDescription")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new" }}
|
||||
/>
|
||||
) : hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }}
|
||||
action={{ label: tCommon("action.clearSearch"), href: "/manage/apps" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
@@ -68,7 +81,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
|
||||
const page = (
|
||||
<ManageCollectionPage
|
||||
title={t("page.list.title")}
|
||||
title={tCommon("entity.apps")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={apps.length}
|
||||
emptyState={emptyState}
|
||||
@@ -82,12 +95,14 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
) : undefined
|
||||
}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
canManageAll ? (
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
@@ -100,9 +115,8 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
<AppItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
canModify={canModify}
|
||||
canDelete={canDelete}
|
||||
editLabel={t("page.list.action.edit", { name: app.name })}
|
||||
editLabel={tCommon("action.editNamed", { name: app.name })}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
@@ -113,12 +127,11 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
|
||||
interface AppItemProps {
|
||||
app: RouterOutputs["app"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
editLabel: string;
|
||||
}
|
||||
|
||||
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
|
||||
const descriptionLines = app.description?.split("\n");
|
||||
const safeHref = getSafeAppHref(app.href);
|
||||
|
||||
@@ -157,23 +170,19 @@ const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canModify || canDelete ? (
|
||||
<ActionIconGroup>
|
||||
{canModify && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
+7
-6
@@ -51,7 +51,7 @@ import {
|
||||
} from "@homarr/definitions";
|
||||
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
|
||||
|
||||
@@ -140,7 +140,8 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => (
|
||||
);
|
||||
|
||||
export const AssistantConfiguration = () => {
|
||||
const t = useScopedI18n("management.page.settings.section.assistant");
|
||||
const t = useI18n("management.page.settings.section.assistant");
|
||||
const tCommon = useI18n("common");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
|
||||
@@ -208,7 +209,7 @@ export const AssistantConfiguration = () => {
|
||||
.filter((providerId) => assistantProviderPresets[providerId].category === category)
|
||||
.map((providerId) => ({
|
||||
value: providerId,
|
||||
label: t(`provider.options.${providerId}.label`),
|
||||
label: assistantProviderPresets[providerId].label ?? t("provider.options.custom.label"),
|
||||
}));
|
||||
|
||||
return { group: t(`provider.groups.${category}`), items };
|
||||
@@ -372,7 +373,7 @@ export const AssistantConfiguration = () => {
|
||||
</ThemeIcon>
|
||||
<Box className={classes.summaryProviderText}>
|
||||
<Group gap="xs">
|
||||
<Text fw={650}>{t(`provider.options.${provider}.label`)}</Text>
|
||||
<Text fw={650}>{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
@@ -418,7 +419,7 @@ export const AssistantConfiguration = () => {
|
||||
description={t("connection.description")}
|
||||
status={
|
||||
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
|
||||
{t(`provider.options.${provider}.label`)}
|
||||
{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
@@ -758,7 +759,7 @@ export const AssistantConfiguration = () => {
|
||||
{t("serverTools.webSearch.label")}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{t("serverTools.beta")}
|
||||
{tCommon("beta")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" maw="66ch">
|
||||
|
||||
@@ -2,13 +2,13 @@ import { notFound } from "next/navigation";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { AssistantConfiguration } from "./_components/assistant-configuration";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management.page.settings.section.assistant");
|
||||
const t = await getI18n("management.page.settings.section.assistant");
|
||||
return {
|
||||
title: `${t("title")} • Homarr`,
|
||||
};
|
||||
@@ -21,7 +21,7 @@ export default async function AssistantPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.settings.section.assistant");
|
||||
const t = await getI18n("management.page.settings.section.assistant");
|
||||
return (
|
||||
<ManagePageLayout title={t("title")}>
|
||||
<Text c="dimmed" maw="70ch">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
@@ -28,8 +28,9 @@ interface BoardCardMenuDropdownProps {
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||
const t = useScopedI18n("management.page.board.action");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const t = useI18n("management.page.board.action");
|
||||
const tRoot = useI18n();
|
||||
const tCommon = useI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: session } = useSession();
|
||||
@@ -57,7 +58,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
|
||||
const handleDeletion = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("delete.confirm.title"),
|
||||
title: tCommon("action.deleteNamed", { name: board.name }),
|
||||
children: t("delete.confirm.description", {
|
||||
name: board.name,
|
||||
}),
|
||||
@@ -68,7 +69,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]);
|
||||
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
|
||||
|
||||
const handleSetHomeBoard = useCallback(async () => {
|
||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
@@ -97,7 +98,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
{t("duplicate.label")}
|
||||
{tRoot("board.action.duplicate.title")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{hasChangeAccess && (
|
||||
@@ -122,7 +123,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
onClick={handleDeletion}
|
||||
disabled={deleteBoardMutation.isPending}
|
||||
>
|
||||
{t("delete.label")}
|
||||
{tCommon("action.delete")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,13 +10,13 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
const t = useI18n("management.page.board.action");
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
|
||||
return (
|
||||
<ManageMobilePrimaryAction>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
{t("new.label")}
|
||||
</Button>
|
||||
</ManageMobilePrimaryAction>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
@@ -36,7 +36,7 @@ import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
import { CreateBoardButton } from "./_components/create-board-button";
|
||||
|
||||
export default async function ManageBoardsPage() {
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
const t = await getI18n("management.page.board");
|
||||
const session = await auth();
|
||||
const boards = await api.board.getManageOverview();
|
||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||
@@ -54,7 +54,12 @@ export default async function ManageBoardsPage() {
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && (
|
||||
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
|
||||
<NoResults
|
||||
icon={IconLayoutDashboard}
|
||||
title={t("noResults.title")}
|
||||
description={t("noResults.description")}
|
||||
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
|
||||
/>
|
||||
)}
|
||||
{boards.length > 0 && (
|
||||
<TourTarget id="manage-boards-list">
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
const t = useScopedI18n("customWidget.betaBanner");
|
||||
const t = useI18n("customWidget.betaBanner");
|
||||
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
|
||||
@@ -1,48 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
|
||||
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
|
||||
|
||||
export function CodeEditor(props: CodeEditorProps) {
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
return (
|
||||
<CustomWidgetCodeEditor
|
||||
{...props}
|
||||
messages={{
|
||||
languageJsx: t("language.jsx"),
|
||||
languageJson: t("language.json"),
|
||||
undo: t("action.undo"),
|
||||
redo: t("action.redo"),
|
||||
components: t("action.components"),
|
||||
componentSearch: t("componentReference.search"),
|
||||
componentEmpty: t("componentReference.empty"),
|
||||
componentCount: (count) => t("componentReference.count", { count }),
|
||||
insertStarter: t("action.insertStarter"),
|
||||
format: t("action.format"),
|
||||
copy: t("action.copy"),
|
||||
copied: t("action.copied"),
|
||||
schema: t("action.schema"),
|
||||
schemaTab: t("reference.schema"),
|
||||
minimalTab: t("reference.minimal"),
|
||||
fullTab: t("reference.full"),
|
||||
errors: (count) => t("status.errors", { count }),
|
||||
warnings: (count) => t("status.warnings", { count }),
|
||||
ready: t("status.ready"),
|
||||
position: (cursor) => t("status.position", cursor),
|
||||
characters: (count, limit) =>
|
||||
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
|
||||
diagnosticsTitle: t("diagnostics.title"),
|
||||
diagnostic: (diagnostic) =>
|
||||
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`,
|
||||
}}
|
||||
/>
|
||||
const t = useI18n("customWidget.editor");
|
||||
const tCommon = useI18n("common");
|
||||
const messages = useMemo<SharedEditorProps["messages"]>(
|
||||
() => ({
|
||||
languageJsx: invariantTechnicalLabels.jsx,
|
||||
languageJson: invariantTechnicalLabels.json,
|
||||
undo: tCommon("action.undo"),
|
||||
redo: t("action.redo"),
|
||||
components: t("action.components"),
|
||||
componentSearch: t("componentReference.search"),
|
||||
componentEmpty: t("componentReference.empty"),
|
||||
componentCount: (count) => t("componentReference.count", { count }),
|
||||
insertStarter: t("action.insertStarter"),
|
||||
format: t("action.format"),
|
||||
copy: tCommon("action.copy"),
|
||||
copied: t("action.copied"),
|
||||
schema: t("action.schema"),
|
||||
schemaTab: invariantTechnicalLabels.jsonSchema,
|
||||
minimalTab: t("reference.minimal"),
|
||||
fullTab: t("reference.full"),
|
||||
errors: (count) => t("status.errors", { count }),
|
||||
warnings: (count) => t("status.warnings", { count }),
|
||||
ready: t("status.ready"),
|
||||
position: (cursor) => t("status.position", cursor),
|
||||
characters: (count, limit) =>
|
||||
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
|
||||
diagnosticsTitle: t("diagnostics.title"),
|
||||
diagnostic: (diagnostic) =>
|
||||
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`,
|
||||
}),
|
||||
[t, tCommon],
|
||||
);
|
||||
return <CustomWidgetCodeEditor {...props} messages={messages} />;
|
||||
}
|
||||
@@ -3,37 +3,70 @@
|
||||
import { ActionIcon, Group, Tooltip } from "@mantine/core";
|
||||
import { IconCopy, IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { buildCustomWidgetAiPrompt, buildCustomWidgetAssistantPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
interface CopyAiPromptButtonBaseProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
request?: string | null;
|
||||
documentationUrl?: string | null;
|
||||
}
|
||||
|
||||
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
|
||||
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
|
||||
(
|
||||
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
|
||||
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
|
||||
);
|
||||
|
||||
export const CopyAiPromptButton = ({
|
||||
rawResponse,
|
||||
currentConfig,
|
||||
draft,
|
||||
getDraft,
|
||||
diagnostics,
|
||||
getDiagnostics,
|
||||
request,
|
||||
documentationUrl,
|
||||
}: CopyAiPromptButtonProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const t = useI18n("customWidget");
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
|
||||
|
||||
const readDraft = () => {
|
||||
if (getDraft) return getDraft();
|
||||
return draft;
|
||||
};
|
||||
const readDiagnostics = () => {
|
||||
if (getDiagnostics) return getDiagnostics();
|
||||
return diagnostics;
|
||||
};
|
||||
|
||||
const handleAssistant = () => {
|
||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return;
|
||||
const prompt = buildCustomWidgetAssistantPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
readDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
readDiagnostics(),
|
||||
);
|
||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
|
||||
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
const value = prompt();
|
||||
const value = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
readDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
readDiagnostics(),
|
||||
);
|
||||
await navigator.clipboard.writeText(value);
|
||||
showSuccessNotification({
|
||||
title: t("action.copyAiPrompt"),
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
IconSparkles,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
@@ -20,10 +22,11 @@ import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } fr
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
|
||||
import { downloadJson } from "~/components/custom-widgets/download";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -36,16 +39,15 @@ interface WidgetRef {
|
||||
}
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const t = useI18n("customWidget");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [publishOpened, publishControls] = useDisclosure(false);
|
||||
const [migrationOpened, migrationControls] = useDisclosure(false);
|
||||
const migrationFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
|
||||
const copyMigrationPrompt = async () => {
|
||||
try {
|
||||
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
|
||||
@@ -62,30 +64,41 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const copyLegacyDefinition = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
|
||||
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
||||
showSuccessNotification({
|
||||
title: t("action.copyLegacyDefinition"),
|
||||
message: t("notification.legacyDefinitionCopied"),
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.copyLegacyDefinition"),
|
||||
message: error instanceof Error ? error.message : t("notification.legacyDefinitionCopyError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const queueMigratedWidget = (text: string) => {
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
showErrorNotification({ title: t("action.migrate"), message: formatCustomWidgetImportIssues(result.issues) });
|
||||
return;
|
||||
}
|
||||
setMigratedWidget(result.widget);
|
||||
migrationControls.open();
|
||||
};
|
||||
const notifyMigrationError = () =>
|
||||
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
||||
|
||||
const pasteMigratedWidget = async () => {
|
||||
try {
|
||||
queueMigratedWidget(await navigator.clipboard.readText());
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
notifyMigrationError();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -94,15 +107,10 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
try {
|
||||
queueMigratedWidget(loadEvent.target?.result as string);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
notifyMigrationError();
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
||||
});
|
||||
reader.addEventListener("error", notifyMigrationError);
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
@@ -110,18 +118,41 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.export.fetch({ id: widget.id });
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${widget.name}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
downloadJson(data, `${widget.name}.json`);
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.export"), message: t("notification.exportError") });
|
||||
}
|
||||
};
|
||||
|
||||
const exportLegacyDefinition = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
|
||||
downloadJson(data, `${widget.name}.legacy.json`);
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.exportLegacyDefinition"), message: t("notification.exportError") });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
toggleMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.widget.customApi.getData.cancel();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
duplicateMutation.mutate(
|
||||
{ id: widget.id },
|
||||
@@ -146,22 +177,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
title: t("action.delete"),
|
||||
children: t("action.deleteConfirm", { name: widget.name }),
|
||||
onConfirm: () => {
|
||||
const callbacks = {
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
deleteMutation.mutate(
|
||||
{ id: widget.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
};
|
||||
deleteMutation.mutate({ id: widget.id }, callbacks);
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -177,6 +210,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Dropdown>
|
||||
{widget.valid && (
|
||||
<>
|
||||
<Menu.Item
|
||||
onClick={handleToggleEnabled}
|
||||
leftSection={widget.enabled ? <IconPlayerPause {...iconProps} /> : <IconPlayerPlay {...iconProps} />}
|
||||
disabled={toggleMutation.isPending}
|
||||
>
|
||||
{widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={handleDuplicate}
|
||||
leftSection={<IconCopy {...iconProps} />}
|
||||
@@ -187,7 +228,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/publish/${widget.id}`}
|
||||
leftSection={<IconBuildingStore {...iconProps} />}
|
||||
>
|
||||
{t("action.publishWorkshop")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
@@ -195,6 +240,13 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
)}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<Menu.Item onClick={() => void copyLegacyDefinition()} leftSection={<IconCopy {...iconProps} />}>
|
||||
{t("action.copyLegacyDefinition")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void exportLegacyDefinition()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.exportLegacyDefinition")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
|
||||
{t("action.copyMigrationPrompt")}
|
||||
</Menu.Item>
|
||||
@@ -222,7 +274,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<input
|
||||
|
||||
+42
-38
@@ -1,27 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Accordion } from "@mantine/core";
|
||||
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { useDeferredCustomWidgetFormDocumentValues } from "./_custom-widget-form-state";
|
||||
import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { LazyOnceAccordion } from "./_lazy-once-accordion";
|
||||
|
||||
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
return (
|
||||
<LazyOnceAccordion label={t("advancedManifest")}>
|
||||
<CustomWidgetManifestEditor form={form} />
|
||||
</LazyOnceAccordion>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm }) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const values = useDeferredCustomWidgetFormDocumentValues();
|
||||
const serialized = JSON.stringify(
|
||||
{
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: form.values.name,
|
||||
...(form.values.description ? { description: form.values.description } : {}),
|
||||
...(form.values.iconUrl ? { iconUrl: form.values.iconUrl } : {}),
|
||||
sources: parseJson(form.values.sources),
|
||||
requests: parseJson(form.values.requests),
|
||||
options: parseJson(form.values.options),
|
||||
template: form.values.template,
|
||||
name: values.name,
|
||||
...(values.description ? { description: values.description } : {}),
|
||||
...(values.iconUrl ? { iconUrl: values.iconUrl } : {}),
|
||||
sources: parseJson(values.sources),
|
||||
requests: parseJson(values.requests),
|
||||
options: parseJson(values.options),
|
||||
template: values.template,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -30,33 +41,26 @@ export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkb
|
||||
const [error, setError] = useState<string>();
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
return (
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="manifest">
|
||||
<Accordion.Control>{t("advancedManifest")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<CodeEditor
|
||||
id="raw-widget-manifest"
|
||||
label={t("manifestFilename")}
|
||||
language="json"
|
||||
value={draft}
|
||||
error={error}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
applyDefinition(form, parsed.data);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("invalidJson"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<CodeEditor
|
||||
id="raw-widget-manifest"
|
||||
label={t("manifestFilename")}
|
||||
language="json"
|
||||
value={draft}
|
||||
error={error}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
applyDefinition(form, parsed.data);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("invalidJson"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { IconClipboard, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
|
||||
|
||||
interface AiCardProps {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
getDraft(): CustomWidgetAiDraft;
|
||||
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
|
||||
request: string;
|
||||
onRequestChange(value: string): void;
|
||||
documentationUrl: string;
|
||||
@@ -18,7 +19,7 @@ interface AiCardProps {
|
||||
}
|
||||
|
||||
export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.ai");
|
||||
const t = useI18n("customWidget.workbench.ai");
|
||||
return (
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="ai">
|
||||
@@ -27,14 +28,15 @@ export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
{t("title")}
|
||||
</Accordion.Control>
|
||||
<CopyAiPromptButton
|
||||
currentConfig={props.candidate}
|
||||
getDraft={props.getDraft}
|
||||
getDiagnostics={props.getDiagnostics}
|
||||
request={props.request}
|
||||
documentationUrl={props.documentationUrl}
|
||||
/>
|
||||
</Group>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text size="sm" c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-wi
|
||||
|
||||
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
|
||||
|
||||
export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) {
|
||||
export function createCustomWidgetCompletions(
|
||||
values: Pick<CustomWidgetFormValues, "options" | "sources">,
|
||||
requestIds: string[],
|
||||
) {
|
||||
const options = parseJson(values.options);
|
||||
const optionKeys = Object.keys(isRecord(options) ? options : {});
|
||||
return [
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconApi,
|
||||
IconBraces,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconDatabase,
|
||||
IconEye,
|
||||
IconPlayerPlay,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useCustomWidgetFormDocumentDirty } from "./_custom-widget-form-state";
|
||||
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
|
||||
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
|
||||
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
const sectionLinks = [
|
||||
["general", "section.general", IconSettings],
|
||||
["sources", "section.sources", IconApi],
|
||||
["requests", "section.requests", IconDatabase],
|
||||
["options", "section.options", IconBraces],
|
||||
["jsx", "section.jsx", IconCode],
|
||||
["preview", "section.preview", IconEye],
|
||||
] as const;
|
||||
|
||||
export function EditorSection({
|
||||
id,
|
||||
title,
|
||||
@@ -52,7 +78,8 @@ export function SaveActions({
|
||||
mode: "create" | "edit";
|
||||
onPreview(): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench");
|
||||
const t = useI18n("customWidget.workbench");
|
||||
const tCommon = useI18n("common");
|
||||
return (
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
@@ -77,9 +104,89 @@ export function SaveActions({
|
||||
{previewPending ? t("action.previewLoading") : t("action.preview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
|
||||
{mode === "create" ? t("action.create") : t("action.save")}
|
||||
{mode === "create" ? tCommon("action.create") : tCommon("action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetSectionNavigation({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect(section: (typeof sectionLinks)[number][0]): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench");
|
||||
const invalidSections = useCustomWidgetFormAnalysisField("invalidSections");
|
||||
return (
|
||||
<nav className={classes.sectionNav} aria-label={t("sectionNavigation")}>
|
||||
{sectionLinks.map(([id, key, SectionIcon]) => {
|
||||
const invalid = invalidSections.has(id);
|
||||
return (
|
||||
<Button
|
||||
key={id}
|
||||
component="a"
|
||||
href={`#${id}`}
|
||||
onClick={() => onSelect(id)}
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color={invalid ? "red" : undefined}
|
||||
leftSection={<SectionIcon size={14} />}
|
||||
rightSection={invalid ? <IconAlertCircle size={13} aria-label={t("status.invalid")} /> : undefined}
|
||||
>
|
||||
{t(key)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetSaveActions({
|
||||
mode,
|
||||
savePending,
|
||||
previewPending,
|
||||
onPreview,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
savePending: boolean;
|
||||
previewPending: boolean;
|
||||
onPreview(): void;
|
||||
}) {
|
||||
const dirty = useCustomWidgetFormDocumentDirty();
|
||||
const candidateValid = useCustomWidgetFormAnalysisField("candidateValid");
|
||||
const hasDiagnostics = useCustomWidgetFormAnalysisField("hasDiagnostics");
|
||||
return (
|
||||
<SaveActions
|
||||
dirty={dirty}
|
||||
savePending={savePending}
|
||||
previewPending={previewPending}
|
||||
invalid={hasDiagnostics || !candidateValid}
|
||||
mode={mode}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetUnsavedChangesGuard() {
|
||||
const dirty = useCustomWidgetFormDocumentDirty();
|
||||
useUnsavedChangesGuard(dirty);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CustomWidgetOptionsSnapshotSync({
|
||||
setOptionsSnapshot,
|
||||
}: {
|
||||
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
}) {
|
||||
const parsedOptions = useCustomWidgetFormAnalysisField("parsedOptions");
|
||||
const lastDefaults = useRef<Record<string, unknown> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!parsedOptions.success) return;
|
||||
const defaults = getCustomWidgetDefaultOptions(parsedOptions.data);
|
||||
if (lastDefaults.current && areCustomWidgetValuesEqual(lastDefaults.current, defaults)) return;
|
||||
lastDefaults.current = defaults;
|
||||
setOptionsSnapshot(defaults);
|
||||
}, [parsedOptions, setOptionsSnapshot]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { TextInput, Textarea } from "@mantine/core";
|
||||
import { IconApi, IconBraces, IconCode, IconDatabase, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
|
||||
import { EditorSection } from "./_custom-widget-form-layout";
|
||||
import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
|
||||
import { useCustomWidgetFormDocumentField } from "./_custom-widget-form-state";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor";
|
||||
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor";
|
||||
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
|
||||
import { LazyOnceAccordion } from "./_lazy-once-accordion";
|
||||
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
|
||||
|
||||
export function CustomWidgetAiSection({
|
||||
getDraft,
|
||||
getDiagnostics,
|
||||
onPaste,
|
||||
}: {
|
||||
getDraft(): CustomWidgetAiDraft;
|
||||
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
|
||||
onPaste(): void;
|
||||
}) {
|
||||
const [request, setRequest] = useState("");
|
||||
const [documentationUrl, setDocumentationUrl] = useState("");
|
||||
return (
|
||||
<CustomWidgetAiCard
|
||||
getDraft={getDraft}
|
||||
getDiagnostics={getDiagnostics}
|
||||
request={request}
|
||||
onRequestChange={setRequest}
|
||||
documentationUrl={documentationUrl}
|
||||
onDocumentationUrlChange={setDocumentationUrl}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetGeneralSection({
|
||||
form,
|
||||
formRevision,
|
||||
mode,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
formRevision: string;
|
||||
mode: "create" | "edit";
|
||||
}) {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const [suggestedIconSearch, setSuggestedIconSearch] = useState(() => form.getValues().name);
|
||||
const iconUrl = useCustomWidgetFormDocumentField("iconUrl");
|
||||
const nameInputProps = form.getInputProps("name");
|
||||
useEffect(() => setSuggestedIconSearch(form.getValues().name), [form, formRevision]);
|
||||
return (
|
||||
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
|
||||
<TextInput
|
||||
key={form.key("name")}
|
||||
label={tCommon("field.name")}
|
||||
required
|
||||
{...nameInputProps}
|
||||
onBlur={(event) => {
|
||||
nameInputProps.onBlur?.(event);
|
||||
setSuggestedIconSearch(form.getValues().name);
|
||||
}}
|
||||
/>
|
||||
<Textarea
|
||||
key={form.key("description")}
|
||||
label={t("field.description")}
|
||||
autosize
|
||||
minRows={2}
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
<IconPicker
|
||||
key={form.key("iconUrl")}
|
||||
withAsterisk={false}
|
||||
suggestedSearch={mode === "create" ? suggestedIconSearch : undefined}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
value={iconUrl}
|
||||
/>
|
||||
</EditorSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetSourcesSection({
|
||||
form,
|
||||
definitionId,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
}) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
useCustomWidgetFormDocumentField("sources");
|
||||
useCustomWidgetFormDocumentField("requests");
|
||||
useCustomWidgetFormDocumentField("secrets");
|
||||
return (
|
||||
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
|
||||
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
|
||||
</EditorSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetRequestsSection({
|
||||
form,
|
||||
onRename,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onRename(currentId: string, nextId: string): void;
|
||||
}) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const requests = useCustomWidgetFormDocumentField("requests");
|
||||
useCustomWidgetFormDocumentField("sources");
|
||||
useCustomWidgetFormDocumentField("options");
|
||||
const requestDiagnostics = useCustomWidgetFormAnalysisField("requestDiagnostics");
|
||||
return (
|
||||
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
|
||||
<CustomWidgetRequestsEditor form={form} onRename={onRename} />
|
||||
<LazyOnceAccordion label={w("builder.advancedJson")}>
|
||||
<CodeEditor
|
||||
id="requests-editor"
|
||||
label={w("requests.label")}
|
||||
description={w("requests.description")}
|
||||
language="json"
|
||||
value={requests}
|
||||
onChange={(value) => form.setFieldValue("requests", value, { forceUpdate: false })}
|
||||
diagnostics={requestDiagnostics}
|
||||
error={form.errors.requests}
|
||||
reference={customWidgetRequestReference}
|
||||
required
|
||||
/>
|
||||
</LazyOnceAccordion>
|
||||
</EditorSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetOptionsSection({
|
||||
form,
|
||||
onRename,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onRename(currentName: string, nextName: string): void;
|
||||
}) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const options = useCustomWidgetFormDocumentField("options");
|
||||
useCustomWidgetFormDocumentField("requests");
|
||||
return (
|
||||
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
|
||||
<CustomWidgetOptionsEditor form={form} onRename={onRename} />
|
||||
<LazyOnceAccordion label={w("builder.advancedJson")}>
|
||||
<CodeEditor
|
||||
id="options-editor"
|
||||
label={w("options.title")}
|
||||
language="json"
|
||||
value={options}
|
||||
onChange={(value) => form.setFieldValue("options", value, { forceUpdate: false })}
|
||||
error={form.errors.options}
|
||||
reference={customWidgetOptionsSchemaReference}
|
||||
/>
|
||||
</LazyOnceAccordion>
|
||||
</EditorSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetTemplateSection({ form }: { form: CustomWidgetWorkbenchForm }) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const template = useCustomWidgetFormDocumentField("template");
|
||||
const jsxCompletions = useCustomWidgetFormAnalysisField("jsxCompletions");
|
||||
const templateDiagnostics = useCustomWidgetFormAnalysisField("templateDiagnostics");
|
||||
return (
|
||||
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
|
||||
<CodeEditor
|
||||
id="jsx-editor"
|
||||
label={w("jsx.label")}
|
||||
language="jsx"
|
||||
value={template}
|
||||
onChange={(value) => form.setFieldValue("template", value, { forceUpdate: false })}
|
||||
diagnostics={templateDiagnostics}
|
||||
completions={jsxCompletions}
|
||||
error={form.errors.template}
|
||||
required
|
||||
maxLength={50_000}
|
||||
/>
|
||||
</EditorSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface CustomWidgetPreviewSectionProps {
|
||||
preview: PreviewState;
|
||||
size: string;
|
||||
onSizeChange(size: string): void;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
onOptionsChange: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
onLiveActionsChange(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetPreviewSection(props: CustomWidgetPreviewSectionProps) {
|
||||
const previewCandidate = useCustomWidgetFormAnalysisField("previewCandidate");
|
||||
const previewValidationIssues = useCustomWidgetFormAnalysisField("previewValidationIssues");
|
||||
return (
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={previewCandidate}
|
||||
validationIssues={previewValidationIssues}
|
||||
preview={props.preview}
|
||||
size={props.size}
|
||||
onSizeChange={props.onSizeChange}
|
||||
optionsSnapshot={props.optionsSnapshot}
|
||||
onOptionsChange={props.onOptionsChange}
|
||||
onLiveActionsChange={props.onLiveActionsChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
startTransition,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
|
||||
|
||||
export interface CustomWidgetFormDocumentStore {
|
||||
getValues(): CustomWidgetFormValues;
|
||||
setValues(values: CustomWidgetFormValues): void;
|
||||
getDirty(): boolean;
|
||||
subscribe(listener: () => void): () => void;
|
||||
markSaved(values: CustomWidgetFormValues): void;
|
||||
}
|
||||
|
||||
const documentValueKeys = customWidgetFormSchema.keyof().options;
|
||||
|
||||
function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) {
|
||||
if (Object.keys(left).length !== documentValueKeys.length) return false;
|
||||
if (Object.keys(right).length !== documentValueKeys.length) return false;
|
||||
return documentValueKeys.every(
|
||||
(key) => Object.hasOwn(left, key) && Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCustomWidgetFormDocumentStore(
|
||||
initialValues: CustomWidgetFormValues,
|
||||
): CustomWidgetFormDocumentStore {
|
||||
let values = initialValues;
|
||||
let persistedValues = initialValues;
|
||||
let dirty = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = () => listeners.forEach((listener) => listener());
|
||||
|
||||
return {
|
||||
getValues: () => values,
|
||||
setValues: (nextValues) => {
|
||||
if (Object.is(values, nextValues)) return;
|
||||
values = nextValues;
|
||||
dirty = !areDocumentValuesEqual(nextValues, persistedValues);
|
||||
notify();
|
||||
},
|
||||
getDirty: () => dirty,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
markSaved: (savedValues) => {
|
||||
const wasDirty = dirty;
|
||||
persistedValues = savedValues;
|
||||
dirty = !areDocumentValuesEqual(values, persistedValues);
|
||||
if (wasDirty !== dirty) notify();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const CustomWidgetFormDocumentContext = createContext<CustomWidgetFormDocumentStore | null>(null);
|
||||
|
||||
export function CustomWidgetFormDocumentProvider({
|
||||
store,
|
||||
children,
|
||||
}: {
|
||||
store: CustomWidgetFormDocumentStore;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <CustomWidgetFormDocumentContext.Provider value={store}>{children}</CustomWidgetFormDocumentContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormDocumentStore() {
|
||||
const store = useContext(CustomWidgetFormDocumentContext);
|
||||
if (!store) throw new Error("Custom widget form document provider is missing");
|
||||
return store;
|
||||
}
|
||||
|
||||
export function useDeferredCustomWidgetFormDocumentValues() {
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const [values, setValues] = useState(() => store.getValues());
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const scheduleUpdate = () => {
|
||||
const nextValues = store.getValues();
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
startTransition(() => {
|
||||
setValues((currentValues) => {
|
||||
if (!Object.is(store.getValues(), nextValues)) return currentValues;
|
||||
return nextValues;
|
||||
});
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
const unsubscribe = store.subscribe(scheduleUpdate);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
};
|
||||
}, [store]);
|
||||
return values;
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormDocumentField<Key extends keyof CustomWidgetFormValues>(field: Key) {
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const getSnapshot = () => store.getValues()[field];
|
||||
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormDocumentDirty() {
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
return useSyncExternalStore(store.subscribe, store.getDirty, store.getDirty);
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormDocumentBridge(
|
||||
form: UseFormReturnType<CustomWidgetFormValues>,
|
||||
store: CustomWidgetFormDocumentStore,
|
||||
) {
|
||||
const formRef = useRef(form);
|
||||
formRef.current = form;
|
||||
return useMemo(
|
||||
() =>
|
||||
new Proxy({} as UseFormReturnType<CustomWidgetFormValues>, {
|
||||
get: (_target, property) => {
|
||||
if (property === "values") return store.getValues();
|
||||
return Reflect.get(formRef.current, property);
|
||||
},
|
||||
}),
|
||||
[store],
|
||||
);
|
||||
}
|
||||
+2
-9
@@ -28,7 +28,7 @@ describe("Custom Widget workbench preview options", () => {
|
||||
const setValues = vi.fn();
|
||||
const result = applyCustomWidgetAiResponse(
|
||||
{
|
||||
values: {
|
||||
getValues: () => ({
|
||||
name: "",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
@@ -37,7 +37,7 @@ describe("Custom Widget workbench preview options", () => {
|
||||
options: "{}",
|
||||
template: "",
|
||||
secrets: [],
|
||||
},
|
||||
}),
|
||||
setValues,
|
||||
} as never,
|
||||
`\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``,
|
||||
@@ -84,13 +84,6 @@ describe("Custom Widget workbench preview options", () => {
|
||||
it("normalizes only changed secrets", () => {
|
||||
expect(
|
||||
getChangedSecrets({
|
||||
name: "Widget",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
sources: "{}",
|
||||
requests: "{}",
|
||||
options: "{}",
|
||||
template: "<Text />",
|
||||
secrets: [
|
||||
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
|
||||
{ sourceId: "other", kind: "password", value: "replacement" },
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>;
|
||||
|
||||
const PREVIEW_QUERY_CONCURRENCY = 4;
|
||||
|
||||
export function buildDefinition(values: CustomWidgetFormValues) {
|
||||
return customWidgetDefinitionSchema.safeParse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
@@ -25,7 +27,7 @@ export function buildDefinition(values: CustomWidgetFormValues) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getChangedSecrets(values: CustomWidgetFormValues) {
|
||||
export function getChangedSecrets(values: Pick<CustomWidgetFormValues, "secrets">) {
|
||||
return values.secrets
|
||||
.filter((secret) => secret.value.trim())
|
||||
.map(({ sourceId, kind, value }) => ({
|
||||
@@ -46,7 +48,7 @@ export function filterSecretsForSourceAuthentication(
|
||||
|
||||
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
|
||||
form.setValues({
|
||||
...form.values,
|
||||
...form.getValues(),
|
||||
name: widget.name,
|
||||
description: widget.description ?? "",
|
||||
iconUrl: widget.iconUrl ?? "",
|
||||
@@ -102,25 +104,43 @@ export function isRuntimeParams(
|
||||
}
|
||||
|
||||
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
|
||||
const data: Record<string, unknown> = {};
|
||||
const status: Record<string, unknown> = {};
|
||||
for (const [requestId] of Object.entries(definition.requests).filter(
|
||||
const requests = Object.entries(definition.requests).filter(
|
||||
([, entry]) => entry.kind === "query" && entry.trigger === "load",
|
||||
)) {
|
||||
try {
|
||||
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
|
||||
data[requestId] = result.data;
|
||||
status[requestId] = { loading: false, ...result };
|
||||
} catch (error) {
|
||||
data[requestId] = null;
|
||||
status[requestId] = {
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
};
|
||||
);
|
||||
const results: Array<{
|
||||
requestId: string;
|
||||
data: unknown;
|
||||
status: Record<string, unknown>;
|
||||
}> = [];
|
||||
let nextIndex = 0;
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
const request = requests[index];
|
||||
if (!request) return;
|
||||
const [requestId] = request;
|
||||
try {
|
||||
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
|
||||
results[index] = { requestId, data: result.data, status: { loading: false, ...result } };
|
||||
} catch (error) {
|
||||
results[index] = {
|
||||
requestId,
|
||||
data: null,
|
||||
status: {
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, status };
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(PREVIEW_QUERY_CONCURRENCY, requests.length) }, () => worker()));
|
||||
return {
|
||||
data: Object.fromEntries(results.map((result) => [result.requestId, result.data])),
|
||||
status: Object.fromEntries(results.map((result) => [result.requestId, result.status])),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconApi,
|
||||
IconBraces,
|
||||
IconCode,
|
||||
IconDatabase,
|
||||
IconEye,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Dispatch, FormEventHandler, SetStateAction } from "react";
|
||||
import { Box, Paper, SegmentedControl, Stack } from "@mantine/core";
|
||||
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
analyzeRequestManifest,
|
||||
customWidgetFormSchema,
|
||||
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { customWidgetOptionsSchema, getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { customWidgetFormSchema, DEFAULT_CUSTOM_WIDGET_FORM_VALUES } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
|
||||
import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest";
|
||||
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
|
||||
import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
|
||||
import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
|
||||
import { buildDefinition, getDefinitionDefaults, isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor";
|
||||
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
|
||||
import {
|
||||
CustomWidgetOptionsSnapshotSync,
|
||||
CustomWidgetSaveActions,
|
||||
CustomWidgetSectionNavigation,
|
||||
CustomWidgetUnsavedChangesGuard,
|
||||
} from "./_custom-widget-form-layout";
|
||||
import {
|
||||
CustomWidgetAiSection,
|
||||
CustomWidgetGeneralSection,
|
||||
CustomWidgetOptionsSection,
|
||||
CustomWidgetPreviewSection,
|
||||
CustomWidgetRequestsSection,
|
||||
CustomWidgetSourcesSection,
|
||||
CustomWidgetTemplateSection,
|
||||
} from "./_custom-widget-form-sections";
|
||||
import {
|
||||
createCustomWidgetFormDocumentStore,
|
||||
CustomWidgetFormDocumentProvider,
|
||||
useCustomWidgetFormDocumentBridge,
|
||||
} from "./_custom-widget-form-state";
|
||||
import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
|
||||
import { buildDefinition, getDefinitionDefaults } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor";
|
||||
import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handlers";
|
||||
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
|
||||
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
|
||||
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
|
||||
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
|
||||
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
|
||||
import { analyzeCustomWidgetAiDiagnostics, CustomWidgetFormAnalysisProvider } from "./_use-custom-widget-form-analysis";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
interface CustomWidgetFormProps {
|
||||
@@ -47,240 +47,204 @@ interface CustomWidgetFormProps {
|
||||
initialValues?: Partial<CustomWidgetFormValues>;
|
||||
definitionId?: string;
|
||||
}
|
||||
const sectionLinks = [
|
||||
["general", "section.general", IconSettings],
|
||||
["sources", "section.sources", IconApi],
|
||||
["requests", "section.requests", IconDatabase],
|
||||
["options", "section.options", IconBraces],
|
||||
["jsx", "section.jsx", IconCode],
|
||||
["preview", "section.preview", IconEye],
|
||||
] as const;
|
||||
|
||||
type MobilePane = "configure" | "preview";
|
||||
|
||||
interface CustomWidgetFormViewProps {
|
||||
mode: "create" | "edit";
|
||||
definitionId?: string;
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
formErrors: CustomWidgetWorkbenchForm["errors"];
|
||||
formRevision: string;
|
||||
documentStore: CustomWidgetFormDocumentStore;
|
||||
mobilePane: MobilePane;
|
||||
setMobilePane: Dispatch<SetStateAction<MobilePane>>;
|
||||
preview: PreviewState;
|
||||
setPreview: Dispatch<SetStateAction<PreviewState>>;
|
||||
previewSize: string;
|
||||
setPreviewSize: Dispatch<SetStateAction<string>>;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
saveIssues: CustomWidgetSaveIssue[];
|
||||
savePending: boolean;
|
||||
previewPending: boolean;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
onPreview(): void;
|
||||
onPasteAiResponse(): void;
|
||||
}
|
||||
|
||||
function getAiDraft(values: CustomWidgetFormValues): CustomWidgetAiDraft {
|
||||
return {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
iconUrl: values.iconUrl,
|
||||
sources: values.sources,
|
||||
requests: values.requests,
|
||||
options: values.options,
|
||||
template: values.template,
|
||||
};
|
||||
}
|
||||
|
||||
const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWidgetFormViewProps) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const documentStore = props.documentStore;
|
||||
const form = props.form;
|
||||
const setMobilePane = props.setMobilePane;
|
||||
const setPreview = props.setPreview;
|
||||
const { renameRequest, renameOption } = useMemo(
|
||||
() =>
|
||||
createCustomWidgetRenameHandlers({
|
||||
form,
|
||||
invalidWidgetMessage: w("invalidWidget"),
|
||||
}),
|
||||
[form, w],
|
||||
);
|
||||
const readAiDraft = useCallback(() => getAiDraft(documentStore.getValues()), [documentStore]);
|
||||
const readAiDiagnostics = useCallback(
|
||||
() => analyzeCustomWidgetAiDiagnostics(documentStore.getValues()),
|
||||
[documentStore],
|
||||
);
|
||||
const handleSectionSelect = useCallback(
|
||||
(section: string) => {
|
||||
setMobilePane(section === "preview" ? "preview" : "configure");
|
||||
},
|
||||
[setMobilePane],
|
||||
);
|
||||
const handleLiveActionsChange = useCallback(
|
||||
(enabled: boolean) =>
|
||||
setPreview((current) => ({
|
||||
...current,
|
||||
session: current.session ? { ...current.session, liveActions: enabled } : null,
|
||||
})),
|
||||
[setPreview],
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomWidgetFormDocumentProvider store={documentStore}>
|
||||
<CustomWidgetFormAnalysisProvider>
|
||||
<form onSubmit={props.onSubmit} className={classes.form}>
|
||||
<CustomWidgetUnsavedChangesGuard />
|
||||
<CustomWidgetOptionsSnapshotSync setOptionsSnapshot={props.setOptionsSnapshot} />
|
||||
<SegmentedControl
|
||||
className={classes.paneSwitcher}
|
||||
fullWidth
|
||||
value={props.mobilePane}
|
||||
onChange={(value) => props.setMobilePane(value as MobilePane)}
|
||||
data={[
|
||||
{ value: "configure", label: w("configure") },
|
||||
{ value: "preview", label: w("section.preview") },
|
||||
]}
|
||||
/>
|
||||
<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />
|
||||
<CustomWidgetSectionNavigation onSelect={handleSectionSelect} />
|
||||
<div className={classes.workbench} data-mobile-pane={props.mobilePane}>
|
||||
<Stack gap="lg" className={classes.configuration}>
|
||||
<CustomWidgetAiSection
|
||||
getDraft={readAiDraft}
|
||||
getDiagnostics={readAiDiagnostics}
|
||||
onPaste={props.onPasteAiResponse}
|
||||
/>
|
||||
<CustomWidgetAdvancedManifest form={form} />
|
||||
<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />
|
||||
<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />
|
||||
<CustomWidgetRequestsSection form={form} onRename={renameRequest} />
|
||||
<CustomWidgetOptionsSection form={form} onRename={renameOption} />
|
||||
<CustomWidgetTemplateSection form={form} />
|
||||
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
|
||||
<CustomWidgetSaveActions
|
||||
mode={props.mode}
|
||||
savePending={props.savePending}
|
||||
previewPending={props.previewPending}
|
||||
onPreview={props.onPreview}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
|
||||
<Paper p="md" className={classes.actionBar} shadow="sm">
|
||||
<CustomWidgetSaveActions
|
||||
mode={props.mode}
|
||||
savePending={props.savePending}
|
||||
previewPending={props.previewPending}
|
||||
onPreview={props.onPreview}
|
||||
/>
|
||||
</Paper>
|
||||
<CustomWidgetPreviewSection
|
||||
preview={props.preview}
|
||||
size={props.previewSize}
|
||||
onSizeChange={props.setPreviewSize}
|
||||
optionsSnapshot={props.optionsSnapshot}
|
||||
onOptionsChange={props.setOptionsSnapshot}
|
||||
onLiveActionsChange={handleLiveActionsChange}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</form>
|
||||
</CustomWidgetFormAnalysisProvider>
|
||||
</CustomWidgetFormDocumentProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
|
||||
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
|
||||
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
|
||||
const [request, setRequest] = useState("");
|
||||
const [documentationUrl, setDocumentationUrl] = useState("");
|
||||
const [formInitialValues] = useState<CustomWidgetFormValues>(() => ({
|
||||
...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
|
||||
...initialValues,
|
||||
}));
|
||||
const [documentStore] = useState(() => createCustomWidgetFormDocumentStore(formInitialValues));
|
||||
const mantineForm = useZodForm(customWidgetFormSchema, {
|
||||
initialValues: formInitialValues,
|
||||
mode: "uncontrolled",
|
||||
validateDebounce: 300,
|
||||
validateInputOnChange: false,
|
||||
onValuesChange: documentStore.setValues,
|
||||
});
|
||||
const form = useCustomWidgetFormDocumentBridge(mantineForm, documentStore);
|
||||
const [mobilePane, setMobilePane] = useState<MobilePane>("configure");
|
||||
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
const [previewSize, setPreviewSize] = useState("standard");
|
||||
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
|
||||
const initialDefinition = buildDefinition(formInitialValues);
|
||||
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
|
||||
});
|
||||
useUnsavedChangesGuard(form.isDirty());
|
||||
|
||||
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
|
||||
const parsedOptions = useMemo(
|
||||
() => customWidgetOptionsSchema.safeParse(parseJson(form.values.options)),
|
||||
[form.values.options],
|
||||
);
|
||||
const requestIds = useMemo(
|
||||
() =>
|
||||
Object.keys(
|
||||
isRecord(parseJson(form.values.requests)) ? (parseJson(form.values.requests) as Record<string, unknown>) : {},
|
||||
),
|
||||
[form.values.requests],
|
||||
);
|
||||
const jsxCompletions = useMemo(
|
||||
() => createCustomWidgetCompletions(form.values, requestIds),
|
||||
[form.values, requestIds],
|
||||
);
|
||||
const templateDiagnostics = useMemo(
|
||||
() => analyzeJsxTemplate(form.values.template, { requestIds }),
|
||||
[form.values.template, requestIds],
|
||||
);
|
||||
const requestDiagnostics = useMemo(() => analyzeRequestManifest(form.values.requests), [form.values.requests]);
|
||||
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
|
||||
const invalidSections = useMemo(
|
||||
() =>
|
||||
getInvalidCustomWidgetSections(
|
||||
candidate.success ? [] : candidate.error.issues,
|
||||
requestDiagnostics,
|
||||
templateDiagnostics,
|
||||
),
|
||||
[candidate, requestDiagnostics, templateDiagnostics],
|
||||
);
|
||||
|
||||
const { save, runPreview, pasteAiResponse, saveIssues, savePending, previewPending } = useCustomWidgetFormActions({
|
||||
const actions = useCustomWidgetFormActions({
|
||||
mode,
|
||||
definitionId,
|
||||
form,
|
||||
candidate,
|
||||
preview,
|
||||
documentStore,
|
||||
setPreview,
|
||||
setMobilePane,
|
||||
optionsSnapshot,
|
||||
setOptionsSnapshot,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
|
||||
}, [parsedOptions]);
|
||||
const latestActions = useRef(actions);
|
||||
useLayoutEffect(() => {
|
||||
latestActions.current = actions;
|
||||
}, [actions]);
|
||||
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>((event) => latestActions.current.save(event), []);
|
||||
const handlePreview = useCallback(() => void latestActions.current.runPreview(), []);
|
||||
const handlePasteAiResponse = useCallback(() => void latestActions.current.pasteAiResponse(), []);
|
||||
|
||||
const invalid = hasDiagnostics || !candidate.success;
|
||||
const { renameRequest, renameOption } = createCustomWidgetRenameHandlers({
|
||||
form,
|
||||
candidate,
|
||||
invalidWidgetMessage: w("invalidWidget"),
|
||||
});
|
||||
return (
|
||||
<form onSubmit={save} className={classes.form}>
|
||||
<SegmentedControl
|
||||
className={classes.paneSwitcher}
|
||||
fullWidth
|
||||
value={mobilePane}
|
||||
onChange={(value) => setMobilePane(value as typeof mobilePane)}
|
||||
data={[
|
||||
{ value: "configure", label: w("configure") },
|
||||
{ value: "preview", label: w("section.preview") },
|
||||
]}
|
||||
/>
|
||||
<CustomWidgetSaveIssuesAlert issues={saveIssues} />
|
||||
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
|
||||
{sectionLinks.map(([id, key, SectionIcon]) => (
|
||||
<Button
|
||||
key={id}
|
||||
component="a"
|
||||
href={`#${id}`}
|
||||
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color={invalidSections.has(id) ? "red" : undefined}
|
||||
leftSection={<SectionIcon size={14} />}
|
||||
rightSection={
|
||||
invalidSections.has(id) ? <IconAlertCircle size={13} aria-label={w("status.invalid")} /> : undefined
|
||||
}
|
||||
>
|
||||
{w(key)}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={classes.workbench} data-mobile-pane={mobilePane}>
|
||||
<Stack gap="lg" className={classes.configuration}>
|
||||
<CustomWidgetAiCard
|
||||
candidate={candidate.success ? candidate.data : null}
|
||||
request={request}
|
||||
onRequestChange={setRequest}
|
||||
documentationUrl={documentationUrl}
|
||||
onDocumentationUrlChange={setDocumentationUrl}
|
||||
onPaste={() => void pasteAiResponse()}
|
||||
/>
|
||||
<CustomWidgetAdvancedManifest form={form} />
|
||||
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
suggestedSearch={mode === "create" ? form.values.name : undefined}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
/>
|
||||
</EditorSection>
|
||||
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
|
||||
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
|
||||
</EditorSection>
|
||||
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
|
||||
<CustomWidgetRequestsEditor form={form} onRename={renameRequest} />
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="raw">
|
||||
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<CodeEditor
|
||||
id="requests-editor"
|
||||
label={w("requests.label")}
|
||||
description={w("requests.description")}
|
||||
language="json"
|
||||
value={form.values.requests}
|
||||
onChange={(value) => form.setFieldValue("requests", value)}
|
||||
diagnostics={requestDiagnostics}
|
||||
error={form.errors.requests}
|
||||
reference={customWidgetRequestReference}
|
||||
required
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</EditorSection>
|
||||
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
|
||||
<CustomWidgetOptionsEditor form={form} onRename={renameOption} />
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="raw">
|
||||
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<CodeEditor
|
||||
id="options"
|
||||
label={w("options.title")}
|
||||
language="json"
|
||||
value={form.values.options}
|
||||
onChange={(value) => form.setFieldValue("options", value)}
|
||||
error={form.errors.options}
|
||||
reference={customWidgetOptionsSchemaReference}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</EditorSection>
|
||||
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
|
||||
<CodeEditor
|
||||
id="jsx-editor"
|
||||
label={w("jsx.label")}
|
||||
language="jsx"
|
||||
value={form.values.template}
|
||||
onChange={(value) => form.setFieldValue("template", value)}
|
||||
diagnostics={templateDiagnostics}
|
||||
completions={jsxCompletions}
|
||||
error={form.errors.template}
|
||||
required
|
||||
maxLength={50_000}
|
||||
/>
|
||||
</EditorSection>
|
||||
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
|
||||
<SaveActions
|
||||
dirty={form.isDirty()}
|
||||
savePending={savePending}
|
||||
previewPending={previewPending}
|
||||
invalid={invalid}
|
||||
mode={mode}
|
||||
onPreview={() => void runPreview()}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
|
||||
<Paper p="md" className={classes.actionBar} shadow="sm">
|
||||
<SaveActions
|
||||
dirty={form.isDirty()}
|
||||
savePending={savePending}
|
||||
previewPending={previewPending}
|
||||
invalid={invalid}
|
||||
mode={mode}
|
||||
onPreview={() => void runPreview()}
|
||||
/>
|
||||
</Paper>
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={candidate.success ? candidate.data : null}
|
||||
validationIssues={
|
||||
candidate.success
|
||||
? []
|
||||
: candidate.error.issues.map((issue) => ({
|
||||
path: issue.path.map(String).join(".") || undefined,
|
||||
message: issue.message,
|
||||
}))
|
||||
}
|
||||
preview={preview}
|
||||
size={previewSize}
|
||||
onSizeChange={setPreviewSize}
|
||||
optionsSnapshot={optionsSnapshot}
|
||||
onOptionsChange={setOptionsSnapshot}
|
||||
onLiveActionsChange={(enabled) =>
|
||||
setPreview((current) => ({
|
||||
...current,
|
||||
session: current.session ? { ...current.session, liveActions: enabled } : null,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</form>
|
||||
<CustomWidgetFormView
|
||||
mode={mode}
|
||||
definitionId={definitionId}
|
||||
form={form}
|
||||
formErrors={mantineForm.errors}
|
||||
formRevision={mantineForm.key("name")}
|
||||
documentStore={documentStore}
|
||||
mobilePane={mobilePane}
|
||||
setMobilePane={setMobilePane}
|
||||
preview={preview}
|
||||
setPreview={setPreview}
|
||||
previewSize={previewSize}
|
||||
setPreviewSize={setPreviewSize}
|
||||
optionsSnapshot={optionsSnapshot}
|
||||
setOptionsSnapshot={setOptionsSnapshot}
|
||||
saveIssues={actions.saveIssues}
|
||||
savePending={actions.savePending}
|
||||
previewPending={actions.previewPending}
|
||||
onSubmit={handleSubmit}
|
||||
onPreview={handlePreview}
|
||||
onPasteAiResponse={handlePasteAiResponse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+11
-5
@@ -19,12 +19,18 @@ describe("legacy custom widget migration UI", () => {
|
||||
});
|
||||
|
||||
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(dialog).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
|
||||
expect(dialog).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(dialog).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
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,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Badge, Button, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
|
||||
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
|
||||
@@ -18,186 +18,174 @@ interface CustomWidgetListProps {
|
||||
}
|
||||
|
||||
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const tEntities = useI18n("common.entity");
|
||||
|
||||
if (definitions.length === 0) {
|
||||
return (
|
||||
<NoResults
|
||||
icon={IconApi}
|
||||
title={t("page.list.noResults")}
|
||||
action={{ href: "/manage/custom-widgets/new", label: t("action.create") }}
|
||||
action={{ href: "/manage/custom-widgets/new", label: tCommon("action.create") }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{definitions.map((def) => (
|
||||
<CustomWidgetCard key={def.id} widget={def} />
|
||||
<ManageCollectionList ariaLabel={tEntities("customWidgets")}>
|
||||
{definitions.map((definition) => (
|
||||
<CustomWidgetRow key={definition.id} widget={definition} />
|
||||
))}
|
||||
</Stack>
|
||||
</ManageCollectionList>
|
||||
);
|
||||
};
|
||||
|
||||
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||
|
||||
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.widget.customApi.getData.cancel();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
|
||||
const origins = [...new Set(widget.sources.map((source) => source.origin))];
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="sm"
|
||||
withBorder={!widget.valid}
|
||||
bd={
|
||||
widget.migrationRequired
|
||||
? "1px solid var(--mantine-color-yellow-6)"
|
||||
: !widget.valid
|
||||
? "1px solid var(--mantine-color-red-6)"
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
|
||||
transition: "opacity 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group wrap="nowrap" gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
{widget.iconUrl ? (
|
||||
<Avatar size={36} radius="sm" src={widget.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<Avatar size={36} radius="sm" color="red">
|
||||
<IconApi size={18} />
|
||||
</Avatar>
|
||||
)}
|
||||
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{widget.name}
|
||||
<>
|
||||
<ManageCollectionItem
|
||||
leading={
|
||||
<Avatar
|
||||
size={40}
|
||||
radius="sm"
|
||||
src={widget.iconUrl}
|
||||
color={widget.valid ? "pink" : "red"}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
alt=""
|
||||
>
|
||||
<IconApi size={20} stroke={1.5} />
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
}
|
||||
badges={<CustomWidgetStatusBadges widget={widget} onConfigureSources={sourceSetupControls.open} />}
|
||||
description={
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{widget.description || t("page.list.noDescription")}
|
||||
</Text>
|
||||
}
|
||||
metadata={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("page.list.requestCount", { count: widget.requestCount })}
|
||||
</Text>
|
||||
{widget.migrationRequired ? (
|
||||
<Text size="xs" c="yellow.8">
|
||||
{t("page.list.migrationDescription")}
|
||||
{origins.map((origin) => (
|
||||
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
|
||||
· {origin}
|
||||
</Text>
|
||||
) : !widget.valid ? (
|
||||
<Text size="xs" c="red">
|
||||
{t("page.list.invalidDefinitionDescription")}
|
||||
</Text>
|
||||
) : null}
|
||||
{widget.sources[0] && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||
{widget.sources.map((source) => source.origin).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{widget.migrationRequired ? (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : widget.valid ? (
|
||||
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
|
||||
JSX · {widget.requestCount}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={widget.validationIssues
|
||||
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
||||
.join("\n")}
|
||||
multiline
|
||||
maw={520}
|
||||
>
|
||||
<Badge
|
||||
color="red"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.invalidDefinition")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{widget.missingSecrets.length > 0 && (
|
||||
<Tooltip
|
||||
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{widget.valid && (
|
||||
<>
|
||||
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={widget.enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
))}
|
||||
</Group>
|
||||
}
|
||||
actions={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{widget.valid && (
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("action.edit")}
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
{tCommon("action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
actionsAlignment="center"
|
||||
/>
|
||||
{widget.valid && (
|
||||
<CustomWidgetSourceSetupDialog
|
||||
definitionId={widget.id}
|
||||
opened={sourceSetupOpened}
|
||||
onClose={sourceSetupControls.close}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything that decides whether this widget can actually run right now. */
|
||||
function CustomWidgetStatusBadges({ widget, onConfigureSources }: { widget: WidgetDef; onConfigureSources(): void }) {
|
||||
const t = useI18n("customWidget");
|
||||
|
||||
if (widget.migrationRequired) {
|
||||
return (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge color="yellow" size="sm" variant="light" leftSection={<IconSparkles size={12} />}>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.valid) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={widget.validationIssues
|
||||
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
||||
.join("\n")}
|
||||
multiline
|
||||
maw={520}
|
||||
>
|
||||
<Badge color="red" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
|
||||
{t("page.list.invalidDefinition")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{widget.enabled ? (
|
||||
<Badge color="green" size="sm" variant="light">
|
||||
{t("page.list.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label={t("page.list.disabledDescription")}>
|
||||
<Badge color="gray" size="sm" variant="light">
|
||||
{t("page.list.disabled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{widget.missingSecrets.length > 0 && (
|
||||
<Tooltip
|
||||
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<UnstyledButton onClick={onConfigureSources} aria-label={t("action.configureSources")}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DefaultValueEditor({
|
||||
choices?: Array<{ label: string; value: string | number }>;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
if (control === "switch")
|
||||
return (
|
||||
<Switch
|
||||
@@ -84,7 +84,7 @@ export function OptionChoicesEditor({
|
||||
value: Array<{ label: string; value: string | number }>;
|
||||
onChange(value: Array<{ label: string; value: string | number }>): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{value.map((choice, index) => (
|
||||
@@ -150,7 +150,7 @@ export function JsonValueEditor({
|
||||
value: unknown;
|
||||
onChange(value: unknown): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
+4
-4
@@ -16,7 +16,7 @@ import {
|
||||
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { customWidgetOptionControls } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
@@ -30,7 +30,7 @@ export function CustomWidgetOptionsEditor({
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onRename(currentName: string, nextName: string): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.builder");
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const parsed = parseJson(form.values.options);
|
||||
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
|
||||
const requests = parseJson(form.values.requests);
|
||||
@@ -69,7 +69,7 @@ export function CustomWidgetOptionsEditor({
|
||||
)
|
||||
: [];
|
||||
return (
|
||||
<Fieldset key={index} legend={typeof option.label === "string" ? option.label : id}>
|
||||
<Fieldset key={id} legend={typeof option.label === "string" ? option.label : id}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<CustomWidgetIdentifierInput
|
||||
@@ -99,7 +99,7 @@ export function CustomWidgetOptionsEditor({
|
||||
}
|
||||
/>
|
||||
<DefaultValueEditor
|
||||
id={`${index}-default`}
|
||||
id={`${id}-default`}
|
||||
control={control}
|
||||
value={option.default}
|
||||
choices={choices}
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } fro
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
|
||||
|
||||
@@ -19,7 +19,7 @@ export function PreviewActionControl({
|
||||
request: CustomJsxRequest & { id: string };
|
||||
sessionId?: string;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.preview");
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const actionMutation = clientApi.customWidget.previewAction.useMutation();
|
||||
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
|
||||
|
||||
@@ -6,6 +6,8 @@ interface PreviewSnapshot {
|
||||
session: { id: string; liveActions: boolean } | null;
|
||||
}
|
||||
|
||||
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template">;
|
||||
|
||||
export function createPreviewDisplayData({
|
||||
candidate,
|
||||
fixture,
|
||||
@@ -13,7 +15,7 @@ export function createPreviewDisplayData({
|
||||
options,
|
||||
fixtureError,
|
||||
}: {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
candidate: PreviewCandidate | null;
|
||||
fixture: "live" | "loading" | "empty" | "error";
|
||||
preview: PreviewSnapshot;
|
||||
options: Record<string, unknown>;
|
||||
|
||||
Loaded 100 of 887 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user