Compare commits
145
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ebfe659b2 | ||
|
|
ca0bb36664 | ||
|
|
839b4721fb | ||
|
|
40374803d4 | ||
|
|
2de3c86b31 | ||
|
|
be0043ab81 | ||
|
|
89f081538c | ||
|
|
fd351e4adf | ||
|
|
43cc40eb66 | ||
|
|
320c51ac89 | ||
|
|
2513e8454f | ||
|
|
09df2910c5 | ||
|
|
083a926719 | ||
|
|
ec79a15023 | ||
|
|
cf240addea | ||
|
|
3ba2800db9 | ||
|
|
527e911eef | ||
|
|
ff2cdecc86 | ||
|
|
742bde1d27 | ||
|
|
9b54edd2db | ||
|
|
37dead1690 | ||
|
|
d2e86895f4 | ||
|
|
afa68687eb | ||
|
|
2534103627 | ||
|
|
365b2ed13e | ||
|
|
7e961d3da4 | ||
|
|
f5b7818c53 | ||
|
|
fee2e6a6af | ||
|
|
040f105946 | ||
|
|
8f94ea47e3 | ||
|
|
c526dd2b77 | ||
|
|
c73b139dbd | ||
|
|
7550766f67 | ||
|
|
5fb8e9223a | ||
|
|
dc46057544 | ||
|
|
f5a9933dd8 | ||
|
|
9f1a15b9bf | ||
|
|
64febbce12 | ||
|
|
98bb1aecf8 | ||
|
|
ab5b8dad43 | ||
|
|
e687c1dd22 | ||
|
|
bcea4733b9 | ||
|
|
06c489bd48 | ||
|
|
8594cc884f | ||
|
|
1de51a9afd | ||
|
|
4a7da0bdab | ||
|
|
3cc6ff33df | ||
|
|
3bc25509cf | ||
|
|
4766653979 | ||
|
|
3c56eab58f | ||
|
|
cb3a2a360e | ||
|
|
f707f64333 | ||
|
|
933a54ead9 | ||
|
|
63e65bbc6c | ||
|
|
9bebc28102 | ||
|
|
605431bc3f | ||
|
|
1d09172887 | ||
|
|
b4be884dfc | ||
|
|
fd7bcb2231 | ||
|
|
8f18b17207 | ||
|
|
a532f9d377 | ||
|
|
61d9faffc5 | ||
|
|
1965c75356 | ||
|
|
79ea987761 | ||
|
|
9826000722 | ||
|
|
b3f58cd8aa | ||
|
|
84dcbd8847 | ||
|
|
fb80b3882d | ||
|
|
6363d96a1b | ||
|
|
0d19968131 | ||
|
|
464d98e124 | ||
|
|
9187af8882 | ||
|
|
abec79abe8 | ||
|
|
b1713a9820 | ||
|
|
987c9ade18 | ||
|
|
3db87d781f | ||
|
|
51467ded12 | ||
|
|
38fc9bfd01 | ||
|
|
9bc1fc397c | ||
|
|
9596a83f60 | ||
|
|
e036cd86d8 | ||
|
|
6141d505e4 | ||
|
|
6692acbd33 | ||
|
|
95a7d56989 | ||
|
|
7bd5991076 | ||
|
|
345a4be4b0 | ||
|
|
6178843198 | ||
|
|
6f563640ac | ||
|
|
4270f2f2f1 | ||
|
|
00855d4825 | ||
|
|
39334569d6 | ||
|
|
a7006d746e | ||
|
|
ffed73ba8f | ||
|
|
90d9a50227 | ||
|
|
e836d07d4c | ||
|
|
33293ba1cf | ||
|
|
b3b2d5a022 | ||
|
|
67ac2bbc7c | ||
|
|
fbb2eddd87 | ||
|
|
38ac8f3075 | ||
|
|
b81b90875f | ||
|
|
06724cfeb2 | ||
|
|
131a5558b5 | ||
|
|
b32ad82850 | ||
|
|
622e1dcfa5 | ||
|
|
9674bde758 | ||
|
|
b479ada14f | ||
|
|
bff3383ac9 | ||
|
|
d865733838 | ||
|
|
67fcd69eb8 | ||
|
|
aeabfd3521 | ||
|
|
37930e44cc | ||
|
|
451dc3ce39 | ||
|
|
4361c8dbe2 | ||
|
|
0f8362c3da | ||
|
|
6b45aca5d5 | ||
|
|
018f98e318 | ||
|
|
85fcea3837 | ||
|
|
f76a0f240a | ||
|
|
637f03e107 | ||
|
|
55214ccbc5 | ||
|
|
778533a62e | ||
|
|
366ec6bdbc | ||
|
|
9f4b147f0e | ||
|
|
08af704c8a | ||
|
|
3f9fb215c6 | ||
|
|
de801f3f00 | ||
|
|
d90e762e4b | ||
|
|
81a13d3b52 | ||
|
|
388a74086e | ||
|
|
cd6c83a7b0 | ||
|
|
9963ba0274 | ||
|
|
5d9f05e083 | ||
|
|
d48f299585 | ||
|
|
c80e9878de | ||
|
|
84f87ce24e | ||
|
|
fbb57c8f40 | ||
|
|
0b9c4d6375 | ||
|
|
7266e15356 | ||
|
|
c22c733463 | ||
|
|
440626ba00 | ||
|
|
f8d8a09c56 | ||
|
|
add6ee56ba | ||
|
|
6b74078766 | ||
|
|
4ca0b747bb |
No files matched your search
+2
-1
@@ -17,7 +17,8 @@ coverage
|
||||
dev
|
||||
.build
|
||||
e2e
|
||||
apps/docs
|
||||
apps/docs/*
|
||||
!apps/docs/package.json
|
||||
apps/nextjs/.next
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -55,6 +55,10 @@ TURBO_TELEMETRY_DISABLED=1
|
||||
# Example: DOCKER_SOCKET_PATHS=/var/run/docker.sock,/run/user/1000/podman/podman.sock
|
||||
# DOCKER_SOCKET_PATHS=
|
||||
|
||||
# Redis DB index for the Next.js cache handler (default: 1).
|
||||
# Must differ from REDIS_DATABASE_INDEX (default: 0) to avoid cache ops affecting app data.
|
||||
# REDIS_CACHE_DB=1
|
||||
|
||||
# Enable kubernetes tool
|
||||
# ENABLE_KUBERNETES=true
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.74.0
|
||||
- 1.73.0
|
||||
- 1.72.0
|
||||
- 1.71.0
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
name: "[Dependency Updates] Auto Approve"
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve-automatic-prs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175486441: homarr-releases[bot]
|
||||
# Extracted from https://api.github.com/users/<username>
|
||||
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to approve pull request
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
- name: Approve automatic PRs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
|
||||
@@ -0,0 +1,43 @@
|
||||
name: "[Bots] Reconcile Auto-merge"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/30 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to enable auto-merge
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
|
||||
- name: Re-enable auto-merge for prepared bot PRs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175441: homarr-releases[bot]
|
||||
gh pr list --repo "${{ github.repository }}" --base dev --state open --limit 100 \
|
||||
--json number,author,reviewDecision,mergeStateStatus,autoMergeRequest \
|
||||
--jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \
|
||||
| while read -r pr; do
|
||||
echo "Re-enabling auto-merge on PR #$pr"
|
||||
gh pr merge "$pr" --auto --squash
|
||||
done
|
||||
@@ -0,0 +1,47 @@
|
||||
name: "[Bots] Approve and Auto-merge"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve-and-automerge:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175441: homarr-releases[bot]
|
||||
# Extracted from https://api.github.com/users/<username>
|
||||
if: |
|
||||
(github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175441) &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.base.ref == 'dev'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to approve a pull request and enable auto-merge
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
|
||||
- name: Approve and enable auto-merge
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
if [ "$(gh pr view "$PR_NUMBER" --json reviewDecision --jq .reviewDecision)" != "APPROVED" ]; then
|
||||
gh pr review "$PR_NUMBER" --approve --body "Automatically approved by Homarr bot policy"
|
||||
fi
|
||||
if [ "$(gh pr view "$PR_NUMBER" --json autoMergeRequest --jq '.autoMergeRequest != null')" != "true" ]; then
|
||||
gh pr merge "$PR_NUMBER" --auto --squash
|
||||
fi
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Lint, typecheck, and build affected workspaces
|
||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=4
|
||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2
|
||||
|
||||
- name: Validate workspace configuration
|
||||
run: pnpm lint:ws
|
||||
|
||||
@@ -78,3 +78,12 @@ skills-lock.json
|
||||
apps/docs/.docusaurus
|
||||
apps/docs/build
|
||||
.pnpm-store
|
||||
benchmark-results
|
||||
.bench/
|
||||
|
||||
# Homarr backups used by scripts/benchmarks/stress-restore.mts. These contain
|
||||
# real integration secrets and an encryption key — never commit one.
|
||||
homarr-backup*.zip
|
||||
*.sqlite
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
@@ -1,3 +1,5 @@
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
||||
|
||||
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
|
||||
|
||||
@@ -206,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw
|
||||
apps/docs/docs/integrations/uptime-kuma @ajnart
|
||||
packages/integrations/src/uptime-kuma @ajnart
|
||||
|
||||
# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn
|
||||
apps/docs/docs/integrations/whats-up-docker @tiimonn
|
||||
apps/docs/docs/widgets/whats-up-docker @tiimonn
|
||||
packages/api/src/router/widgets/wud.ts @tiimonn
|
||||
packages/integrations/src/wud @tiimonn
|
||||
packages/request-handler/src/wud.ts @tiimonn
|
||||
packages/widgets/src/wud @tiimonn
|
||||
|
||||
# Traefik - by @Nerdy-Technician
|
||||
apps/docs/docs/integrations/traefik @Nerdy-Technician
|
||||
apps/docs/docs/widgets/traefik @Nerdy-Technician
|
||||
|
||||
+37
-7
@@ -3,12 +3,18 @@
|
||||
FROM node:24.18.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
ARG TARGETPLATFORM
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat curl bash && apk update
|
||||
# Native dependencies normally use prebuilds, but the build must remain
|
||||
# reproducible when a prebuild download times out and compilation is required.
|
||||
RUN apk add --no-cache libc6-compat curl bash python3 make g++
|
||||
|
||||
RUN corepack enable pnpm
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY patches ./patches
|
||||
COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./
|
||||
# @homarr/definitions generates documentation types during install.
|
||||
COPY --parents ./packages/definitions/src ./
|
||||
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
|
||||
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
||||
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
|
||||
@@ -22,22 +28,45 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm fetch --ignore-scripts && \
|
||||
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
||||
|
||||
COPY . .
|
||||
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
||||
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
|
||||
# Install only from the fetched, committed lockfile so local and Docker builds
|
||||
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
|
||||
# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm install --recursive
|
||||
npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG SKIP_ENV_VALIDATION='true'
|
||||
ARG CI='true'
|
||||
ARG DISABLE_REDIS_LOGS='true'
|
||||
ARG TARGETPLATFORM
|
||||
# Opt-in React profiling build + browser source maps, for React DevTools' Profiler
|
||||
# and for readable names in heap snapshots. See next.config.ts.
|
||||
ARG HOMARR_PROFILING='false'
|
||||
ENV HOMARR_PROFILING=${HOMARR_PROFILING}
|
||||
|
||||
# React ships a separate profiling renderer; the production one omits the hooks
|
||||
# DevTools needs, which is what makes it report "Profiling not supported". Next only
|
||||
# swaps it via a webpack alias (create-compiler-aliases.js), so under Turbopack both
|
||||
# `next build --profile` and `reactProductionProfiling` silently produce a normal
|
||||
# build. Point the vendored client entry at the profiling bundle instead — the same
|
||||
# swap webpack's alias performs, done where Turbopack cannot miss it.
|
||||
RUN if [ "$HOMARR_PROFILING" = "true" ]; then \
|
||||
set -e; \
|
||||
entry=node_modules/next/dist/compiled/react-dom/client.js; \
|
||||
grep -q "react-dom-client.production.js" "$entry" || { echo "profiling patch: unexpected $entry"; exit 1; }; \
|
||||
sed -i "s#./cjs/react-dom-client.production.js#./cjs/react-dom-profiling.profiling.js#" "$entry"; \
|
||||
grep -q "react-dom-profiling.profiling.js" "$entry" || { echo "profiling patch failed"; exit 1; }; \
|
||||
echo "profiling patch applied to $entry"; \
|
||||
fi
|
||||
|
||||
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
|
||||
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
|
||||
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
|
||||
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
|
||||
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
|
||||
--mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \
|
||||
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" \
|
||||
pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
@@ -85,5 +114,6 @@ ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
|
||||
EXPOSE 7575
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Performance findings
|
||||
|
||||
This file grew past 1,200 lines covering server memory, client rendering, methodology and a long
|
||||
list of rejected ideas at once. It has been split by subject under [`docs/perf/`](./docs/perf/):
|
||||
|
||||
| document | subject |
|
||||
| --- | --- |
|
||||
| [docs/perf/README.md](./docs/perf/README.md) | Headline results and the change-by-change ratio table |
|
||||
| [docs/perf/SERVER-MEMORY.md](./docs/perf/SERVER-MEMORY.md) | What the container's RAM is made of, layer by layer, and what reduced it |
|
||||
| [docs/perf/CLIENT-PERFORMANCE.md](./docs/perf/CLIENT-PERFORMANCE.md) | Dashboard cost in Chrome: DOM, fibers, images, paint |
|
||||
| [docs/perf/REJECTED.md](./docs/perf/REJECTED.md) | Ideas implemented, measured, and abandoned — with their numbers |
|
||||
| [docs/perf/METHODOLOGY.md](./docs/perf/METHODOLOGY.md) | The tools, and the measurement traps that produced wrong answers |
|
||||
|
||||
## Result in one table
|
||||
|
||||
Server RAM, merge-base `06724cfeb` vs this branch, two alternated runs per side, cgroup **anonymous**
|
||||
memory — the figure that counts against a container limit:
|
||||
|
||||
| stage | before | after | change |
|
||||
| --- | --- | --- | --- |
|
||||
| **peak** | 376.6 MiB | **279.3 MiB** | **−26%** |
|
||||
| boot idle | 130.5 MiB | **62.4 MiB** | **−52%** |
|
||||
| after sign-in | 247.4 MiB | **142.9 MiB** | **−42%** |
|
||||
| dashboard loaded | 293.4 MiB | **170.4 MiB** | **−42%** |
|
||||
| 4 tabs, 2 boards | 366.5 MiB | **258.2 MiB** | **−30%** |
|
||||
| after 2 min idle soak | 323.7 MiB | **200.8 MiB** | **−38%** |
|
||||
|
||||
No before/after range overlaps at any stage. Cost: **+18% CPU**, solid and non-overlapping.
|
||||
+2
-2
@@ -55,8 +55,8 @@ pnpm typecheck
|
||||
docs/
|
||||
├── getting-started/ # Installation guides, glossary, prerequisites
|
||||
├── management/ # Boards, apps, users, integrations, settings, tasks, API
|
||||
├── integrations/ # 55 integration guides (one folder each)
|
||||
├── widgets/ # 42 widget guides (one folder each)
|
||||
├── integrations/ # 56 integration guides (one folder each)
|
||||
├── widgets/ # 43 widget guides (one folder each)
|
||||
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
|
||||
└── community/ # FAQ, donate, license, translations, get-in-touch
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@ You should also create a backup of the database, in case you break something.
|
||||
## Execute CLI with Docker
|
||||
|
||||
1. Execute ``docker ps | grep homarr`` on the root system with Docker to find the container ID of your Homarr container.
|
||||
2. Run ``docker exec -it <container-id> /bin/bash homarr <your-command-goes-here>``. \
|
||||
2. Run ``docker exec -it <container-id> homarr <your-command-goes-here>``. \
|
||||
Replace ``<container-id>`` with your container ID from step 1. \
|
||||
Replace ``<your-command-goes-here>`` with the command you want to execute (see sub-pages).
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ This guide will help you set up your development environment to start contributi
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
|
||||
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
|
||||
- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/).
|
||||
- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm version.
|
||||
- **Git**: You can download it from the [official website](https://git-scm.com/).
|
||||
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
|
||||
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
|
||||
@@ -49,7 +49,10 @@ This guide will help you set up your development environment to start contributi
|
||||
|
||||
#### During development
|
||||
|
||||
- `pnpm dev`: Start the development server.
|
||||
- `pnpm dev`: Start the Next.js development server.
|
||||
- `pnpm dev:benchmark`: Run the local authenticated-board benchmark. Set `DEV_BENCHMARK_BOARD_URL` and
|
||||
`DEV_BENCHMARK_STORAGE_STATE`; readiness-only measurements require the explicit `--smoke` flag and are not
|
||||
suitable for performance claims.
|
||||
- `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images.
|
||||
- `pnpm dev:cli -- build <name>`: Build the current checkout as `homarr:<name>`.
|
||||
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
|
||||
@@ -74,4 +77,5 @@ This guide will help you set up your development environment to start contributi
|
||||
|
||||
- `pnpm build`: Build the project for production.
|
||||
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
|
||||
- `docker build -t homarr .`: Build a docker image for the project.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
|
||||
@@ -25,15 +25,14 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
|
||||
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| ``PUID`` | User ID to run the container as | Any valid user ID | ``0`` |
|
||||
| ``PGID`` | Group ID to run the container as | Any valid group ID | ``0`` |
|
||||
| ``LOG_LEVEL`` | Log level to use | ``debug`` / ``info`` / ``warn`` / ``error`` | ``info`` |
|
||||
| ``NO_EXTERNAL_CONNECTION`` | Disables some requests that need internet connection | `true` or `false` | `false` |
|
||||
| ``ENABLE_DNS_CACHING`` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
|
||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
|
||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -43,9 +42,9 @@ See [Single Sign-On](/docs/advanced/single-sign-on) for more informations.
|
||||
|
||||
The `SECRET_ENCRYPTION_KEY` is required. If none is specified before starting the container, a random key will be shown in the error message and the container will exit.
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| ``SECRET_ENCRYPTION_KEY`` | Secret used to encrypt secrets in database. | 64 character hex string | - |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ----------------------- | ------------------------------------------- | ----------------------- | ------- |
|
||||
| `SECRET_ENCRYPTION_KEY` | Secret used to encrypt secrets in database. | 64 character hex string | - |
|
||||
|
||||
:::info
|
||||
A random secret can be generated by using the following command: `openssl rand -hex 32`
|
||||
@@ -53,27 +52,27 @@ 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_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` | - |
|
||||
|
||||
## Database
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| ``DB_DRIVER`` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | ``better-sqlite3`` / ``mysql2`` / ``node-postgres`` | ``better-sqlite3`` |
|
||||
| ``DB_DIALECT`` | Database dialect to use. | ``sqlite`` / ``mysql`` / ``postgresql`` | ``sqlite`` |
|
||||
| ``DB_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` |
|
||||
| ``DB_HOST`` | Database host to connect to. | Any valid database host | - |
|
||||
| ``DB_PORT`` | Database port to connect to. | Any valid database port | - |
|
||||
| ``DB_NAME`` | Database name to connect to. | Any valid database name | - |
|
||||
| ``DB_USER`` | Database user to connect with. | Any valid database user | - |
|
||||
| ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - |
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- |
|
||||
| `DB_DRIVER` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | `better-sqlite3` / `mysql2` / `node-postgres` | `better-sqlite3` |
|
||||
| `DB_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` |
|
||||
| `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` |
|
||||
| `DB_HOST` | Database host to connect to. | Any valid database host | - |
|
||||
| `DB_PORT` | Database port to connect to. | Any valid database port | - |
|
||||
| `DB_NAME` | Database name to connect to. | Any valid database name | - |
|
||||
| `DB_USER` | Database user to connect with. | Any valid database user | - |
|
||||
| `DB_PASSWORD` | Database password to connect with. | Any valid database password | - |
|
||||
|
||||
You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values.
|
||||
|
||||
|
||||
## Redis
|
||||
|
||||
By default Redis is running within the installation. (for example in Docker).
|
||||
@@ -83,25 +82,23 @@ However for example for K8s it can be useful to use an external Redis instance.
|
||||
External redis is currently not supported with Proxmox Community Scripts
|
||||
:::
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
|
||||
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
|
||||
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
|
||||
| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
|
||||
| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - |
|
||||
| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - |
|
||||
| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------ | ------- |
|
||||
| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
|
||||
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
|
||||
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
|
||||
| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
|
||||
| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - |
|
||||
| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - |
|
||||
| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
|
||||
|
||||
## Proxy
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| ``HTTP_PROXY`` | HTTP proxy to use | Any valid HTTP proxy URL | - |
|
||||
| ``HTTPS_PROXY`` | HTTPS proxy to use | Any valid HTTPS proxy URL | - |
|
||||
| ``NO_PROXY`` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - |
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| -------------------- | ------------------------------------------------------------------- | ------------------------- | ------- |
|
||||
| `HTTP_PROXY` | HTTP proxy to use | Any valid HTTP proxy URL | - |
|
||||
| `HTTPS_PROXY` | HTTPS proxy to use | Any valid HTTPS proxy URL | - |
|
||||
| `NO_PROXY` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - |
|
||||
|
||||
## Advanced deployments
|
||||
|
||||
@@ -109,6 +106,6 @@ External redis is currently not supported with Proxmox Community Scripts
|
||||
The advanced deployments environment variables should only be used if you know what you are doing.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | ----------- |
|
||||
| ``DB_MIGRATIONS_DISABLED`` | Disable db migrations. For example for helm charts |
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | -------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
|
||||
@@ -9,17 +9,19 @@ tags:
|
||||
---
|
||||
|
||||
## Icon picker
|
||||
|
||||
Icons in Homarr are automatically requested from multiple sources:
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
|
||||
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||
- [Tabler icons](https://tabler-icons.io/)
|
||||
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
||||
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
|
||||
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
|
||||
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
|
||||
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||
- [Tabler icons](https://tabler-icons.io/)
|
||||
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
||||
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
|
||||
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
|
||||
|
||||
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
|
||||
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
|
||||
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
|
||||
|
||||
### Finding Missing Icons
|
||||
|
||||
@@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
|
||||
|
||||
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
|
||||
|
||||
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
|
||||
|
||||
### Using external icons
|
||||
|
||||
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
|
||||
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
|
||||
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
|
||||
|
||||
### Uploading a local image
|
||||
|
||||
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
|
||||
@@ -47,7 +47,6 @@ services:
|
||||
traefik.http.routers.homarr.rule: Host(`your.internal.dns.address.here.com`)
|
||||
traefik.http.routers.homarr.entrypoints: websecure
|
||||
traefik.http.routers.homarr-secure.app: homarr
|
||||
traefik.http.apps.homarr.loadbalancer.server.port: 7575
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
|
||||
@@ -84,7 +84,7 @@ Docker will mount the configuration files and icons to your host machine. Please
|
||||
### Updating
|
||||
To update Homarr, you must remove your container first. Make sure that you've mounted your data and that you have access to it, so your configuration doesn't get lost.
|
||||
|
||||
1. Run ``docker rm homarr`` to remove the container.
|
||||
1. Run ``docker rm -f homarr`` to stop and remove the container.
|
||||
2. Pull the latest Homarr image ``docker pull ghcr.io/homarr-labs/homarr:latest``.
|
||||
3. Re-run the command you used to install Homarr.
|
||||
|
||||
@@ -98,8 +98,8 @@ Want to update all your containers automatically? Checkout [Watchtower](https://
|
||||
|
||||
### Uninstalling
|
||||
|
||||
1. Obtain the container ID of your Homarr container using ``docker ps | grep homarr``.
|
||||
2. Delete the container using ``docker rm <your-container-id>``
|
||||
1. Obtain the container ID of your Homarr container using ``docker ps -a | grep homarr``.
|
||||
2. Delete the container using ``docker rm -f <your-container-id>``
|
||||
3. Prune unused Docker images using ``docker image prune`` (*Warning: this also removes you other unused images - not just Homarr*)
|
||||
4. (Optional): Prune any network or volumes if you use any.
|
||||
5. (Optional): Remove Homarr from your reverse proxy, VPNs or tunnels if you use any.
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.73.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "What's Up Docker"
|
||||
description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { wudIntegration } from ".";
|
||||
import { wudWidget } from "@site/docs/widgets/whats-up-docker";
|
||||
|
||||
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
|
||||
|
||||
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: [],
|
||||
steps: [],
|
||||
},
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
|
||||
"Enter that username and password in Homarr — Basic authentication is handled automatically",
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const wudIntegration: IntegrationDefinition = {
|
||||
name: "What's Up Docker",
|
||||
description:
|
||||
"What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
|
||||
path: "../../integrations/whats-up-docker",
|
||||
};
|
||||
@@ -7,8 +7,8 @@ tags:
|
||||
- Ping
|
||||
---
|
||||
|
||||
Apps are the building blocks of your Homarr board.
|
||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||
Apps are the building blocks of your Homarr board.
|
||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||
Apps are used in the bookmark and app widget.
|
||||
|
||||
## Create an app
|
||||
@@ -27,8 +27,8 @@ The name must be at least one character long and can be chosen freely.
|
||||
|
||||
### Icon URL
|
||||
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.
|
||||
|
||||

|
||||
|
||||
@@ -40,11 +40,14 @@ An optional description that can be shown as tooltip on the app widget.
|
||||
|
||||
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
|
||||
|
||||
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
|
||||
|
||||
### Ping URL
|
||||
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
|
||||
It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
|
||||
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
|
||||
|
||||
## List of apps
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
---
|
||||
tags:
|
||||
- Assistant
|
||||
- AI
|
||||
- Administration
|
||||
- Security
|
||||
---
|
||||
|
||||
# Assistant
|
||||
|
||||
Administrators can configure the instance-wide AI assistant from **Management → Assistant**.
|
||||
It is available to signed-in users from the user menu, with `Shift + A`, and from the
|
||||
`Cmd/Ctrl + K` command menu. **Ask Homarr Assistant** is always available as a local result in the default
|
||||
search. Selecting it with an existing query sends that query in the background; selecting it without a
|
||||
query turns the command-menu search field into a prompt composer. Press
|
||||
`Enter` to send the prompt in the background without opening the conversation. The search field receives
|
||||
focus whenever the command menu opens, including when it is opened by an assistant tool. Sending from the
|
||||
command menu preserves any draft in the expanded conversation. The local result is temporarily disabled while
|
||||
the assistant is responding or waiting for an approval.
|
||||
|
||||
While a response is running, a compact activity bar appears at the bottom of the page and the user-menu
|
||||
avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed
|
||||
responses add an unread count to the user menu until they are opened or dismissed. The expanded
|
||||
conversation has a minimize action that returns it to the activity bar without interrupting a response.
|
||||
The transcript follows streamed output while it is already at the bottom, including fast responses. Scrolling
|
||||
up releases that follow state until **Scroll to latest** is selected or the user returns to the bottom.
|
||||
|
||||
`Shift + A` opens the compact conversation panel directly. The shortcut is ignored while typing in text
|
||||
fields, selectors, and editable content.
|
||||
|
||||
The **Assistant** board widget embeds the same persisted conversation and full interaction surface directly
|
||||
on a dashboard. By default it follows the current conversation. Widget settings can instead pin a specific
|
||||
conversation; the conversation picker stays hidden until **Pinned conversation** is selected.
|
||||
|
||||
When the assistant is unavailable, its command-menu result remains visible but disabled and explains that
|
||||
an administrator must configure and enable it.
|
||||
|
||||
## Configure a model provider
|
||||
|
||||
Homarr includes presets for OpenRouter, OpenAI, Anthropic Claude, Google Gemini, xAI, Groq, Mistral AI,
|
||||
DeepSeek, Together AI, Ollama, and LM Studio. Provider icons make the presets easy to identify. A custom
|
||||
preset connects any other service that implements the OpenAI Chat Completions API.
|
||||
|
||||
The provider list also shows **Homarr — Coming soon** as a disabled option. The future service will be a
|
||||
Homarr-hosted OpenRouter proxy with the same model discovery, tool-calling, and server-tool features. Keeping
|
||||
it visible reserves a clear place for that service without making an unavailable backend selectable today.
|
||||
|
||||
The management page presents setup as three compact sections: provider and endpoint, credentials, then
|
||||
model and availability. A status summary at the top shows whether the connection is ready, whether a key
|
||||
is protected, which model is selected, and whether users can open the assistant.
|
||||
|
||||
1. Choose a provider preset or **Custom endpoint**, then confirm the API base URL. It usually ends in `/v1`.
|
||||
2. Add an API key if the service requires bearer authentication. Additional authentication or routing
|
||||
headers are available in the collapsed advanced section.
|
||||
3. Save the provider connection.
|
||||
4. Refresh the model list and select a discovered model. Homarr displays the friendly model name but
|
||||
stores and sends the provider's exact model ID.
|
||||
5. Enable the assistant and save the configuration.
|
||||
|
||||
The model discovery path defaults to `/models` and is appended to the API base URL. Leave it blank when
|
||||
the service does not expose model discovery. A manual model ID can still be used when discovery is
|
||||
disabled, unavailable, or returns no models.
|
||||
|
||||
API keys and additional header values are encrypted in the Homarr database. They are decrypted only on
|
||||
the server for discovery and model requests, and they are never returned to the browser. Changing the
|
||||
provider or base URL clears existing credentials unless replacements are submitted with the change. This
|
||||
prevents credentials for one service from being sent to another endpoint.
|
||||
|
||||
When a key is already stored, the page shows its protected state instead of an ambiguous empty password
|
||||
field. **Replace key** starts a two-step flow: enter the new key, then review the provider, destination,
|
||||
and impact before confirming. The existing key remains active until confirmation, and its value cannot be
|
||||
restored after replacement. Removing only the API key also requires explicit confirmation. **Remove all
|
||||
credentials** uses a separate destructive confirmation because it also clears the selected model and
|
||||
disables the assistant.
|
||||
|
||||
:::info Container networking
|
||||
|
||||
For a containerized Homarr installation, `localhost` refers to the Homarr container. Use a resolvable
|
||||
container name, internal DNS name, or host gateway address to reach Ollama, LM Studio, or another local
|
||||
model server.
|
||||
|
||||
:::
|
||||
|
||||
The configured endpoint must support streaming Chat Completions and structured tool calls for the full
|
||||
assistant experience. Model discovery alone does not guarantee tool support, except when OpenRouter
|
||||
explicitly advertises the model's `tools` capability.
|
||||
|
||||
OpenRouter web search is an opt-in option in the model section. It registers OpenRouter's model-callable
|
||||
[`openrouter:web_search` server tool](https://openrouter.ai/docs/guides/features/server-tools/web-search)
|
||||
alongside Homarr's tools, so OpenRouter performs each search without
|
||||
exposing the stored credential to the browser. The model decides when live research is needed and can use the
|
||||
grounded results and source links in responses or dashboard notes. Homarr limits each assistant request to
|
||||
three searches with five results per search. OpenRouter charges web-search usage separately from model tokens.
|
||||
Completed responses show a compact web-search activity card when OpenRouter reports search usage. OpenRouter
|
||||
does not expose these server-side searches as ordinary client-executed tool calls, so Homarr shows the reported
|
||||
search count and the safe title and URL from each returned `url_citation` annotation instead. Citation excerpts
|
||||
stay in the provider response and are not persisted in the activity card.
|
||||
|
||||
The option is also available for custom endpoints that forward OpenRouter's server-tools request format. This
|
||||
supports OpenRouter proxies, including the future Homarr Router provider. Leave it disabled for ordinary
|
||||
OpenAI-compatible endpoints because the `openrouter:*` tool type is not part of the standard OpenAI API.
|
||||
|
||||
The Claude preset uses Anthropic's OpenAI SDK compatibility layer. Anthropic describes that layer as
|
||||
suited to testing and comparison rather than as its full native API; unsupported OpenAI fields may be
|
||||
ignored. Streaming responses and tool calls are supported, which are the capabilities Homarr Assistant
|
||||
uses. Choose OpenRouter or another provider when you prefer a provider-neutral production endpoint.
|
||||
|
||||
## Conversations and tools
|
||||
|
||||
Conversations and messages are stored per user in the Homarr database. The assistant stays out of the
|
||||
page layout until it is opened. Users can start, revisit, and delete conversations from the
|
||||
conversation menu in the floating panel.
|
||||
|
||||
Messages support copy, edit-and-resend, response regeneration and branch navigation, Markdown export,
|
||||
and helpful/not-helpful feedback.
|
||||
Selecting text in a message exposes a quote action that carries the selected passage into the next prompt.
|
||||
Files returned by a compatible model can be downloaded directly from the message.
|
||||
Assistant responses render GitHub-flavored Markdown, including headings, links, lists, task lists, tables,
|
||||
quotes, inline code, and fenced code blocks. Line breaks are preserved, and Homarr repairs common streamed
|
||||
responses where a provider returns escaped line breaks or places a bold-label list on one line.
|
||||
Fenced `mermaid` blocks render as diagrams in the current color scheme using Mermaid's strict security mode;
|
||||
invalid diagrams fall back to their source instead of breaking the response.
|
||||
When Homarr's icon search returns an image URL, visual result tables render that actual icon in a compact
|
||||
image column instead of substituting an emoji. Assistant-provided Markdown images accept safe relative,
|
||||
HTTP, and HTTPS sources and load without forwarding the Homarr page as the referrer.
|
||||
Images up to 1 MB and text, Markdown, CSV, HTML, XML, or JSON documents up to 350 KB can be attached by
|
||||
using the paperclip or dropping files onto the composer. A request can contain up to five attachments.
|
||||
Image understanding depends on the selected model; document text is extracted locally by Homarr and sent
|
||||
as part of the prompt.
|
||||
|
||||
Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations,
|
||||
boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on
|
||||
the server before adding it to the model context. Selected references appear as compact, typed pills in the
|
||||
composer and sent message, while preserving assistant-ui's underlying directive syntax for the model. When the
|
||||
assistant cites one of those references in its own response, Homarr renders the same typed pill and uses the
|
||||
stored app or integration icon when one is available. Type `/`
|
||||
for common prompt shortcuts. Both menus support keyboard navigation and keep the active result visible while
|
||||
moving through long lists. The selector below the composer combines searchable, provider-grouped models with
|
||||
their description, context size and image-input capability, plus the thinking-level control. Model selection is
|
||||
stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, or set
|
||||
from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning setting and
|
||||
still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider
|
||||
streams it. The **Thinking** disclosure can be opened or closed without interrupting the response.
|
||||
|
||||
Each completed response includes a circular context-window meter. Select it to open request details when
|
||||
the provider reports them: input, output, cached-input and reasoning tokens, used and remaining context,
|
||||
time to first output, total duration, output speed, agent-step timing, and cost. OpenRouter-reported cost
|
||||
is labeled as reported.
|
||||
While the next response is still streaming, the meter keeps the last completed reading for the same model and
|
||||
context capacity instead of briefly showing an incorrect zero. It switches to the new request when token usage
|
||||
arrives; incompatible model capacities are never mixed.
|
||||
OpenRouter responses request inline usage and routing metadata. When request details are opened, Homarr also
|
||||
loads the server-side generation record for each model step and briefly polls while OpenRouter finishes
|
||||
processing it. The details separate Homarr's end-to-end timing from OpenRouter's provider latency,
|
||||
generation time, and provider throughput, and can include normalized and native token counts, moderation
|
||||
latency, cache discount, fallback count, service tier, data region, BYOK status, routed provider, generation
|
||||
identifier, exact cost, and upstream inference cost. Provider credentials remain server-side; the browser
|
||||
can request only generation identifiers already stored on a conversation owned by the signed-in user and
|
||||
signed by the Homarr server for that user and conversation. Successful generation lookups are cached
|
||||
briefly so polling and multi-step requests do not repeatedly query OpenRouter for the same record.
|
||||
When a provider omits cost but model discovery includes prompt and completion pricing, Homarr labels the
|
||||
calculated value as estimated. Missing provider data is shown as not reported rather than inferred.
|
||||
|
||||
The assistant can use the same internal tool definitions as the [Homarr MCP server](./mcp).
|
||||
Each tool call runs as the signed-in user, so normal Homarr permission checks still apply. Read-only
|
||||
procedures can run during the agent loop. Mutating procedures require an explicit approval in the
|
||||
conversation before they execute. The approval appears as a large **Approve and run** or **Deny** action;
|
||||
the assistant does not require a second typed confirmation first. Tool cards expose their status, timing,
|
||||
validated input, result, and error state without flooding the conversation with raw JSON by default.
|
||||
Common collection and widget results are summarized inline as compact responsive cards, while the full
|
||||
structured result remains available in the tool card details. Icon searches show the returned image variants,
|
||||
file formats, and repositories instead of the size of the complete icon catalog.
|
||||
|
||||
Large tool-driven requests can use up to 32 agent steps and a larger response budget within one request, so
|
||||
multi-item dashboard changes normally finish without requiring a follow-up “Continue” message. Independent
|
||||
read-only lookups may be performed together, while every mutation still pauses for its normal approval.
|
||||
|
||||
The assistant can create a formatted dashboard note by adding a **Notebook** widget. Ask it for a note with
|
||||
headings, lists, tasks, links, emphasis, tables, or images; it generates compatible rich-text content, shows
|
||||
the native widget configuration form and standard mutation approval, then places the widget in the next
|
||||
available dashboard position. The generated content is retained even though the Notebook content field is
|
||||
normally hidden from widget settings.
|
||||
|
||||
Every assistant-created dashboard widget now passes through Homarr's native widget editor before it is added.
|
||||
The form fills widget defaults, validates widget-specific options, and only offers compatible integrations that
|
||||
the current user can use. Required integrations cannot be left empty, single-integration widgets cannot receive
|
||||
multiple integrations, and the reviewed values are passed unchanged into the final approval-gated mutation.
|
||||
|
||||
Custom-widget tools let an administrator list, inspect, create, and update definitions. The custom-widget
|
||||
form's **Design with AI** action sends a focused request into the current conversation, where the assistant
|
||||
can propose the matching mutation and use the same approval controls. Existing stored secret values are
|
||||
reported only as configured/not configured and are preserved on update unless replacements are explicitly
|
||||
provided.
|
||||
|
||||
The shield control in the conversation header can enable automatic approvals for the current conversation.
|
||||
While enabled, Homarr accepts every affirmative approval step as it arrives: protected mutations are approved,
|
||||
presented app details and board changes are used, and structured questions with an explicitly categorized
|
||||
**Confirm** answer choose that answer. Neutral choices such as selecting a board still wait for input because
|
||||
they do not have a safe affirmative default. Automatic approvals turn off when you switch conversations, and
|
||||
the shared approval tracker prevents the same call from being handled twice when both a board widget and the
|
||||
floating assistant are visible.
|
||||
|
||||
When the assistant genuinely needs missing information or a choice, it can pause with a structured
|
||||
question. Each proposed answer is visibly categorized as **Confirm**, **Decline**, or **Option**, so the effect
|
||||
of automatic approval is unambiguous. Users can select one of the proposed answers or choose **Other** and
|
||||
enter their own response. While a question is unanswered, its controls stay in a dedicated area immediately
|
||||
above the composer instead of requiring the user to find the tool call in the transcript. The answered choice
|
||||
then remains in the conversation history.
|
||||
Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the
|
||||
question or approval that needs attention and opens directly back to the request. While an answer, app
|
||||
form, or mutation approval is pending, the composer preserves its draft but does not replace the pending
|
||||
tool call with a new message. If the provider stops before an interactive tool supplies complete arguments,
|
||||
Homarr replaces the loading state with an explicit error and confirms that no change was made.
|
||||
|
||||
App creation uses Homarr's native app form inside the conversation. The form reuses the local icon picker,
|
||||
including automatic matching and searches through the `icon.findIcons` procedure, before the final
|
||||
`app.create` approval is requested. App fields are filled only after the complete tool input has arrived, so
|
||||
streaming partial arguments cannot replace the proposed values with empty fields. The model is instructed not
|
||||
to invent external icon CDN URLs.
|
||||
|
||||
Board appearance changes use a similar review flow. The assistant first reads the board's current settings,
|
||||
including its complete custom stylesheet, and then opens an editable form with Custom CSS, Appearance,
|
||||
Background, and General tabs. The form highlights how many fields were proposed, preserves settings the
|
||||
assistant did not change, and returns only values that differ from the current board. Custom CSS can be
|
||||
copied or restored before continuing, and Homarr warns when it contains an imported stylesheet or remote
|
||||
resource URL. Continuing from the form opens the normal mutation approval before anything is saved.
|
||||
|
||||
Ask for changes such as “make the cards denser on my Home board,” “add a mobile CSS rule without removing
|
||||
my existing styles,” or “change the board colors and disable app status checks.” The current user must have
|
||||
modify permission on the selected board. The empty conversation includes an **Improve a dashboard** starter,
|
||||
and `/style` prepares the same guided workflow from the composer.
|
||||
|
||||
Homarr also exposes a small set of browser-only actions to the assistant. These actions can navigate to
|
||||
safe internal routes, refresh the current view, and open Homarr interfaces such as the command menu and media request search.
|
||||
They never receive provider credentials and cannot navigate to arbitrary external URLs.
|
||||
|
||||
After an approved mutation completes, Homarr automatically refetches active page and dashboard data so
|
||||
board items, settings, custom CSS, apps, and other visible results update without a full browser reload.
|
||||
The refresh action in the conversation header performs the same safe refetch manually. It keeps the
|
||||
conversation open while refreshing both client queries and server-rendered route data.
|
||||
|
||||
:::warning
|
||||
|
||||
Assistant prompts and selected tool results are sent to the configured model endpoint. Review that
|
||||
service's privacy and retention terms before enabling this feature for an instance.
|
||||
|
||||
:::
|
||||
@@ -9,11 +9,11 @@ tags:
|
||||
- Layout
|
||||
---
|
||||
|
||||
import iconColorImage from './img/icon-color.png'
|
||||
import itemRadiusImage from './img/item-radius.png'
|
||||
import layoutLargeImage from './img/layout-large.png'
|
||||
import layoutMediumImage from './img/layout-medium.png'
|
||||
import layoutSmallImage from './img/layout-small.png'
|
||||
import iconColorImage from "./img/icon-color.png";
|
||||
import itemRadiusImage from "./img/item-radius.png";
|
||||
import layoutLargeImage from "./img/layout-large.png";
|
||||
import layoutMediumImage from "./img/layout-medium.png";
|
||||
import layoutSmallImage from "./img/layout-small.png";
|
||||
|
||||
Each dashboard is called a "board".
|
||||
You can create as many boards as you want.
|
||||
@@ -24,24 +24,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||

|
||||
|
||||
### Name
|
||||
|
||||
The name must not include any special characters and must be at least one character long.
|
||||
Underscores and hyphen-minus are allowed.
|
||||
|
||||
### Column count
|
||||
Homarr has a complex drag and drop system that can be used from the browser.
|
||||
|
||||
Homarr has a complex drag-and-drop system that can be used from the browser.
|
||||
The column count defines how many horizontal columns are available to drag and drop elements to.
|
||||
|
||||
### Public
|
||||
|
||||
This will enable public access for everyone - regardless of whether they are logged in or not.
|
||||
This can be really useful if you want to have public boards in your company or for family and fiends.
|
||||
This can be really useful if you want to have public boards in your company or for family and friends.
|
||||
Modifications for public boards must be allowed explicitly using user groups and their permissions.
|
||||
|
||||
## Board settings
|
||||
|
||||
You navigate to the board settings by clicking on these three dots:
|
||||
|
||||

|
||||
@@ -50,8 +55,12 @@ Alternatively, you can also navigate from the board page itself using the cog ic
|
||||
|
||||

|
||||
|
||||
All board settings are edited on a single page. Click the **Save changes** button in the save bar at the bottom to apply them, or **Discard** to restore the last saved values. The save bar only appears when you have unsaved changes. Access control and the Danger zone are independent sections and are applied immediately.
|
||||
|
||||
### General
|
||||
|
||||
#### Page title
|
||||
|
||||
This is the title that will be visible at the top left in the navigation bar:
|
||||
|
||||

|
||||
@@ -59,6 +68,7 @@ This is the title that will be visible at the top left in the navigation bar:
|
||||
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
|
||||
|
||||
#### Meta title
|
||||
|
||||
This is the secondary title of the page that is not directly visible in the page itself.
|
||||
Browser will use it to set the title of tabs:
|
||||
|
||||
@@ -67,10 +77,12 @@ Browser will use it to set the title of tabs:
|
||||
Homarr will fall back to the board name and a suffix if nothing is set.
|
||||
|
||||
#### Logo image URL
|
||||
|
||||
This is the URL that is used for the image at the top left.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
#### Favicon image URL
|
||||
|
||||
This is the URL that is used for the favicon in the tab title.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
@@ -94,71 +106,83 @@ Amount of columns for this layout. Defines how many items can be placed in a row
|
||||
|
||||
#### Breakpoint
|
||||
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minmum of the next layout is reached the layout will change.
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minimum of the next layout is reached the layout will change.
|
||||
|
||||
#### Preview
|
||||
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
|
||||
### Background
|
||||
|
||||
#### Background image URL
|
||||
|
||||
Homarr can display a background image behind apps and widgets.
|
||||
This URL can be used to define what image should be shown.
|
||||
|
||||
#### Background image attachment
|
||||
|
||||
This defines whether the background should move when the user scrolls.
|
||||
|
||||
#### Background image size
|
||||
|
||||
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
|
||||
|
||||
#### Background image repeat
|
||||
|
||||
This defines whether the background should be repeated if it cannot cover the entire page.
|
||||
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):
|
||||
|
||||

|
||||
|
||||
### Appearance
|
||||
|
||||
#### Primary color
|
||||
|
||||
Color that can be chosen freely to give accents to active elements.
|
||||
|
||||
#### Secondary color
|
||||
|
||||
Color that can be chosen freely to give accents.
|
||||
|
||||
#### Opacity
|
||||
|
||||
Opacity of the colors.
|
||||
|
||||
#### Icon color
|
||||
|
||||
Change the color of your bookmark / app icons.
|
||||
|
||||
<img src={iconColorImage} height={100} />
|
||||
<img src={iconColorImage} height={100} />
|
||||
|
||||
#### Item radius
|
||||
|
||||
Change the radius of your items / categories.
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
### Custom CSS
|
||||
|
||||
This is the field where you can enter custom CSS.
|
||||
The styling will only be applied on the respective board page.
|
||||
|
||||
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's Custom
|
||||
CSS and other visual or behavior settings. The assistant reads the current stylesheet first and presents an
|
||||
editable review form before requesting approval to save the change.
|
||||
|
||||
[See this guide on how to work with custom CSS](/docs/advanced/styling/).
|
||||
|
||||
### Behaviour
|
||||
|
||||
#### Disable app status
|
||||
|
||||
This will disable the status check for apps.
|
||||
|
||||
### Access control
|
||||
|
||||
With the access control, you can permit a single user or a group to your board.
|
||||
The users and groups can have one of these three levels of access:
|
||||
|
||||
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
|
||||
|----------------------|------------|-----------|----------|--------------------------|
|
||||
| -------------------- | ---------- | --------- | -------- | ------------------------ |
|
||||
| View board | X | | | |
|
||||
| Modify board | X | X | X | |
|
||||
| Full access | X | X | X | X |
|
||||
@@ -177,22 +201,27 @@ After editing permissions, the save button below the table must be pressed.
|
||||
Otherwise, all changes will be discarded.
|
||||
|
||||
### Danger zone
|
||||
|
||||
#### Rename
|
||||
|
||||
Renaming a board will break all existing links to it permanently with no redirect.
|
||||
|
||||
#### Board visibility
|
||||
|
||||
By default, boards will be private. This means only authenticated users that are authorized have access.
|
||||
Making a board public will expose it to everyone - including unauthenticated users.
|
||||
|
||||
#### Delete
|
||||
|
||||
This will permanently delete the board.
|
||||
This action is irreversible and cannot be undone.
|
||||
|
||||
## Delete boards
|
||||
|
||||
The deletion of boards is permanent and cannot be undone.
|
||||
There are two ways to delete a board:
|
||||
|
||||
- Navigate to the settings of your board, open the "Danger zone" dropdown and click on the "delete this board" button.
|
||||
- Navigate to the settings of your board, open the "Danger zone" section and click on the "delete this board" button.
|
||||
- Navigate to the list of boards in the management pages, click the three dots and click on "delete permanently".
|
||||
|
||||
Both actions will execute the same deletion process.
|
||||
@@ -200,7 +229,7 @@ Both actions will execute the same deletion process.
|
||||
## Duplicate boards
|
||||
|
||||
:::note
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
nor the integrations the user does not have access to.
|
||||
:::
|
||||
|
||||
@@ -210,47 +239,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an
|
||||

|
||||
|
||||
## Setting boards as your home board
|
||||
|
||||
The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL.
|
||||
All the following paths will lead to the home board:
|
||||
- ``my-homarr.com``
|
||||
- ``my-homarr.com/boards``
|
||||
|
||||
- `my-homarr.com`
|
||||
- `my-homarr.com/boards`
|
||||
|
||||
Your home board will have a special small badge at the top right in the list of boards:
|
||||
|
||||

|
||||
|
||||
### For yourself
|
||||
|
||||
To set the home board yourself, the easiest option is to set it directly in the list of boards:
|
||||
|
||||

|
||||
|
||||
Alternatively, you can also define it via your user profile:
|
||||
|
||||
1. Click on your user profile at the top right
|
||||
2. Click on ``Your preferences``
|
||||
3. Change the home board in the ``home board`` dropdown and click on ``Save``.
|
||||
2. Click on `Your preferences`
|
||||
3. Change the home board in the `home board` dropdown and click on `Save`.
|
||||
|
||||
### For other users
|
||||
|
||||
1. Navigate to the users: ``Management`` > ``Users`` > ``Manage``
|
||||
1. Navigate to the users: `Management` > `Users` > `Manage`
|
||||
2. Click on the username of the user that you want to adjust
|
||||
3. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
### For user groups
|
||||
|
||||
1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage``
|
||||
2. Go to tab ``Settings``
|
||||
3. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage`
|
||||
2. Go to tab `Settings`
|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
### For whole server
|
||||
|
||||
You can also set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
|
||||
1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards``
|
||||
2. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
1. Navigate to the settings: `Management` > `Settings` > `Boards`
|
||||
2. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
:::note
|
||||
For the global home board you can only select boards that are public.
|
||||
|
||||
@@ -50,17 +50,9 @@ To create a custom widget, navigate to the **Custom Widgets** section in the man
|
||||
| **Action Button** | Clickable button executing the API request | Triggers: restart, clear cache |
|
||||
| **Custom JSX** | Custom JSX template with Mantine v9 components | Full layout control |
|
||||
|
||||
### Design with AI
|
||||
### AI-Powered JSX Generation
|
||||
|
||||
The **Design with AI** action is available while creating or editing a custom widget. Describe what the
|
||||
widget should show and, when [Homarr Assistant](../assistant) is enabled, send the request directly from
|
||||
the form. The assistant receives the widget schema, the current non-secret configuration, and the latest
|
||||
test response. It can then propose and run the matching create or update tool after you approve the
|
||||
mutation in the conversation.
|
||||
|
||||
Run **Test** first when possible so the prompt includes the endpoint's real response shape. Saved secret
|
||||
values are never added to the prompt. **Copy to clipboard** remains available when Homarr Assistant is
|
||||
disabled or when you prefer to use another AI service such as ChatGPT, Claude, or Gemini.
|
||||
When editing or creating a custom widget with the **Custom JSX** display type, you'll find a **Copy Prompt** button. Clicking it copies a pre-formatted prompt describing your widget's name, API endpoint, and available Mantine components. Paste this into your preferred AI assistant (ChatGPT, Claude, Copilot, etc.) to generate a JSX template tailored to your data.
|
||||
|
||||
<details>
|
||||
<summary>Example: Pokédex widget using Custom JSX</summary>
|
||||
|
||||
@@ -12,10 +12,6 @@ Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) serve
|
||||
|
||||
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
|
||||
|
||||
The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
|
||||
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
|
||||
approval before running mutating procedures.
|
||||
|
||||
## Getting Started
|
||||
|
||||
The MCP endpoint lives under the **API** page at `Management` → `Tools` → `API` → **MCP** tab.
|
||||
@@ -77,7 +73,13 @@ Replace `<your-api-key>` with your actual API key. Never commit API keys to vers
|
||||
"mcpServers": {
|
||||
"homarr": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:<your-api-key>"]
|
||||
"args": [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://your-homarr-instance.com/api/mcp/mcp",
|
||||
"--header",
|
||||
"ApiKey:<your-api-key>"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +131,7 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query**
|
||||
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
|
||||
| `apiKeys` | List API keys | GET |
|
||||
| `beszel` | Systems status, alerts, historical system metrics | GET |
|
||||
| `board` | List boards, read settings/CSS, create/update/delete board | GET + POST |
|
||||
| `board` | List boards, get board by ID, create/update/delete board | GET + POST |
|
||||
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
|
||||
| `icon` | Search icons | GET |
|
||||
| `info` | Server health, version info | GET |
|
||||
|
||||
@@ -10,109 +10,126 @@ tags:
|
||||
|
||||
# Settings
|
||||
|
||||
The settings page has a few options that will be applied to the entire Homarr application.
|
||||
They may require a full page reload for changes to apply.
|
||||
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.
|
||||
|
||||
## Analytics
|
||||
|
||||
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
|
||||
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
|
||||
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
|
||||
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
|
||||
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
|
||||
We collect this data to see what features people ACTUALLY use.
|
||||
We use this data to decide where we spend our development time.
|
||||
|
||||
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
|
||||
We do not sell the data. We do not share the data with third parties.
|
||||
The data does not contain personal information and does not identify you or your installation.
|
||||
|
||||
We understand that you might not want Homarr to communicate with our services.
|
||||
By unchecking the `Send anonymous analytics` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
|
||||
If you are paranoid, you can permanently block `umami.homarr.dev` from your DNS / firewall.
|
||||
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
|
||||
The server sends the data once a week and when the server starts.
|
||||
|
||||
Additionally, you opt in to more data collection to help us provide more useful data.
|
||||
### What we collect
|
||||
|
||||
### Integration data
|
||||
Homarr collects technical data about your installation:
|
||||
|
||||
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
- Your Homarr version
|
||||
- Your database type (SQLite, MySQL, or PostgreSQL)
|
||||
- Whether Docker or Kubernetes is enabled
|
||||
- Your enabled authentication providers
|
||||
- Your operating system and CPU architecture
|
||||
- Your default language
|
||||
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
|
||||
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
|
||||
- The widget types you use and how many you have of each
|
||||
|
||||
### Widget data
|
||||
Homarr does NOT collect:
|
||||
|
||||
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
- URLs
|
||||
- Names
|
||||
- Usernames, emails, or passwords
|
||||
- Integration secrets or API keys
|
||||
- Error messages or logs
|
||||
- Any data that can identify you
|
||||
|
||||
### Users data
|
||||
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
|
||||
|
||||
Enabling this optional data will send the count of users and whether you activated SSO.
|
||||
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
|
||||
This is disabled by default.
|
||||
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
|
||||
|
||||
### Disable analytics
|
||||
|
||||
Analytics are enabled by default. To disable analytics:
|
||||
|
||||
1. Open the Settings page.
|
||||
2. Uncheck the "Help us improve Homarr" checkbox.
|
||||
3. Reload the full page.
|
||||
|
||||
When analytics are disabled, Homarr does not send any data to PostHog.
|
||||
|
||||
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
|
||||
|
||||
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
|
||||
|
||||
## Search Engine Optimization (SEO)
|
||||
|
||||
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
|
||||
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
|
||||
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
|
||||
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
|
||||
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
|
||||
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
|
||||
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
|
||||
|
||||
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
|
||||
|
||||
### No index
|
||||
|
||||
This will ask crawlers to not index your Homarr instance on any indexes.
|
||||
It is up to the crawler to properly respect this request.
|
||||
By default, this setting is enabled. Disabling it will allow indexing.
|
||||
This setting asks crawlers to not index your instance.
|
||||
This setting is enabled by default. Disable this setting to allow indexing.
|
||||
|
||||
### No follow
|
||||
|
||||
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
|
||||
By default, this setting is enabled and will ask crawlers to not follow any links.
|
||||
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
|
||||
Crawlers follow links to discover new pages.
|
||||
This setting asks crawlers to not follow links. This setting is enabled by default.
|
||||
Crawler behavior can differ between search engines.
|
||||
|
||||
### No translate
|
||||
|
||||
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
|
||||
This can lead to some shifting and can be undesired in some cases. By enabling this,
|
||||
you ask crawlers to never translate and never prompt users for translation.
|
||||
It should be noted that some browsers will ignore this and translate regardless.
|
||||
Some browsers offer to translate pages.
|
||||
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
|
||||
|
||||
### No site links search box
|
||||
|
||||
This setting is exclusive to Google.
|
||||
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
|
||||
The user can search them directly in Google.
|
||||
You can ask the crawlers to not allow searches by enabling this setting.
|
||||
This setting is disabled by default.
|
||||
This setting applies to Google only.
|
||||
Google can show a search box below your domain.
|
||||
This setting asks Google to not show this box. This setting is disabled by default.
|
||||
|
||||
## Boards
|
||||
|
||||
### Global home board
|
||||
|
||||
You can set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
Note that the board must be public to be used as a global home board.
|
||||
You can set a global home board.
|
||||
This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
|
||||
The board must be public.
|
||||
|
||||
### Global mobile board
|
||||
|
||||
You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header).
|
||||
It will similar to the global home board be as fallback for normal users and for any anonymous users.
|
||||
It also has to be public.
|
||||
You can set a global mobile board.
|
||||
Homarr shows this board on mobile devices instead of the global home board.
|
||||
Homarr detects mobile devices with the User-Agent header.
|
||||
The board must be public.
|
||||
|
||||
### App status
|
||||
|
||||
#### Enable status by default
|
||||
|
||||
You can decide if the app status should be enabled by default when adding a new app to the board.
|
||||
You can decide if app status is enabled when you add a new app.
|
||||
|
||||
#### Force disable status
|
||||
|
||||
You can decide if the app status should be disabled for all boards.
|
||||
|
||||
###
|
||||
You can decide if app status is disabled on all boards.
|
||||
|
||||
## Appearance
|
||||
|
||||
### Default color scheme
|
||||
|
||||
You can set the default color scheme for all users that have not set a color scheme themselves.
|
||||
You can set the default color scheme for users without their own color scheme.
|
||||
|
||||
## Culture
|
||||
|
||||
### Default language
|
||||
|
||||
You can set the default language for all users that have not set a language themselves.
|
||||
You can set the default language for users without their own language.
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
title: "Assistant"
|
||||
description: "Have a complete assistant conversation directly on a board."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
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 { assistantWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={assistantWidget} categories={["Productivity"]} />
|
||||
|
||||
The Assistant widget brings the same conversation available from the user menu and command menu onto a
|
||||
board. Messages, conversations, tool calls, approvals, files, mentions, model controls, reasoning controls,
|
||||
usage details, and response actions remain synchronized with the app-wide assistant.
|
||||
|
||||
The assistant must be enabled by an administrator in **Management → Assistant**. Signed-out users and users
|
||||
on an instance without an enabled model see an unavailable state instead of a broken composer.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
The default widget size is ten columns by four rows and automatically fits boards with fewer columns. Controls
|
||||
and labels condense as the widget becomes narrower, while the conversation remains scrollable and usable on
|
||||
touch devices. When a board layout does not provide enough height for conversation controls, the widget shows
|
||||
a compact **Open assistant** action instead of squeezing an unusable composer into the card. Increase its height
|
||||
in that layout to keep the complete conversation inline.
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={assistantWidget.configuration} />
|
||||
|
||||
**Current conversation** follows whichever conversation is active in the user-menu panel, `Shift + A`, or
|
||||
the command menu. This is the default and keeps every assistant entry point synchronized.
|
||||
|
||||
**Pinned conversation** reveals a searchable conversation selector. A pinned widget never changes the
|
||||
active conversation silently: select **Open conversation** inside the widget before continuing that thread.
|
||||
|
||||
### Conversation features
|
||||
|
||||
- Create, switch, rename, and delete conversations.
|
||||
- Send text and supported file or image attachments.
|
||||
- Mention permitted apps, integrations, boards, widgets, and tools with `@`.
|
||||
- Use `/` shortcuts for common dashboard tasks.
|
||||
- Review tool calls, approve mutations, and answer structured assistant questions inline.
|
||||
- Answer pending structured questions from the dock above the composer without scrolling through the conversation.
|
||||
- Enable conversation-scoped automatic approvals from the shield control to approve protected mutations, use presented form details, and choose answers categorized as affirmative without individual clicks.
|
||||
- Edit user messages and copy, regenerate, branch, quote, export, or rate responses.
|
||||
- Inspect Markdown, sources, generated files, tool results, reasoning, token use, context-window use, timing,
|
||||
speed, provider routing, and cost.
|
||||
- Change the model and reasoning effort for the active conversation.
|
||||
- Refresh the current board from the conversation header. Approved mutations also refresh visible board data automatically.
|
||||
|
||||
The expanded assistant and board widget use the same persisted thread runtime. A response continues in the
|
||||
background when the widget leaves the viewport or the expanded panel is minimized.
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { WidgetDefinition } from "@site/src/types";
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
|
||||
export const assistantWidget: WidgetDefinition = {
|
||||
icon: IconRobot,
|
||||
name: "Assistant",
|
||||
description: "Have a complete assistant conversation directly on a board.",
|
||||
path: "../../widgets/assistant",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Conversation",
|
||||
description: "Follow the active conversation or attach the widget to one persisted conversation.",
|
||||
values: { type: "select", options: ["Current conversation", "Pinned conversation"] },
|
||||
defaultValue: "Current conversation",
|
||||
},
|
||||
{
|
||||
name: "Pinned conversation",
|
||||
description: "Search for a conversation. This field is hidden when Current conversation is selected.",
|
||||
values: { type: "string" },
|
||||
defaultValue: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -28,8 +28,6 @@ import { icalIntegration } from '@site/docs/integrations/ical';
|
||||
|
||||
The calendar widget shows a month view of events from different integrations.
|
||||
It allows you to view calendar events or upcoming releases from your media managers.
|
||||
Calendar requests use normal human month numbers (`1` for January through `12` for December), including when
|
||||
the same data is requested through Homarr's MCP or Assistant tools.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -45,20 +45,18 @@ Some proxies disallow iframes by default for this reason.
|
||||
You can use https://iframetest.com to check, if the site is allowing iframes.
|
||||
|
||||
If you manage all of your own applications, you can usually change that by configuring
|
||||
the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) to allow iframes.
|
||||
the appropriate headers (such as `Content-Security-Policy`) to allow iframes.
|
||||
|
||||
For example, to allow your site to be embedded in iframes from specific domains:
|
||||
|
||||
**Apache (.htaccess or httpd.conf):**
|
||||
```apache
|
||||
Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"
|
||||
Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com"
|
||||
```
|
||||
|
||||
**Nginx:**
|
||||
```nginx
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com";
|
||||
add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com";
|
||||
```
|
||||
|
||||
### Example URLs
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "What's Up Docker"
|
||||
description: "Displays how many watched containers have image updates available."
|
||||
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 { wudWidget } from ".";
|
||||
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
|
||||
|
||||
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
|
||||
|
||||
This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: wudIntegration,
|
||||
note: "Displays monitored container and available-update counts",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={wudWidget.configuration} />
|
||||
@@ -0,0 +1,37 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconBrandDocker } from "@tabler/icons-react";
|
||||
|
||||
export const wudWidget: WidgetDefinition = {
|
||||
icon: IconBrandDocker,
|
||||
name: "What's Up Docker",
|
||||
description: "Displays how many watched containers have image updates available.",
|
||||
path: "../../widgets/whats-up-docker",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show title",
|
||||
description: "Displays the icon and \"What's Up Docker\" label above the stats",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show update ring",
|
||||
description: "Displays a ring showing the percentage of monitored containers with a pending update",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Layout",
|
||||
description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)",
|
||||
values: { type: "select", options: ["Horizontal", "Vertical"] },
|
||||
defaultValue: "Horizontal",
|
||||
},
|
||||
{
|
||||
name: "Show container update list",
|
||||
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -26,5 +26,10 @@
|
||||
"**/*.md",
|
||||
"**/*.mdx",
|
||||
],
|
||||
"exclude": ["node_modules"],
|
||||
// `include` sweeps up **/*.js and allowJs is on, so anything the build emits into this package is
|
||||
// typechecked as source. The turbo `typecheck` task only dependsOn ["^topo"], not on its own
|
||||
// package's `build`, so `docusaurus build` and `tsc --noEmit` run concurrently - and tsc can observe
|
||||
// Docusaurus's transient `build/__server/` bundles and fail with TS1473 before the build removes
|
||||
// them. Excluding generated output makes typecheck independent of that scheduling.
|
||||
"exclude": ["node_modules", "build", ".docusaurus"],
|
||||
}
|
||||
+87
-13
@@ -1,4 +1,6 @@
|
||||
// Importing env files here to validate on build
|
||||
import path from "node:path";
|
||||
|
||||
import "@homarr/auth/env";
|
||||
import "@homarr/core/infrastructure/db/env";
|
||||
import "@homarr/common/env";
|
||||
@@ -13,7 +15,30 @@ const withNextIntl = createNextIntlPlugin({
|
||||
requestConfig: "../../packages/translation/src/request.ts",
|
||||
});
|
||||
|
||||
/**
|
||||
* Profiling build. React's production bundle strips the hooks React DevTools needs,
|
||||
* which is why the Profiler tab reports "Profiling not supported"; the profiling
|
||||
* variant keeps them. Source maps are emitted alongside so heap snapshots and flame
|
||||
* charts show real component names instead of minified ones like `y`.
|
||||
*
|
||||
* Build with:
|
||||
* docker build --build-arg HOMARR_PROFILING=true . -t homarr:performance
|
||||
*
|
||||
* `reactProductionProfiling` in this config is only read by the webpack path — under
|
||||
* Turbopack it comes from `next build --profile`, which apps/nextjs/package.json
|
||||
* passes when HOMARR_PROFILING=true. Both are set so either bundler works.
|
||||
*
|
||||
* Off by default: the profiling variant is slower and source maps are large, so this
|
||||
* must never be what ships to users.
|
||||
*/
|
||||
const isProfilingBuild = process.env.HOMARR_PROFILING === "true";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactProductionProfiling: isProfilingBuild,
|
||||
productionBrowserSourceMaps: isProfilingBuild,
|
||||
// Next previews otherwise create agent instruction files in the application
|
||||
// directory during development.
|
||||
agentRules: false,
|
||||
env: {
|
||||
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
||||
},
|
||||
@@ -25,24 +50,73 @@ const nextConfig: NextConfig = {
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
/**
|
||||
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
||||
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
||||
*
|
||||
* Everything else here is a server-only dependency that is cheaper left as a
|
||||
* runtime require than inlined into the server bundle. Bundling pulls a package's
|
||||
* whole transitive graph into a chunk that is loaded and compiled at boot even
|
||||
* when the code path never runs — measurably so for the DB drivers: only
|
||||
* better-sqlite3 is ever used, yet inlining mysql2 and pg cost ~21 MiB of
|
||||
* resident memory at idle.
|
||||
*/
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
serverExternalPackages: [
|
||||
"dockerode",
|
||||
"better-sqlite3",
|
||||
"mysql2",
|
||||
"pg",
|
||||
"winston",
|
||||
"drizzle-orm",
|
||||
"ical.js",
|
||||
"jszip",
|
||||
"ldapts",
|
||||
"node-unifi",
|
||||
"@kubernetes/client-node",
|
||||
"linkedom",
|
||||
// Only reachable through /api/mcp/[transport].
|
||||
"@modelcontextprotocol/sdk",
|
||||
// Bundling inlines a copy per chunk: a server heap snapshot found this package's
|
||||
// command table in 10 chunks and resident 6 times over (2.1 MiB of duplication).
|
||||
// Safe to externalise because ioredis is already external and owns this as a
|
||||
// direct dependency, so it is always traced alongside it. Bundle hygiene — it did
|
||||
// not move measured memory (idle 89.7 -> 89.4 MiB, inside run-to-run noise).
|
||||
// got and @octokit/request-error duplicate similarly but are only transitive
|
||||
// deps here, so externalising them risks a runtime require that was never traced,
|
||||
// for no measured gain.
|
||||
"@ioredis/commands",
|
||||
],
|
||||
experimental: {
|
||||
/**
|
||||
* Dead config under Turbopack, which this app builds with: "Turbopack automatically analyzes and
|
||||
* optimizes imports without requiring this configuration."
|
||||
*
|
||||
* Measured rather than assumed - adding the seven Mantine entry points that are missing here
|
||||
* (charts, dates, dropzone, form, notifications, spotlight, tiptap) produced a client bundle that
|
||||
* was byte-for-byte identical, 482 files and 18050.7 KiB in both arms, down to the chunk hashes.
|
||||
* Kept only as a fallback for a webpack build; do not add to it expecting an effect.
|
||||
*/
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
webpackMemoryOptimizations: true,
|
||||
turbopackFileSystemCacheForBuild: true,
|
||||
useTypeScriptCli: true,
|
||||
/**
|
||||
* Next otherwise loads the module graph for every route at startup. Measured here: 74 route
|
||||
* bundles and 1,114 files, 24 MiB of source, resident before the first request — and one
|
||||
* shared 16 MiB subtree behind the API routes that reaches every integration. A dashboard
|
||||
* serves a handful of routes per session, so preloading the rest is pure cost.
|
||||
*
|
||||
* The trade is that the first request to a route pays its module load instead of the boot
|
||||
* paying for all of them. That suits a long-lived self-hosted server, where boot happens
|
||||
* once and routes warm up as they are used.
|
||||
*/
|
||||
preloadEntriesOnStart: false,
|
||||
},
|
||||
turbopack: {
|
||||
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
|
||||
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
|
||||
// (see vercel/next.js#95125). Suppress until upstream fix lands.
|
||||
ignoreIssue: [
|
||||
{
|
||||
path: "**/*",
|
||||
title: "Encountered unexpected file in NFT list",
|
||||
},
|
||||
],
|
||||
root: path.resolve(import.meta.dirname, "../.."),
|
||||
resolveAlias:
|
||||
process.env.NODE_ENV === "development"
|
||||
? {
|
||||
"@homarr/tasks": path.resolve(import.meta.dirname, "src/instrumentation-noop.ts"),
|
||||
"@homarr/websocket": path.resolve(import.meta.dirname, "src/instrumentation-noop.ts"),
|
||||
}
|
||||
: {},
|
||||
},
|
||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||
images: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
|
||||
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build $([ \"$HOMARR_PROFILING\" = \"true\" ] && echo --profile)",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"format": "oxfmt .",
|
||||
@@ -14,11 +14,6 @@
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "catalog:",
|
||||
"@assistant-ui/react": "catalog:",
|
||||
"@assistant-ui/react-ai-sdk": "catalog:",
|
||||
"@assistant-ui/react-lexical": "catalog:",
|
||||
"@assistant-ui/react-markdown": "catalog:",
|
||||
"@dnd-kit/core": "catalog:",
|
||||
"@dnd-kit/modifiers": "catalog:",
|
||||
"@dnd-kit/sortable": "catalog:",
|
||||
@@ -64,11 +59,10 @@
|
||||
"@modelcontextprotocol/sdk": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/query-async-storage-persister": "catalog:",
|
||||
"@tanstack/react-devtools": "catalog:",
|
||||
"@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:",
|
||||
@@ -77,25 +71,23 @@
|
||||
"@xterm/addon-fit": "catalog:",
|
||||
"@xterm/xterm": "catalog:",
|
||||
"adm-zip": "catalog:",
|
||||
"ai": "catalog:",
|
||||
"assistant-stream": "catalog:",
|
||||
"babel-plugin-react-compiler": "catalog:",
|
||||
"better-sqlite3": "catalog:",
|
||||
"chroma-js": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"dompurify": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"flag-icons": "catalog:",
|
||||
"glob": "catalog:",
|
||||
"isomorphic-dompurify": "catalog:",
|
||||
"jotai": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"linkedom": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mantine-datatable": "catalog:",
|
||||
"mantine-react-table": "catalog:",
|
||||
"mcp-handler": "catalog:",
|
||||
"mermaid": "catalog:",
|
||||
"next": "catalog:",
|
||||
"postcss-preset-mantine": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
@@ -104,9 +96,6 @@
|
||||
"react-dom": "catalog:",
|
||||
"react-error-boundary": "catalog:",
|
||||
"react-simple-code-editor": "catalog:",
|
||||
"remark-breaks": "catalog:",
|
||||
"remark-directive": "catalog:",
|
||||
"remark-gfm": "catalog:",
|
||||
"sass": "catalog:",
|
||||
"sql.js": "catalog:",
|
||||
"superjson": "catalog:",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import type { TanStackDevtoolsReactPlugin } from "@tanstack/react-devtools";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
|
||||
|
||||
const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const;
|
||||
const metricStyle = {
|
||||
background: "color-mix(in srgb, currentColor 4%, transparent)",
|
||||
border: "1px solid color-mix(in srgb, currentColor 18%, transparent)",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
} as const;
|
||||
|
||||
const readDiagnostics = (queryClient: QueryClient) => {
|
||||
const queries = queryClient.getQueryCache().getAll();
|
||||
const mutations = queryClient.getMutationCache().getAll();
|
||||
const board = document.querySelector("[data-homarr-dev-benchmark-board]");
|
||||
const widgetItems = board?.querySelectorAll('[data-type="item"]').length ?? 0;
|
||||
const readyWidgets = board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0;
|
||||
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
|
||||
|
||||
return {
|
||||
active: queries.filter((query) => query.getObserversCount() > 0).length,
|
||||
errors: queries.filter((query) => query.state.status === "error").length,
|
||||
fetching: queries.filter((query) => query.state.fetchStatus === "fetching").length,
|
||||
pendingMutations: mutations.filter((mutation) => mutation.state.status === "pending").length,
|
||||
stale: queries.filter((query) => query.isStale()).length,
|
||||
total: queries.length,
|
||||
readyWidgets,
|
||||
widgetItems,
|
||||
scriptKiB: Math.round(
|
||||
resources
|
||||
.filter((resource) => resource.initiatorType === "script")
|
||||
.reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const Metric = ({ label, value }: { label: string; value: number | string }) => (
|
||||
<div style={metricStyle}>
|
||||
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
|
||||
<output style={{ ...valueStyle, display: "block", fontSize: 22, fontWeight: 600, marginTop: 4 }}>{value}</output>
|
||||
</div>
|
||||
);
|
||||
|
||||
const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => {
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(queryClient));
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const update = () => setDiagnostics(readDiagnostics(queryClient));
|
||||
let frame: number | undefined;
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== undefined) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = undefined;
|
||||
update();
|
||||
});
|
||||
};
|
||||
const unsubscribeQueries = queryClient.getQueryCache().subscribe(scheduleUpdate);
|
||||
const unsubscribeMutations = queryClient.getMutationCache().subscribe(scheduleUpdate);
|
||||
const observer = new MutationObserver(scheduleUpdate);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
update();
|
||||
|
||||
return () => {
|
||||
unsubscribeQueries();
|
||||
unsubscribeMutations();
|
||||
observer.disconnect();
|
||||
if (frame !== undefined) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [active, pathname, queryClient]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<section data-homarr-devtools-panel style={{ fontFamily: "system-ui, sans-serif", padding: 16 }}>
|
||||
<header style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>Homarr performance</h2>
|
||||
<p style={{ marginBlock: "4px 0", opacity: 0.7 }}>Live state for {pathname}</p>
|
||||
</header>
|
||||
<div style={{ display: "grid", gap: 8, gridTemplateColumns: "repeat(auto-fit, minmax(110px, 1fr))" }}>
|
||||
<Metric label="Queries" value={diagnostics.total} />
|
||||
<Metric label="Active" value={diagnostics.active} />
|
||||
<Metric label="Fetching" value={diagnostics.fetching} />
|
||||
<Metric label="Stale" value={diagnostics.stale} />
|
||||
<Metric label="Errors" value={diagnostics.errors} />
|
||||
<Metric label="Mutations" value={diagnostics.pendingMutations} />
|
||||
<Metric label="Widgets ready" value={`${diagnostics.readyWidgets}/${diagnostics.widgetItems}`} />
|
||||
<Metric label="Script KiB" value={diagnostics.scriptKiB} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const developmentToolsPlugins = [
|
||||
{
|
||||
id: "tanstack-query",
|
||||
name: "TanStack Query",
|
||||
render: () => <ReactQueryDevtoolsPanel />,
|
||||
},
|
||||
{
|
||||
id: "homarr-performance",
|
||||
name: "Homarr",
|
||||
defaultOpen: true,
|
||||
render: (_element, { devtoolsOpen }) => <HomarrDevtoolsPanel active={devtoolsOpen} />,
|
||||
},
|
||||
] satisfies TanStackDevtoolsReactPlugin[];
|
||||
|
||||
export const DevelopmentTools = () => (
|
||||
<TanStackDevtools config={{ position: "bottom-right", hideUntilHover: true }} plugins={developmentToolsPlugins} />
|
||||
);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
|
||||
|
||||
const queryCacheStorage = {
|
||||
getItem: (_key: string) => null as string | null,
|
||||
async setItem(_key: string, value: string) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
|
||||
console.warn("[query-cache] persist failed", error);
|
||||
});
|
||||
},
|
||||
async removeItem(_key: string) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
|
||||
console.warn("[query-cache] remove failed", error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const createWidgetQueryPersister = () =>
|
||||
createAsyncStoragePersister({
|
||||
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
||||
key: queryCacheStoragePrefix,
|
||||
throttleTime: 2000,
|
||||
serialize: (data) => superjson.stringify(data),
|
||||
deserialize: (data) => superjson.parse(data),
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const createTrpcError = (code: string) => {
|
||||
const error = Object.assign(new Error(code), { data: { code } });
|
||||
Object.setPrototypeOf(error, TRPCClientError.prototype);
|
||||
return error;
|
||||
};
|
||||
|
||||
describe("createQueryRetry", () => {
|
||||
it.each(["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE_CONTENT"])(
|
||||
"does not retry the permanent %s response",
|
||||
(code) => {
|
||||
expect(createQueryRetry(3)(0, createTrpcError(code))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("retries transient and network failures only up to the configured limit", () => {
|
||||
const retry = createQueryRetry(3);
|
||||
|
||||
expect(retry(0, createTrpcError("INTERNAL_SERVER_ERROR"))).toBe(true);
|
||||
expect(retry(2, new Error("connection reset"))).toBe(true);
|
||||
expect(retry(3, new Error("connection reset"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
const nonRetryableTrpcCodes = new Set([
|
||||
"PARSE_ERROR",
|
||||
"BAD_REQUEST",
|
||||
"UNAUTHORIZED",
|
||||
"FORBIDDEN",
|
||||
"NOT_FOUND",
|
||||
"METHOD_NOT_SUPPORTED",
|
||||
"CONFLICT",
|
||||
"PRECONDITION_FAILED",
|
||||
"PAYLOAD_TOO_LARGE",
|
||||
"UNPROCESSABLE_CONTENT",
|
||||
]);
|
||||
|
||||
export const createQueryRetry = (maxRetries: number) => (failureCount: number, error: unknown) => {
|
||||
if (failureCount >= maxRetries) return false;
|
||||
if (error instanceof TRPCClientError && error.data?.code && nonRetryableTrpcCodes.has(error.data.code)) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
|
||||
describe("getSessionQueryScope", () => {
|
||||
it("is stable when permission order changes", () => {
|
||||
expect(getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view", "integration-use"] } })).toBe(
|
||||
getSessionQueryScope({ user: { id: "user-a", permissions: ["integration-use", "board-view"] } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes for a different user or permission set", () => {
|
||||
const original = getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view"] } });
|
||||
|
||||
expect(getSessionQueryScope({ user: { id: "user-b", permissions: ["board-view"] } })).not.toBe(original);
|
||||
expect(getSessionQueryScope({ user: { id: "user-a", permissions: [] } })).not.toBe(original);
|
||||
expect(getSessionQueryScope(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("removes the previous session subtree before requesting a document reload", async () => {
|
||||
const host = document.createElement("div");
|
||||
const root = createRoot(host);
|
||||
const onScopeChange = vi.fn();
|
||||
const renderScope = async (currentScope: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
SessionQueryScopeGuard,
|
||||
{ currentScope, initialScope: "a", onScopeChange },
|
||||
createElement("span", null, "session-a-secret"),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await renderScope("a");
|
||||
expect(host.textContent).toBe("session-a-secret");
|
||||
|
||||
await renderScope("b");
|
||||
expect(host.textContent).not.toContain("session-a-secret");
|
||||
expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe("true");
|
||||
expect(onScopeChange).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createElement, useEffect } from "react";
|
||||
|
||||
interface QuerySession {
|
||||
user: {
|
||||
id: string;
|
||||
permissions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const getSessionQueryScope = (session: QuerySession | null | undefined) =>
|
||||
session ? JSON.stringify([session.user.id, session.user.permissions.toSorted()]) : null;
|
||||
|
||||
interface SessionQueryScopeGuardProps {
|
||||
initialScope: string | null;
|
||||
currentScope: string | null;
|
||||
onScopeChange: () => void;
|
||||
}
|
||||
|
||||
export const SessionQueryScopeGuard = ({
|
||||
initialScope,
|
||||
currentScope,
|
||||
onScopeChange,
|
||||
children,
|
||||
}: PropsWithChildren<SessionQueryScopeGuardProps>) => {
|
||||
const hasScopeChanged = initialScope !== currentScope;
|
||||
useEffect(() => {
|
||||
if (hasScopeChanged) onScopeChange();
|
||||
}, [hasScopeChanged, onScopeChange]);
|
||||
|
||||
if (!hasScopeChanged) return children;
|
||||
|
||||
return createElement("div", {
|
||||
"aria-busy": true,
|
||||
"aria-label": "Refreshing session",
|
||||
role: "status",
|
||||
style: { minHeight: "100dvh" },
|
||||
});
|
||||
};
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
@@ -25,18 +23,24 @@ import { TRPCClientError } from "@trpc/client";
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
isPersistableWidgetQueryKey,
|
||||
queryCacheBuster,
|
||||
dashboardSupportingQueryPolicies,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
} from "@homarr/api/query-cache";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const DevelopmentTools =
|
||||
process.env.NODE_ENV === "development"
|
||||
? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false })
|
||||
: () => null;
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -59,21 +63,46 @@ const constructWebsocketUrl = () => {
|
||||
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
|
||||
};
|
||||
|
||||
const wsClient = createWSClient({
|
||||
url: constructWebsocketUrl(),
|
||||
});
|
||||
export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
|
||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
const [persister] = useState(() => createWidgetQueryPersister());
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
initialScope={initialSessionQueryScope}
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={reloadPage}
|
||||
>
|
||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
url: constructWebsocketUrl(),
|
||||
lazy: { enabled: true, closeMs: 30_000 },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
useEffect(
|
||||
() => () => {
|
||||
void wsClient.close();
|
||||
},
|
||||
[wsClient],
|
||||
);
|
||||
const [queryClient] = useState(() => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
gcTime: queryCacheDefaultGcTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 3,
|
||||
retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3),
|
||||
},
|
||||
mutations: {
|
||||
onError(error) {
|
||||
@@ -91,23 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
},
|
||||
},
|
||||
});
|
||||
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
||||
for (const { definition } of Object.values(widgetImports)) {
|
||||
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
|
||||
if (def.refetchInterval === undefined) continue;
|
||||
const key = def.queryKey ?? [["widget", def.kind]];
|
||||
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
|
||||
client.setQueryDefaults(key, { refetchInterval: interval });
|
||||
client.setQueryDefaults([["widget"]], {
|
||||
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
|
||||
client.setQueryDefaults(queryKey, {
|
||||
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
|
||||
});
|
||||
}
|
||||
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
|
||||
client.setQueryDefaults(queryKey, policy);
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
const [trpcClient] = useState(() => {
|
||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||
|
||||
const trpcClient = useMemo(() => {
|
||||
return clientApi.createClient({
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
enabled: (opts) => opts.direction === "down" && opts.result instanceof Error,
|
||||
}),
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
@@ -147,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
}, [wsClient]);
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister,
|
||||
buster: queryCacheBuster,
|
||||
maxAge: queryCacheDefaultGcTimeMs,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</PersistQueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
</QueryClientProvider>
|
||||
</clientApi.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
@@ -32,7 +32,6 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
const searchParams = useSearchParams();
|
||||
const isError = searchParams.has("error");
|
||||
const router = useRouter();
|
||||
const isMounted = useMounted();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const form = useZodForm(extendedValidation, {
|
||||
initialValues: {
|
||||
@@ -133,7 +132,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} />
|
||||
@@ -141,7 +140,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>
|
||||
)}
|
||||
@@ -152,7 +151,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("oidc") && (
|
||||
<Button fullWidth variant="light" disabled={!isMounted} onClick={async () => await signInAsync("oidc")}>
|
||||
<Button fullWidth variant="light" onClick={async () => await signInAsync("oidc")}>
|
||||
{t("action.login.labelWith", { provider: oidcClientName })}
|
||||
</Button>
|
||||
)}
|
||||
@@ -162,13 +161,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 (
|
||||
@@ -178,7 +176,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>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string }>({
|
||||
async getInitialBoardAsync() {
|
||||
return await api.board.getHomeBoard();
|
||||
return await getHomeBoardAsync();
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { createBoardContentPage } from "../../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardAddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: { id: "new", name: "" },
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{ title: (translate) => translate("section.category.create.title") },
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal()}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconBox size={20} />}
|
||||
onClick={() =>
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { Box, Stack } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import { WidgetContextMenuProvider } from "~/components/board/items/widget-context-menu-provider";
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
@@ -14,20 +15,22 @@ export const ClientBoard = () => {
|
||||
|
||||
const fullWidthSortedSections = board.sections
|
||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<Stack h="100%">
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
) : (
|
||||
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
<WidgetContextMenuProvider>
|
||||
<Stack h="100%">
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
) : (
|
||||
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
</WidgetContextMenuProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Placed here because gridstack styles are used for board content
|
||||
import "~/styles/gridstack.scss";
|
||||
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import superjson from "superjson";
|
||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
||||
import { getQueryClient } from "@homarr/api/server";
|
||||
import { makeQueryClient } from "@homarr/api/shared";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||
@@ -18,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getQueryCacheAsync } from "@homarr/redis";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
@@ -30,6 +25,7 @@ import { DynamicClientBoard } from "./_dynamic-client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
const getQueryClient = cache(makeQueryClient);
|
||||
|
||||
export type Params = Record<string, unknown>;
|
||||
|
||||
@@ -50,24 +46,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
const resolvedParams = await params;
|
||||
const queryClient = getQueryClient();
|
||||
const session = await auth();
|
||||
|
||||
const board = await getInitialBoard(resolvedParams).catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
|
||||
|
||||
const itemsMap = board.items.reduce((acc, item) => {
|
||||
const existing = acc.get(item.kind);
|
||||
@@ -78,7 +58,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}
|
||||
return acc;
|
||||
}, new Map<WidgetKind, Item[]>());
|
||||
|
||||
const [integrations] = await Promise.all([
|
||||
getIntegrationsWithPermissionsAsync(session),
|
||||
...Array.from(itemsMap).map(([kind, items]) =>
|
||||
@@ -94,19 +73,12 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
),
|
||||
]);
|
||||
|
||||
const userId = session?.user.id ?? "anonymous";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense>
|
||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||
</Suspense>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
</>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
@@ -127,11 +99,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Ignore not found and bad-request errors and return empty metadata
|
||||
if (
|
||||
error instanceof TRPCError &&
|
||||
(error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")
|
||||
) {
|
||||
// Ignore not found errors and return empty metadata
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -140,19 +109,3 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
|
||||
try {
|
||||
const serialized = await getQueryCacheAsync(userId, boardId);
|
||||
if (!serialized) return null;
|
||||
|
||||
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
|
||||
if (!persisted || persisted.buster !== queryCacheBuster) return null;
|
||||
if (!persisted.clientState?.queries?.length) return null;
|
||||
|
||||
return <HydrationBoundary state={persisted.clientState} />;
|
||||
} catch (error) {
|
||||
logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { SimpleGrid, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
const BoardLoadingShell = () => (
|
||||
<Stack h="100%" p="md" aria-busy aria-label="Loading dashboard" role="status">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{[2, 1, 2, 2, 1, 2].map((rows, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
data-homarr-dev-benchmark-board-shell
|
||||
height={rows * 96}
|
||||
radius="var(--mantine-radius-default)"
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||
ssr: false,
|
||||
loading: BoardLoadingShell,
|
||||
});
|
||||
@@ -1,45 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconBox,
|
||||
IconBoxAlignTop,
|
||||
IconChevronDown,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconResize,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
|
||||
const loadBoardAddMenu = () => import("./_board-add-menu");
|
||||
const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined);
|
||||
const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), {
|
||||
loading: () => (
|
||||
<HeaderButton loading>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
),
|
||||
});
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
@@ -52,16 +43,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
{isEditMode && <BoardAddMenu />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
@@ -69,94 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
);
|
||||
};
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: {
|
||||
id: "new",
|
||||
name: "",
|
||||
},
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(() => {
|
||||
openItemSelectModal();
|
||||
}, [openItemSelectModal]);
|
||||
|
||||
const handleSelectApp = useCallback(() => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
const handleAddIntegration = useCallback(() => {
|
||||
openIntegrationSelectModal({});
|
||||
}, [openIntegrationSelectModal]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</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")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
@@ -197,21 +100,29 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-edit-mode">
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
<TourTarget id="board-edit-mode">
|
||||
<HeaderButton
|
||||
onClick={toggle}
|
||||
loading={isPending}
|
||||
onFocus={preloadBoardAddMenu}
|
||||
onPointerEnter={preloadBoardAddMenu}
|
||||
>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
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 (
|
||||
<OnboardingTour.Target id="board-switcher">
|
||||
<Box>
|
||||
<Menu position="bottom-end">
|
||||
<TourTarget id="board-switcher">
|
||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
@@ -219,6 +130,11 @@ const SelectBoardsMenu = () => {
|
||||
</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}
|
||||
@@ -233,7 +149,7 @@ const SelectBoardsMenu = () => {
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -246,15 +162,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
const target = (event.target as HTMLElement).closest("a");
|
||||
|
||||
if (!target) {
|
||||
console.warn("No anchor element found for click event", event);
|
||||
return;
|
||||
}
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
@@ -280,19 +194,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
event.returnValue = true;
|
||||
};
|
||||
|
||||
const anchors = document.querySelectorAll(anchorSelector);
|
||||
anchors.forEach((link) => {
|
||||
link.addEventListener("click", handleClick);
|
||||
});
|
||||
document.addEventListener("click", handleClick, true);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
anchors.forEach((link) => {
|
||||
link.removeEventListener("click", handleClick);
|
||||
});
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [isDirty]);
|
||||
}, [isDirty, openConfirmModal, router, t]);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { BoardOtherHeaderActions } from "../_header-actions";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
@@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator";
|
||||
export default createBoardLayout<{ locale: string; name: string }>({
|
||||
headerActions: <BoardOtherHeaderActions />,
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -19,130 +19,106 @@ import {
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { generateColors } from "../../(content)/_theme";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
const hexRegex = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const progressPercentageLabel = (value: number) => `${value}%`;
|
||||
|
||||
export const ColorSettingsContent = ({ board }: Props) => {
|
||||
const form = useZodForm(boardSavePartialSettingsSchema, {
|
||||
initialValues: {
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
iconColor: board.iconColor ?? "",
|
||||
itemRadius: board.itemRadius,
|
||||
},
|
||||
});
|
||||
export const ColorSettingsContent = ({ form }: Props) => {
|
||||
const [showPreview, { toggle }] = useDisclosure(false);
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<ColorInput
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<SectionCard title={t("board.setting.section.appearance.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<ColorInput
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("secondaryColor")}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
<Stack>
|
||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
label={progressPercentageLabel}
|
||||
{...form.getInputProps("opacity")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
{...form.getInputProps("iconColor")}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconX />}
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{t("board.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")}
|
||||
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" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<ColorInput
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("secondaryColor")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
<Stack>
|
||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
label={progressPercentageLabel}
|
||||
{...form.getInputProps("opacity")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
{...form.getInputProps("iconColor")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
leftSection={<IconX />}
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{t("board.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")}
|
||||
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" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,41 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition } from "react";
|
||||
import { ActionIcon, Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { UploadMedia } from "@homarr/forms-collection";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
||||
import { SelectWithDescriptionBadge } from "@homarr/ui";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
|
||||
export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useZodForm(boardSavePartialSettingsSchema, {
|
||||
initialValues: {
|
||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
backgroundImageRepeat: board.backgroundImageRepeat,
|
||||
backgroundImageSize: board.backgroundImageSize,
|
||||
},
|
||||
});
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl, 200);
|
||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
|
||||
const medias = clientApi.media.getPaginated.useQuery({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
@@ -53,121 +44,106 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<Autocomplete
|
||||
flex={1}
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
<SectionCard title={t("board.setting.section.background.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<Autocomplete
|
||||
flex={1}
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.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"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(uploadedMedias) => {
|
||||
const first = uploadedMedias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
form.setFieldValue("backgroundImageUrl", first.url);
|
||||
});
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.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"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
form.setFieldValue("backgroundImageUrl", first.url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,48 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||
import { Switch } from "@mantine/core";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const BehaviorSettingsContent = ({ board }: Props) => {
|
||||
export const BehaviorSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
disableStatus: board.disableStatus,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<SectionCard title={t("board.setting.section.behavior.title")}>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
|
||||
import { Alert, Input } from "@mantine/core";
|
||||
import { highlight, languages } from "prismjs";
|
||||
import Editor from "react-simple-code-editor";
|
||||
|
||||
@@ -8,59 +8,39 @@ import "~/styles/prismjs.scss";
|
||||
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import classes from "./customcss.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
export const CustomCssSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
customCss: board.customCss ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
<SectionCard title={t("board.setting.section.customCss.title")}>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomCssInputProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
@@ -70,6 +50,7 @@ const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||
htmlFor: "custom-css",
|
||||
}}
|
||||
description={customCssT("description")}
|
||||
error={error}
|
||||
inputWrapperOrder={["label", "description", "input", "error"]}
|
||||
>
|
||||
<div className={classes.codeEditorRoot}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -10,7 +10,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import classes from "./danger.module.css";
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
|
||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
@@ -82,60 +82,36 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.rename.label")}
|
||||
description={t("section.dangerZone.action.rename.description")}
|
||||
buttonText={t("section.dangerZone.action.rename.button")}
|
||||
onClick={onRenameClick}
|
||||
action={
|
||||
<Button variant="subtle" color="red" onClick={onRenameClick}>
|
||||
{t("section.dangerZone.action.rename.button")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{hideVisibility ? null : (
|
||||
<>
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
onClick={onVisibilityClick}
|
||||
isPending={isChangeVisibilityPending}
|
||||
/>
|
||||
</>
|
||||
{!hideVisibility && (
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
|
||||
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.delete.label")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
buttonText={t("section.dangerZone.action.delete.button")}
|
||||
onClick={onDeleteClick}
|
||||
isPending={isDeletePending}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||
{t("section.dangerZone.action.delete.button")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface DangerZoneRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
isPending?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
|
||||
return (
|
||||
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold" size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{description}</Text>
|
||||
</Stack>
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</DangerZoneRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core";
|
||||
import { useEffect } from "react";
|
||||
import { Grid, Loader, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const GeneralSettingsContent = ({ board }: Props) => {
|
||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
const t = useI18n();
|
||||
const ref = useRef({
|
||||
pageTitle: board.pageTitle,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
});
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useZodForm(
|
||||
boardSavePartialSettingsSchema
|
||||
.pick({
|
||||
pageTitle: true,
|
||||
logoImageUrl: true,
|
||||
metaTitle: true,
|
||||
faviconImageUrl: true,
|
||||
})
|
||||
.required(),
|
||||
{
|
||||
initialValues: {
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
},
|
||||
onValuesChange({ pageTitle }) {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle,
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useLogoPreview(form.values.logoImageUrl);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
|
||||
// Cleanup for not applied changes of the page title and logo image URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: ref.current.pageTitle,
|
||||
logoImageUrl: ref.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
}, [updateBoard]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
// Save the current values to the ref so that it does not reset if the form is submitted
|
||||
ref.current = {
|
||||
pageTitle: values.pageTitle,
|
||||
logoImageUrl: values.logoImageUrl,
|
||||
};
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.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 }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<SectionCard title={t("board.setting.section.general.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.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 }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -146,14 +84,3 @@ const useMetaTitlePreview = (title: string | null) => {
|
||||
isPending: (title ?? "") !== metaTitleDebounced,
|
||||
};
|
||||
};
|
||||
|
||||
// Previously we used the useFavicon hook from Mantine
|
||||
// However if the user tried to click on a link to a different page
|
||||
// it caused an error "Cannot read properties of null (reading 'removeChild')"
|
||||
// Probably because the head element was null (https://github.com/mantinedev/mantine/blob/b90d9b81031f45f5d15e75f25138ed6477f65bce/packages/%40mantine/hooks/src/use-favicon/use-favicon.ts#L21)
|
||||
// See https://github.com/homarr-labs/homarr/issues/4905
|
||||
const updateFavicon = (url: string) => {
|
||||
const link: HTMLLinkElement | null = document.querySelector('link[rel="icon"]');
|
||||
if (!link) return;
|
||||
link.href = url;
|
||||
};
|
||||
@@ -2,114 +2,79 @@
|
||||
|
||||
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
|
||||
export const LayoutSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
||||
initialValues: {
|
||||
layouts: board.layouts,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
saveLayouts({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues({
|
||||
layouts: [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) =>
|
||||
previous.layouts !== undefined && previous.layouts.length >= 2
|
||||
? {
|
||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}
|
||||
: previous,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
<SectionCard title={t("board.setting.section.layout.title")}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
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 { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { ColorSettingsContent } from "./_appereance";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
import { LayoutSettingsContent } from "./_layout";
|
||||
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
const boardSettingsFormSchema = boardSavePartialSettingsSchema
|
||||
.extend({
|
||||
layouts: boardSaveLayoutsSchema.shape.layouts,
|
||||
})
|
||||
.required();
|
||||
|
||||
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
|
||||
|
||||
const PARTIAL_FORM_KEYS = [
|
||||
"pageTitle",
|
||||
"metaTitle",
|
||||
"logoImageUrl",
|
||||
"faviconImageUrl",
|
||||
"backgroundImageUrl",
|
||||
"backgroundImageAttachment",
|
||||
"backgroundImageRepeat",
|
||||
"backgroundImageSize",
|
||||
"primaryColor",
|
||||
"secondaryColor",
|
||||
"opacity",
|
||||
"iconColor",
|
||||
"itemRadius",
|
||||
"customCss",
|
||||
"disableStatus",
|
||||
] as const;
|
||||
|
||||
const buildInitialValues = (board: Board): FormValues => ({
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
backgroundImageRepeat: board.backgroundImageRepeat,
|
||||
backgroundImageSize: board.backgroundImageSize,
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
iconColor: board.iconColor ?? "",
|
||||
itemRadius: board.itemRadius,
|
||||
customCss: board.customCss ?? "",
|
||||
disableStatus: board.disableStatus,
|
||||
layouts: board.layouts,
|
||||
});
|
||||
|
||||
interface BoardSettingsFormProps {
|
||||
board: Board;
|
||||
permissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
hasFullAccess: boolean;
|
||||
hideVisibility: boolean;
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSection = useScopedI18n("board.setting.section");
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||
const saveLayouts = useSaveLayoutsMutation(board);
|
||||
|
||||
const form = useZodForm(boardSettingsFormSchema, {
|
||||
initialValues: buildInitialValues(board),
|
||||
onValuesChange({ pageTitle }) {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const initialValuesRef = useRef(buildInitialValues(board));
|
||||
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: lastSavedRef.current.pageTitle,
|
||||
logoImageUrl: lastSavedRef.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
}, [updateBoard]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (isDirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||
fields.some((field) => values[field] !== defaults[field]);
|
||||
|
||||
const { layouts, ...partialSettings } = values;
|
||||
|
||||
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
|
||||
{
|
||||
when: changed(...PARTIAL_FORM_KEYS),
|
||||
action: () =>
|
||||
savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("layouts"),
|
||||
action: () => saveLayouts.mutateAsync({ id: board.id, layouts }),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((saveAction) => saveAction.when).map((saveAction) => saveAction.action());
|
||||
if (promises.length === 0) return;
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
lastSavedRef.current = { pageTitle: values.pageTitle, logoImageUrl: values.logoImageUrl };
|
||||
initialValuesRef.current = values;
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty();
|
||||
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("common.notification.update.success"),
|
||||
});
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: lastSavedRef.current.pageTitle,
|
||||
logoImageUrl: lastSavedRef.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
|
||||
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<GeneralSettingsContent board={board} form={form} />
|
||||
<LayoutSettingsContent form={form} />
|
||||
<BackgroundSettingsContent form={form} />
|
||||
<ColorSettingsContent form={form} />
|
||||
<CustomCssSettingsContent form={form} />
|
||||
<BehaviorSettingsContent form={form} />
|
||||
|
||||
{hasFullAccess && (
|
||||
<SectionCard title={tSection("access.title")}>
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// Previously part of the general settings section; applied on the unified save
|
||||
// https://github.com/homarr-labs/homarr/issues/4905
|
||||
const updateFavicon = (url: string) => {
|
||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (!link) return;
|
||||
link.href = url;
|
||||
};
|
||||
@@ -11,3 +11,13 @@ export const useSavePartialSettingsMutation = (board: Board) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSaveLayoutsMutation = (board: Board) => {
|
||||
const utils = clientApi.useUtils();
|
||||
return clientApi.board.saveLayouts.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
@media (min-width: 36em) {
|
||||
.dangerZoneGroup {
|
||||
--group-wrap: nowrap !important;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,20 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrush,
|
||||
IconClick,
|
||||
IconFileTypeCss,
|
||||
IconLayout,
|
||||
IconPhoto,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { capitalize } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { ColorSettingsContent } from "./_appereance";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
import { LayoutSettingsContent } from "./_layout";
|
||||
import { BoardSettingsForm } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{
|
||||
name: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
tab?: keyof TranslationObject["board"]["setting"]["section"];
|
||||
}>;
|
||||
}
|
||||
|
||||
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
|
||||
@@ -57,11 +33,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
|
||||
} catch (error) {
|
||||
// Ignore not found errors and redirect to 404
|
||||
// error is already logged in _layout-creator.tsx
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -70,7 +42,6 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
|
||||
};
|
||||
|
||||
export default async function BoardSettingsPage(props: Props) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
@@ -85,76 +56,13 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
||||
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
|
||||
<AccordionItemFor value="general" icon={IconSettings}>
|
||||
<GeneralSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="layout" icon={IconLayout}>
|
||||
<LayoutSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||
<BackgroundSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="appearance" icon={IconBrush}>
|
||||
<ColorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
<CustomCssSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="behavior" icon={IconClick}>
|
||||
<BehaviorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<AccordionItemFor value="access" icon={IconUser}>
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||
<DangerZoneSettingsContent
|
||||
hideVisibility={
|
||||
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
|
||||
}
|
||||
/>
|
||||
</AccordionItemFor>
|
||||
</>
|
||||
)}
|
||||
</ActiveTabAccordion>
|
||||
<BoardSettingsForm
|
||||
board={board}
|
||||
permissions={permissions}
|
||||
hasFullAccess={hasFullAccess}
|
||||
hideVisibility={boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
type AccordionItemForProps = PropsWithChildren<{
|
||||
value: keyof TranslationObject["board"]["setting"]["section"];
|
||||
icon: TablerIcon;
|
||||
danger?: boolean;
|
||||
noPadding?: boolean;
|
||||
}>;
|
||||
|
||||
const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
|
||||
const t = await getScopedI18n("board.setting.section");
|
||||
return (
|
||||
<AccordionItem
|
||||
value={value}
|
||||
styles={
|
||||
danger
|
||||
? {
|
||||
item: {
|
||||
"--__item-border-color": "rgba(248,81,73,0.4)",
|
||||
borderWidth: 4,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AccordionControl icon={<Icon />}>
|
||||
<Text fw="bold" size="lg">
|
||||
{t(`${value}.title`)}
|
||||
</Text>
|
||||
</AccordionControl>
|
||||
<AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
|
||||
{children}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
@@ -11,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
||||
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
||||
import { env } from "~/env";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import type { Board } from "./_types";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
@@ -21,11 +23,6 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
|
||||
const logger = createLogger({ module: "createBoardLayout" });
|
||||
|
||||
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
|
||||
if (!hasSession) return <>{children}</>;
|
||||
return <BoardTourProvider>{children}</BoardTourProvider>;
|
||||
};
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
@@ -43,8 +40,32 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
}: PropsWithChildren<{
|
||||
params: Promise<TParams>;
|
||||
}>) => {
|
||||
const session = await auth();
|
||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||
const resolvedParams = await params;
|
||||
const sessionPromise = auth();
|
||||
const initialBoardPromise = getInitialBoard(resolvedParams).then(
|
||||
(board) => ({ status: "fulfilled", board }) as const,
|
||||
(error: unknown) => ({ status: "rejected", error }) as const,
|
||||
);
|
||||
const colorSchemePromise = getCurrentColorSchemeAsync();
|
||||
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
|
||||
if (!withTour || !session || env.DEMO_MODE) return false;
|
||||
|
||||
try {
|
||||
const user = await getRscUserSettingsAsync(session.user.id);
|
||||
return user !== undefined && !user.completedBoardTour;
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to load the board tour status", { cause: error }));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
|
||||
sessionPromise,
|
||||
initialBoardPromise,
|
||||
colorSchemePromise,
|
||||
shouldRunBoardTourPromise,
|
||||
]);
|
||||
if (initialBoardResult.status === "rejected") {
|
||||
const { error } = initialBoardResult;
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
@@ -60,8 +81,8 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
}
|
||||
const initialBoard = initialBoardResult.board;
|
||||
|
||||
return (
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
@@ -69,7 +90,7 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
@@ -78,7 +99,7 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourWrapper>
|
||||
</BoardTourGate>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</BoardReadyProvider>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { JSX } from "react";
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { db } from "@homarr/db";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -29,9 +29,16 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
|
||||
finish: InitFinish,
|
||||
};
|
||||
|
||||
const getCurrentOnboardingStepAsync = async () => {
|
||||
const value = await db.query.onboarding.findFirst();
|
||||
if (!value) return { current: "start" as const, previous: null };
|
||||
|
||||
return { current: value.step, previous: value.previousStep };
|
||||
};
|
||||
|
||||
export default async function InitPage() {
|
||||
const t = await getScopedI18n("init.step");
|
||||
const currentStep = await api.onboard.currentStep();
|
||||
const currentStep = await getCurrentOnboardingStepAsync();
|
||||
|
||||
const CurrentComponent = stepComponents[currentStep.current];
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ import { notFound } from "next/navigation";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
import { SettingsProvider } from "@homarr/settings";
|
||||
@@ -26,7 +26,6 @@ import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { AssistantProvider } from "~/components/assistant/assistant-provider";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
@@ -44,6 +43,8 @@ const fontSans = Inter({
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
const logger = createLogger({ module: "rootLayout" });
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
@@ -79,15 +80,27 @@ export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: SupportedLanguage }>;
|
||||
}) {
|
||||
if (!isLocaleSupported((await props.params).locale)) {
|
||||
const { locale } = await props.params;
|
||||
if (!isLocaleSupported(locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
||||
const serverSettings = await getServerSettingsAsync(db);
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||
const sessionPromise = auth();
|
||||
const userPromise = sessionPromise.then((session) =>
|
||||
session
|
||||
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
||||
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
|
||||
return null;
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const [session, user, serverSettings, colorScheme] = await Promise.all([
|
||||
sessionPromise,
|
||||
userPromise,
|
||||
getRscServerSettingsAsync(),
|
||||
getCurrentColorSchemeAsync(),
|
||||
]);
|
||||
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
(innerProps) => {
|
||||
@@ -124,11 +137,8 @@ export default async function Layout(props: {
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
(innerProps) => <AssistantProvider {...innerProps} />,
|
||||
]);
|
||||
|
||||
const { locale } = await props.params;
|
||||
|
||||
return (
|
||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||
<html
|
||||
|
||||
@@ -40,8 +40,9 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import githubContributors from "@static-data/contributors.json";
|
||||
import dependencies from "@static-data/dependencies.json";
|
||||
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
||||
import crowdinContributors from "@static-data/translators.json";
|
||||
import classes from "./about.module.css";
|
||||
@@ -57,7 +58,6 @@ export async function generateMetadata() {
|
||||
export default async function AboutPage() {
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const version = getPackageVersion();
|
||||
const dependencies = await getDependenciesAsync();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -159,34 +159,26 @@ export default async function AboutPage() {
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
|
||||
{Object.entries(dependencies)
|
||||
.filter(([, value]) => !value.includes("workspace:"))
|
||||
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.map(([name, version]) => (
|
||||
<UnstyledButton
|
||||
key={name}
|
||||
component="a"
|
||||
href={`https://www.npmjs.com/package/${name}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Card radius="md" p="xs" className={classes.dependencyCard}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" size="lg">
|
||||
<IconPackage size="1.5rem" stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
{Object.entries(dependencies).map(([name, version]) => (
|
||||
<UnstyledButton key={name} component="a" href={`https://www.npmjs.com/package/${name}`} target="_blank">
|
||||
<Card radius="md" p="xs" className={classes.dependencyCard}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" size="lg">
|
||||
<IconPackage size="1.5rem" stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw="bold" lineClamp={1} title={name}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
v{version.replace("^", "").replace("~", "")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw="bold" lineClamp={1} title={name}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
v{version.replace("^", "").replace("~", "")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAssistantConnectionState } from "./assistant-configuration-state";
|
||||
|
||||
describe("getAssistantConnectionState", () => {
|
||||
it("marks a saved required-key provider as ready", () => {
|
||||
expect(
|
||||
getAssistantConnectionState({
|
||||
connectionConfigured: true,
|
||||
destinationChanged: false,
|
||||
providerRequiresApiKey: true,
|
||||
apiKeyConfigured: true,
|
||||
}),
|
||||
).toEqual({
|
||||
hasStoredApiKey: true,
|
||||
connectionPending: false,
|
||||
connectionReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the endpoint to be saved before exposing model controls", () => {
|
||||
expect(
|
||||
getAssistantConnectionState({
|
||||
connectionConfigured: true,
|
||||
destinationChanged: true,
|
||||
providerRequiresApiKey: true,
|
||||
apiKeyConfigured: true,
|
||||
}),
|
||||
).toEqual({
|
||||
hasStoredApiKey: false,
|
||||
connectionPending: true,
|
||||
connectionReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call a required-key connection ready after its key is removed", () => {
|
||||
expect(
|
||||
getAssistantConnectionState({
|
||||
connectionConfigured: true,
|
||||
destinationChanged: false,
|
||||
providerRequiresApiKey: true,
|
||||
apiKeyConfigured: false,
|
||||
}),
|
||||
).toEqual({
|
||||
hasStoredApiKey: false,
|
||||
connectionPending: true,
|
||||
connectionReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a saved local endpoint without an API key", () => {
|
||||
expect(
|
||||
getAssistantConnectionState({
|
||||
connectionConfigured: true,
|
||||
destinationChanged: false,
|
||||
providerRequiresApiKey: false,
|
||||
apiKeyConfigured: false,
|
||||
}),
|
||||
).toEqual({
|
||||
hasStoredApiKey: false,
|
||||
connectionPending: false,
|
||||
connectionReady: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
interface AssistantConnectionStateInput {
|
||||
connectionConfigured: boolean;
|
||||
destinationChanged: boolean;
|
||||
providerRequiresApiKey: boolean;
|
||||
apiKeyConfigured: boolean;
|
||||
}
|
||||
|
||||
export const getAssistantConnectionState = ({
|
||||
connectionConfigured,
|
||||
destinationChanged,
|
||||
providerRequiresApiKey,
|
||||
apiKeyConfigured,
|
||||
}: AssistantConnectionStateInput) => {
|
||||
const hasStoredApiKey = apiKeyConfigured && !destinationChanged;
|
||||
const credentialsMissing = providerRequiresApiKey && !hasStoredApiKey;
|
||||
const connectionPending = !connectionConfigured || destinationChanged || credentialsMissing;
|
||||
|
||||
return {
|
||||
hasStoredApiKey,
|
||||
connectionPending,
|
||||
connectionReady: !connectionPending,
|
||||
};
|
||||
};
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
.configuration {
|
||||
width: 100%;
|
||||
max-width: 74rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.summaryProvider {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summaryProviderText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summaryEndpoint {
|
||||
max-width: min(46rem, 70vw);
|
||||
}
|
||||
|
||||
.section {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
padding: var(--mantine-spacing-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stepNumber {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex: 0 0 1.75rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--mantine-primary-color-light);
|
||||
color: var(--mantine-primary-color-light-color);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 700;
|
||||
line-height: 1.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sectionBody {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.sectionActions {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.credentialSummary {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.credentialSummaryContent {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.credentialFlow {
|
||||
padding: var(--mantine-spacing-md);
|
||||
border: 1px solid light-dark(var(--mantine-color-yellow-4), var(--mantine-color-yellow-8));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-yellow-0),
|
||||
color-mix(in srgb, var(--mantine-color-yellow-9) 18%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
.credentialStepper :global(.mantine-Stepper-stepLabel) {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.credentialStepper :global(.mantine-Stepper-content) {
|
||||
padding-top: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.advancedControl {
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
|
||||
.selectedModel {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.selectedModelText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selectedModelBadges {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.capabilityRow,
|
||||
.enableRow {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.securityNote {
|
||||
max-width: 66ch;
|
||||
}
|
||||
|
||||
.flowActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.summaryEndpoint {
|
||||
max-width: 78vw;
|
||||
}
|
||||
|
||||
.summaryActions,
|
||||
.credentialActions,
|
||||
.sectionActions > :global(.mantine-Group-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summaryActions > *,
|
||||
.credentialActions > *,
|
||||
.flowActions > *,
|
||||
.sectionActions :global(.mantine-Button-root) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.credentialSummary {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.flowActions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.credentialStepper :global(.mantine-Stepper-stepLabel) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.selectedModel {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selectedModelBadges {
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.capabilityRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.configuration *,
|
||||
.configuration *::before,
|
||||
.configuration *::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
-970
@@ -1,970 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Stepper,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconDatabaseSearch,
|
||||
IconKey,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconShieldLock,
|
||||
IconTool,
|
||||
IconTrash,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
assistantProviderCanUseOpenRouterServerTools,
|
||||
assistantProviderIds,
|
||||
assistantProviderPresets,
|
||||
getAssistantModelOptionLabel,
|
||||
resolveAssistantModelId,
|
||||
} from "@homarr/definitions";
|
||||
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./assistant-configuration.module.css";
|
||||
import { getAssistantConnectionState } from "./assistant-configuration-state";
|
||||
|
||||
type HeaderEntry = { id: number; name: string; value: string };
|
||||
type CredentialFlow = "idle" | "replace" | "remove";
|
||||
type AssistantProviderOption = AssistantProvider | "homarr";
|
||||
|
||||
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProviderOption; size?: number }) => {
|
||||
if (providerId === "homarr") {
|
||||
return <Image src="/logo/logo.png" alt="" aria-hidden w={size} h={size} fit="contain" />;
|
||||
}
|
||||
|
||||
const preset = assistantProviderPresets[providerId];
|
||||
if (!preset.iconUrl) return <IconWorld size={size} aria-hidden />;
|
||||
|
||||
const darkIconUrl = "darkIconUrl" in preset ? preset.darkIconUrl : null;
|
||||
if (!darkIconUrl) {
|
||||
return <Image src={preset.iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image src={preset.iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" darkHidden />
|
||||
<Image src={darkIconUrl} alt="" aria-hidden w={size} h={size} fit="contain" lightHidden />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConfigurationSectionProps {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ConfigurationSection = ({ number, title, description, status, actions, children }: ConfigurationSectionProps) => (
|
||||
<section className={classes.section}>
|
||||
<Group className={classes.sectionHeader} justify="space-between" align="flex-start">
|
||||
<Group className={classes.sectionHeading} gap="sm" align="flex-start" wrap="nowrap">
|
||||
<Box className={classes.stepNumber} aria-hidden>
|
||||
{number}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={650}>{title}</Text>
|
||||
<Text size="sm" c="dimmed" maw="66ch">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
{status}
|
||||
</Group>
|
||||
<Stack className={classes.sectionBody} gap="md">
|
||||
{children}
|
||||
</Stack>
|
||||
{actions && <Box className={classes.sectionActions}>{actions}</Box>}
|
||||
</section>
|
||||
);
|
||||
|
||||
const ConfigurationSkeleton = ({ label }: { label: string }) => (
|
||||
<Stack className={classes.configuration} gap="md" aria-label={label}>
|
||||
<Paper withBorder p="md">
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<Skeleton circle h={36} />
|
||||
<Stack gap={5}>
|
||||
<Skeleton h={14} w={180} />
|
||||
<Skeleton h={10} w={280} />
|
||||
</Stack>
|
||||
</Group>
|
||||
<Skeleton h={24} w={110} radius="xl" />
|
||||
</Group>
|
||||
</Paper>
|
||||
{[1, 2, 3].map((number) => (
|
||||
<Paper key={number} withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Skeleton h={18} w={220} />
|
||||
<Skeleton h={12} w="65%" />
|
||||
<Skeleton h={42} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const AssistantConfiguration = () => {
|
||||
const t = useScopedI18n("management.page.settings.section.assistant");
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
|
||||
const [provider, setProvider] = useState<AssistantProvider>("openrouter");
|
||||
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.openrouter.baseUrl);
|
||||
const [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [credentialFlow, setCredentialFlow] = useState<CredentialFlow>("idle");
|
||||
const [credentialStep, setCredentialStep] = useState(0);
|
||||
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
|
||||
const [clearHeaders, setClearHeaders] = useState(false);
|
||||
const [nextHeaderId, setNextHeaderId] = useState(1);
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
const [clearCredentialsOpened, setClearCredentialsOpened] = useState(false);
|
||||
const replacementKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProvider(configuration?.provider ?? "openrouter");
|
||||
setBaseUrl(configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl);
|
||||
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
|
||||
setModelId(configuration?.modelId ?? "");
|
||||
setEnabled(configuration?.enabled ?? false);
|
||||
setWebSearchEnabled(configuration?.webSearchEnabled ?? false);
|
||||
setApiKey("");
|
||||
setCredentialFlow("idle");
|
||||
setCredentialStep(0);
|
||||
}, [configuration]);
|
||||
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/$/, "");
|
||||
const normalizedModelDiscoveryPath = modelDiscoveryPath.trim() || null;
|
||||
const destinationChanged =
|
||||
configuration?.connectionConfigured === true &&
|
||||
(configuration.provider !== provider || configuration.baseUrl !== normalizedBaseUrl);
|
||||
const connectionChanged =
|
||||
configuration?.connectionConfigured === true &&
|
||||
(destinationChanged || configuration.modelDiscoveryPath !== normalizedModelDiscoveryPath);
|
||||
|
||||
const {
|
||||
data: models,
|
||||
error: discoveryError,
|
||||
isFetching: isDiscovering,
|
||||
refetch: discoverModels,
|
||||
} = clientApi.assistant.discoverModels.useQuery(undefined, {
|
||||
enabled:
|
||||
configuration?.connectionConfigured === true && configuration.modelDiscoveryPath !== null && !connectionChanged,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() =>
|
||||
(models ?? []).map((model) => ({
|
||||
value: model.id,
|
||||
label: getAssistantModelOptionLabel(model),
|
||||
})),
|
||||
[models],
|
||||
);
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
|
||||
const items = assistantProviderIds
|
||||
.filter((providerId) => assistantProviderPresets[providerId].category === category)
|
||||
.map((providerId) => ({
|
||||
value: providerId,
|
||||
label: t(`provider.options.${providerId}.label`),
|
||||
}));
|
||||
|
||||
return {
|
||||
group: t(`provider.groups.${category}`),
|
||||
items:
|
||||
category === "hosted"
|
||||
? [
|
||||
{
|
||||
value: "homarr",
|
||||
label: t("provider.options.homarr.label"),
|
||||
disabled: true,
|
||||
},
|
||||
...items,
|
||||
]
|
||||
: items,
|
||||
};
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const selectedModel = connectionChanged ? undefined : models?.find((model) => model.id === modelId);
|
||||
const hasDiscoveredModels = !connectionChanged && (models?.length ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!models || models.length === 0) return;
|
||||
const resolvedModelId = resolveAssistantModelId(models, modelId);
|
||||
if (resolvedModelId && resolvedModelId !== modelId) {
|
||||
setModelId(resolvedModelId);
|
||||
}
|
||||
}, [modelId, models]);
|
||||
|
||||
useEffect(() => {
|
||||
if (credentialFlow !== "replace" || credentialStep !== 0) return;
|
||||
const animationFrame = requestAnimationFrame(() => replacementKeyInputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [credentialFlow, credentialStep]);
|
||||
|
||||
const preset = assistantProviderPresets[provider];
|
||||
const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0);
|
||||
const { hasStoredApiKey, connectionPending, connectionReady } = getAssistantConnectionState({
|
||||
connectionConfigured: configuration?.connectionConfigured === true,
|
||||
destinationChanged,
|
||||
providerRequiresApiKey: preset.requiresApiKey,
|
||||
apiKeyConfigured: configuration?.apiKeyConfigured === true,
|
||||
});
|
||||
const hasEffectiveApiKey = apiKey.trim().length > 0 || hasStoredApiKey;
|
||||
const connectionValid =
|
||||
baseUrl.trim().length > 0 && headerValuesValid && (!preset.requiresApiKey || hasEffectiveApiKey);
|
||||
const modelControlsDisabled = connectionPending || isDiscovering;
|
||||
const canSaveConfiguration = !modelControlsDisabled && modelId.trim().length > 0;
|
||||
const canUseOpenRouterServerTools = assistantProviderCanUseOpenRouterServerTools(provider);
|
||||
|
||||
const updateConnection = clientApi.assistant.updateConnection.useMutation({
|
||||
onSuccess: async ({ credentialsClearedForDestinationChange }) => {
|
||||
setApiKey("");
|
||||
setCredentialFlow("idle");
|
||||
setCredentialStep(0);
|
||||
setHeaders([]);
|
||||
setClearHeaders(false);
|
||||
await Promise.all([
|
||||
utils.assistant.getAdminConfiguration.invalidate(),
|
||||
utils.assistant.discoverModels.invalidate(),
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: t("notification.connectionSaved.title"),
|
||||
message: credentialsClearedForDestinationChange
|
||||
? t("notification.connectionSaved.credentialsCleared")
|
||||
: t("notification.connectionSaved.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
|
||||
});
|
||||
|
||||
const saveConfiguration = clientApi.assistant.updateConfiguration.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.assistant.getAdminConfiguration.invalidate(),
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
utils.assistant.getRuntimeOptions.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: t("notification.saved.title"),
|
||||
message: t("notification.saved.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
|
||||
});
|
||||
|
||||
const clearCredentials = clientApi.assistant.clearCredentials.useMutation({
|
||||
onSuccess: async () => {
|
||||
setClearCredentialsOpened(false);
|
||||
setApiKey("");
|
||||
setHeaders([]);
|
||||
setModelId("");
|
||||
setEnabled(false);
|
||||
await Promise.all([
|
||||
utils.assistant.getAdminConfiguration.invalidate(),
|
||||
utils.assistant.discoverModels.invalidate(),
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: t("notification.credentialsCleared.title"),
|
||||
message: t("notification.credentialsCleared.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
|
||||
});
|
||||
|
||||
const pending = updateConnection.isPending || saveConfiguration.isPending || clearCredentials.isPending;
|
||||
|
||||
const resetCredentialFlow = () => {
|
||||
setApiKey("");
|
||||
setCredentialFlow("idle");
|
||||
setCredentialStep(0);
|
||||
};
|
||||
|
||||
const clearDraftConnectionState = () => {
|
||||
resetCredentialFlow();
|
||||
setHeaders([]);
|
||||
setClearHeaders(false);
|
||||
setModelId("");
|
||||
setEnabled(false);
|
||||
setWebSearchEnabled(false);
|
||||
};
|
||||
|
||||
const changeProvider = (value: string | null) => {
|
||||
if (!value || !(value in assistantProviderPresets)) return;
|
||||
const nextProvider = value as AssistantProvider;
|
||||
const nextPreset = assistantProviderPresets[nextProvider];
|
||||
clearDraftConnectionState();
|
||||
setProvider(nextProvider);
|
||||
setBaseUrl(nextPreset.baseUrl);
|
||||
setModelDiscoveryPath(nextPreset.modelDiscoveryPath ?? "");
|
||||
};
|
||||
|
||||
const changeBaseUrl = (value: string) => {
|
||||
if (value !== baseUrl) {
|
||||
clearDraftConnectionState();
|
||||
}
|
||||
setBaseUrl(value);
|
||||
};
|
||||
|
||||
const changeModelDiscoveryPath = (value: string) => {
|
||||
if (value !== modelDiscoveryPath) {
|
||||
setModelId("");
|
||||
setEnabled(false);
|
||||
}
|
||||
setModelDiscoveryPath(value);
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
setHeaders((current) => [...current, { id: nextHeaderId, name: "", value: "" }]);
|
||||
setNextHeaderId((current) => current + 1);
|
||||
setClearHeaders(false);
|
||||
};
|
||||
|
||||
const updateHeader = (id: number, field: "name" | "value", value: string) => {
|
||||
setHeaders((current) => current.map((header) => (header.id === id ? { ...header, [field]: value } : header)));
|
||||
};
|
||||
|
||||
const saveConnection = (clearApiKey = false) => {
|
||||
const customHeaders =
|
||||
headers.length > 0 ? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value])) : undefined;
|
||||
updateConnection.mutate({
|
||||
provider,
|
||||
baseUrl,
|
||||
modelDiscoveryPath: modelDiscoveryPath.trim() || null,
|
||||
apiKey: clearApiKey ? undefined : apiKey.trim() || undefined,
|
||||
clearApiKey,
|
||||
customHeaders,
|
||||
clearCustomHeaders: clearHeaders,
|
||||
});
|
||||
};
|
||||
|
||||
const saveAssistantConfiguration = () => {
|
||||
if (!canSaveConfiguration) return;
|
||||
saveConfiguration.mutate({
|
||||
enabled,
|
||||
modelId: modelId.trim(),
|
||||
webSearchEnabled: canUseOpenRouterServerTools && webSearchEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ConfigurationSkeleton label={t("loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack className={classes.configuration} gap="md">
|
||||
<Paper className={classes.summary} withBorder p="md" radius="md" pos="relative">
|
||||
<LoadingOverlay visible={pending} />
|
||||
<Group justify="space-between" align="center">
|
||||
<Group className={classes.summaryProvider} gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="default" size="lg" radius="md">
|
||||
<ProviderIcon providerId={provider} size={22} />
|
||||
</ThemeIcon>
|
||||
<Box className={classes.summaryProviderText}>
|
||||
<Group gap="xs">
|
||||
<Text fw={650}>{t(`provider.options.${provider}.label`)}</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={connectionChanged ? "yellow" : connectionReady ? "green" : "gray"}
|
||||
>
|
||||
{connectionChanged
|
||||
? t("overview.unsavedChanges")
|
||||
: connectionReady
|
||||
? t("overview.connectionReady")
|
||||
: t("overview.setupRequired")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text className={classes.summaryEndpoint} size="sm" c="dimmed" truncate>
|
||||
{configuration?.modelId ?? t("overview.noModel")} · {normalizedBaseUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group className={classes.summaryActions} gap="xs">
|
||||
{configuration?.webSearchEnabled && (
|
||||
<Badge variant="light" leftSection={<IconSearch size={12} />}>
|
||||
{t("serverTools.webSearch.label")}
|
||||
</Badge>
|
||||
)}
|
||||
{hasStoredApiKey && (
|
||||
<Badge variant="light" color="teal" leftSection={<IconLock size={12} />}>
|
||||
{t("overview.keyProtected")}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="filled" color={configuration?.enabled ? "green" : "gray"}>
|
||||
{configuration?.enabled ? t("overview.available") : t("overview.disabled")}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Group gap="xs" align="flex-start" wrap="nowrap">
|
||||
<IconShieldCheck size={18} color="var(--mantine-color-blue-6)" aria-hidden />
|
||||
<Text className={classes.securityNote} size="sm" c="dimmed">
|
||||
{t("security.description")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<ConfigurationSection
|
||||
number={1}
|
||||
title={t("connection.endpointTitle")}
|
||||
description={t("connection.description")}
|
||||
status={
|
||||
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
|
||||
{t(`provider.options.${provider}.label`)}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={t("provider.title")}
|
||||
description={t(`provider.options.${provider}.description`)}
|
||||
value={provider}
|
||||
onChange={changeProvider}
|
||||
data={providerOptions}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
leftSection={<ProviderIcon providerId={provider} />}
|
||||
renderOption={({ option }) => (
|
||||
<Group gap="sm" wrap="nowrap" align={option.value === "homarr" ? "flex-start" : "center"} w="100%">
|
||||
<ProviderIcon providerId={option.value as AssistantProviderOption} />
|
||||
<Box flex={1} miw={0}>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Text size="sm" truncate>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.value === "homarr" && (
|
||||
<Badge size="xs" variant="light" color="gray" flex="0 0 auto">
|
||||
{t("provider.comingSoon")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{option.value === "homarr" && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2} maw="46ch">
|
||||
{t("provider.options.homarr.description")}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("baseUrl.title")}
|
||||
description={t("baseUrl.description")}
|
||||
leftSection={<IconWorld size={16} />}
|
||||
value={baseUrl}
|
||||
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
|
||||
placeholder="https://provider.example/v1"
|
||||
/>
|
||||
<TextInput
|
||||
label={t("model.discoveryPath")}
|
||||
description={t("model.discoveryPathDescription")}
|
||||
value={modelDiscoveryPath}
|
||||
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
|
||||
placeholder="/models"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{destinationChanged && (
|
||||
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("destinationChanged.title")}>
|
||||
{t("destinationChanged.description")}
|
||||
</Alert>
|
||||
)}
|
||||
</ConfigurationSection>
|
||||
|
||||
<ConfigurationSection
|
||||
number={2}
|
||||
title={t("credentials.title")}
|
||||
description={t("credentials.description")}
|
||||
status={
|
||||
<Badge variant="light" color={hasStoredApiKey ? "green" : preset.requiresApiKey ? "yellow" : "gray"}>
|
||||
{hasStoredApiKey
|
||||
? t("apiKey.configuredBadge")
|
||||
: preset.requiresApiKey
|
||||
? t("credentials.required")
|
||||
: t("credentials.optional")}
|
||||
</Badge>
|
||||
}
|
||||
actions={
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => setClearCredentialsOpened(true)}
|
||||
disabled={!configuration?.apiKeyConfigured && !configuration?.customHeadersConfigured}
|
||||
>
|
||||
{t("connection.clearCredentials")}
|
||||
</Button>
|
||||
{credentialFlow === "idle" && (
|
||||
<Button
|
||||
leftSection={<IconCheck size={16} />}
|
||||
onClick={() => saveConnection()}
|
||||
disabled={!connectionValid}
|
||||
loading={updateConnection.isPending}
|
||||
>
|
||||
{t("connection.save")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{hasStoredApiKey && credentialFlow === "idle" ? (
|
||||
<Group className={classes.credentialSummary} justify="space-between" align="center">
|
||||
<Group className={classes.credentialSummaryContent} gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color="teal" variant="light" radius="xl">
|
||||
<IconShieldLock size={18} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={600} size="sm">
|
||||
{t("apiKey.savedTitle")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("apiKey.savedDescription")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group className={classes.credentialActions} gap="xs">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconKey size={16} />}
|
||||
onClick={() => {
|
||||
setCredentialFlow("replace");
|
||||
setCredentialStep(0);
|
||||
}}
|
||||
>
|
||||
{t("apiKey.replace")}
|
||||
</Button>
|
||||
<Button variant="subtle" color="red" onClick={() => setCredentialFlow("remove")}>
|
||||
{t("apiKey.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
) : credentialFlow === "replace" ? (
|
||||
<Box className={classes.credentialFlow}>
|
||||
<Stepper
|
||||
className={classes.credentialStepper}
|
||||
active={credentialStep}
|
||||
size="sm"
|
||||
allowNextStepsSelect={false}
|
||||
>
|
||||
<Stepper.Step label={t("apiKey.replaceFlow.enterStep")}>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={650}>{t("apiKey.replaceFlow.title")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("apiKey.replaceFlow.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
<PasswordInput
|
||||
label={t("apiKey.replaceFlow.newKey")}
|
||||
description={t("apiKey.replaceFlow.newKeyDescription")}
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.currentTarget.value)}
|
||||
leftSection={<IconKey size={16} />}
|
||||
placeholder={t("apiKey.replacementPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
ref={replacementKeyInputRef}
|
||||
/>
|
||||
<Box className={classes.flowActions}>
|
||||
<Button variant="default" onClick={resetCredentialFlow}>
|
||||
{t("apiKey.replaceFlow.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
rightSection={<IconCheck size={16} />}
|
||||
disabled={apiKey.trim().length === 0}
|
||||
onClick={() => setCredentialStep(1)}
|
||||
>
|
||||
{t("apiKey.replaceFlow.review")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("apiKey.replaceFlow.reviewStep")}>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
color="yellow"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title={t("apiKey.replaceFlow.warningTitle")}
|
||||
>
|
||||
{t("apiKey.replaceFlow.warningDescription")}
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("apiKey.replaceFlow.provider")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{t(`provider.options.${provider}.label`)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("apiKey.replaceFlow.endpoint")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{normalizedBaseUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
<Box className={classes.flowActions}>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => setCredentialStep(0)}
|
||||
>
|
||||
{t("apiKey.replaceFlow.back")}
|
||||
</Button>
|
||||
<Button
|
||||
color="yellow"
|
||||
variant="filled"
|
||||
leftSection={<IconKey size={16} />}
|
||||
onClick={() => saveConnection()}
|
||||
disabled={!connectionValid}
|
||||
loading={updateConnection.isPending}
|
||||
>
|
||||
{t("apiKey.replaceFlow.confirm")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Box>
|
||||
) : credentialFlow === "remove" ? (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("apiKey.removeFlow.title")}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{preset.requiresApiKey
|
||||
? t("apiKey.removeFlow.description")
|
||||
: t("apiKey.removeFlow.optionalDescription")}
|
||||
</Text>
|
||||
<Box className={classes.flowActions}>
|
||||
<Button variant="default" onClick={resetCredentialFlow}>
|
||||
{t("apiKey.removeFlow.cancel")}
|
||||
</Button>
|
||||
<Button color="red" loading={updateConnection.isPending} onClick={() => saveConnection(true)}>
|
||||
{t("apiKey.removeFlow.confirm")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : (
|
||||
<PasswordInput
|
||||
label={t("apiKey.title")}
|
||||
description={preset.requiresApiKey ? t("apiKey.required") : t("apiKey.optional")}
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.currentTarget.value)}
|
||||
leftSection={<IconKey size={16} />}
|
||||
placeholder={t("apiKey.placeholder")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="headers">
|
||||
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("headers.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("headers.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
{configuration?.customHeadersConfigured && (
|
||||
<Badge variant="light" color={clearHeaders ? "yellow" : "green"}>
|
||||
{clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")}
|
||||
</Text>
|
||||
<Button variant="light" size="compact-sm" leftSection={<IconPlus size={14} />} onClick={addHeader}>
|
||||
{t("headers.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
{headers.map((header) => (
|
||||
<Group key={header.id} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
flex={1}
|
||||
miw="12rem"
|
||||
label={t("headers.name")}
|
||||
value={header.name}
|
||||
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
|
||||
placeholder="X-Provider-Header"
|
||||
/>
|
||||
<PasswordInput
|
||||
flex={2}
|
||||
miw="12rem"
|
||||
label={t("headers.value")}
|
||||
value={header.value}
|
||||
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
|
||||
placeholder={t("headers.valuePlaceholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Tooltip label={t("headers.remove")}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("headers.remove")}
|
||||
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
|
||||
>
|
||||
<IconTrash size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
))}
|
||||
{configuration?.customHeadersConfigured && headers.length === 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color={clearHeaders ? "gray" : "red"}
|
||||
onClick={() => setClearHeaders(!clearHeaders)}
|
||||
>
|
||||
{clearHeaders ? t("headers.keep") : t("headers.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</ConfigurationSection>
|
||||
|
||||
<ConfigurationSection
|
||||
number={3}
|
||||
title={t("model.title")}
|
||||
description={t("model.description")}
|
||||
status={
|
||||
configuration?.modelId ? (
|
||||
<Badge variant="light" color={configuration.enabled ? "green" : "gray"}>
|
||||
{configuration.enabled ? t("overview.available") : t("overview.disabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="light" color="gray">
|
||||
{t("overview.noModel")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<IconCircleCheck size={16} />}
|
||||
onClick={saveAssistantConfiguration}
|
||||
disabled={!canSaveConfiguration}
|
||||
loading={saveConfiguration.isPending}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{connectionPending ? t("model.saveConnectionFirst") : t("model.chooseModel")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<IconDatabaseSearch size={14} />}
|
||||
loading={isDiscovering}
|
||||
disabled={modelControlsDisabled || configuration?.modelDiscoveryPath === null}
|
||||
onClick={() => void discoverModels()}
|
||||
>
|
||||
{t("model.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
{hasDiscoveredModels ? (
|
||||
<Select
|
||||
label={t("model.title")}
|
||||
value={modelId || null}
|
||||
onChange={(value) => setModelId(value ?? "")}
|
||||
data={modelControlsDisabled ? [] : modelOptions}
|
||||
disabled={modelControlsDisabled}
|
||||
placeholder={t("model.placeholder")}
|
||||
description={t("model.discovered", { count: models?.length ?? 0 })}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
limit={100}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label={t("model.title")}
|
||||
value={modelId}
|
||||
onChange={(event) => setModelId(event.currentTarget.value)}
|
||||
disabled={modelControlsDisabled}
|
||||
placeholder={t("model.placeholder")}
|
||||
description={
|
||||
connectionPending
|
||||
? t("model.saveConnectionFirst")
|
||||
: isDiscovering
|
||||
? t("model.discovering")
|
||||
: discoveryError || models?.length === 0
|
||||
? t("model.manualFallback")
|
||||
: t("model.manual")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{discoveryError && !connectionPending && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("model.discoveryFailed")}>
|
||||
{discoveryError.message}
|
||||
</Alert>
|
||||
)}
|
||||
{selectedModel && (
|
||||
<Group className={classes.selectedModel} justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box className={classes.selectedModelText}>
|
||||
<Text fw={600}>{selectedModel.name}</Text>
|
||||
{selectedModel.description && (
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{selectedModel.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Group className={classes.selectedModelBadges} gap="xs" justify="flex-end">
|
||||
{selectedModel.contextLength && (
|
||||
<Badge variant="light">{t("model.context", { count: selectedModel.contextLength })}</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={selectedModel.toolSupport === "confirmed" ? "green" : "gray"}
|
||||
leftSection={<IconTool size={12} />}
|
||||
>
|
||||
{selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{canUseOpenRouterServerTools && (
|
||||
<Stack gap="xs">
|
||||
<Divider label={t("serverTools.title")} labelPosition="left" />
|
||||
<Group className={classes.capabilityRow} justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="xl" size="md">
|
||||
<IconSearch size={17} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("serverTools.webSearch.label")}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{t("serverTools.beta")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" maw="66ch">
|
||||
{provider === "custom"
|
||||
? t("serverTools.webSearch.proxyDescription")
|
||||
: t("serverTools.webSearch.description")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t("serverTools.webSearch.limit")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={webSearchEnabled}
|
||||
onChange={(event) => setWebSearchEnabled(event.currentTarget.checked)}
|
||||
disabled={modelControlsDisabled}
|
||||
aria-label={t("serverTools.webSearch.label")}
|
||||
size="md"
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group className={classes.enableRow} justify="space-between" align="center" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("enabled.label")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("enabled.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.currentTarget.checked)}
|
||||
disabled={!canSaveConfiguration}
|
||||
aria-label={t("enabled.label")}
|
||||
size="md"
|
||||
/>
|
||||
</Group>
|
||||
</ConfigurationSection>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={clearCredentialsOpened}
|
||||
onClose={() => setClearCredentialsOpened(false)}
|
||||
title={t("clearCredentials.title")}
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("clearCredentials.warningTitle")}>
|
||||
{t("clearCredentials.description")}
|
||||
</Alert>
|
||||
<Divider />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setClearCredentialsOpened(false)}>
|
||||
{t("clearCredentials.cancel")}
|
||||
</Button>
|
||||
<Button color="red" loading={clearCredentials.isPending} onClick={() => clearCredentials.mutate()}>
|
||||
{t("clearCredentials.confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } 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");
|
||||
return {
|
||||
title: `${t("title")} • Homarr`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AssistantPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.settings.section.assistant");
|
||||
return (
|
||||
<ManagePageLayout title={t("title")}>
|
||||
<Text c="dimmed" maw="70ch">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<AssistantConfiguration />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildAiPrompt } from "./_copy-ai-prompt-button";
|
||||
|
||||
const schema = {
|
||||
type: "object",
|
||||
required: ["name", "url", "displayType", "displayConfig"],
|
||||
};
|
||||
|
||||
describe("buildAiPrompt", () => {
|
||||
test("asks Homarr Assistant to create a definition and includes the user's request", () => {
|
||||
const prompt = buildAiPrompt(
|
||||
schema,
|
||||
'{"status":"online"}',
|
||||
{
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Status",
|
||||
url: "https://example.com/status",
|
||||
},
|
||||
"Show a compact green status when the service is online.",
|
||||
);
|
||||
|
||||
expect(prompt).toContain("`customWidget_create`");
|
||||
expect(prompt).toContain("`secrets: []`");
|
||||
expect(prompt).toContain("do not ask for typed confirmation");
|
||||
expect(prompt).toContain('```json\n{"status":"online"}\n```');
|
||||
expect(prompt).toContain('"name": "Status"');
|
||||
expect(prompt).toContain("Show a compact green status when the service is online.");
|
||||
});
|
||||
|
||||
test("asks Homarr Assistant to update the selected definition without replacing its secrets", () => {
|
||||
const prompt = buildAiPrompt(
|
||||
schema,
|
||||
null,
|
||||
{
|
||||
name: "Existing widget",
|
||||
authType: "bearer",
|
||||
},
|
||||
"Use a progress bar.",
|
||||
"widget-definition-id",
|
||||
);
|
||||
|
||||
expect(prompt).toContain("custom widget definition `widget-definition-id`");
|
||||
expect(prompt).toContain("`customWidget_update`");
|
||||
expect(prompt).toContain("Preserve configured secrets by omitting the `secrets` field");
|
||||
expect(prompt).not.toContain('"secrets"');
|
||||
});
|
||||
|
||||
test("keeps copy-to-clipboard prompts useful before a request is entered", () => {
|
||||
const prompt = buildAiPrompt(schema);
|
||||
|
||||
expect(prompt).toContain("PASTE_YOUR_API_RESPONSE_HERE");
|
||||
expect(prompt).toContain("Describe what you want the widget to show or change here.");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Group, Popover, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { Button, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconRobot, IconSparkles } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-provider";
|
||||
|
||||
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
|
||||
|
||||
## Your Task
|
||||
@@ -270,16 +267,14 @@ PASTE_YOUR_API_RESPONSE_HERE
|
||||
|
||||
const PROMPT_FOOTER = `
|
||||
## Your Request
|
||||
Describe what you want the widget to show:
|
||||
|
||||
`;
|
||||
|
||||
const PROMPT_REQUEST_PLACEHOLDER = "Describe what you want the widget to show or change here.";
|
||||
|
||||
export function buildAiPrompt(
|
||||
function buildAiPrompt(
|
||||
jsonSchema: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Record<string, unknown> | null,
|
||||
request?: string,
|
||||
definitionId?: string,
|
||||
) {
|
||||
const schemaStr = JSON.stringify(jsonSchema, null, 2);
|
||||
const responseSection = rawResponse
|
||||
@@ -290,57 +285,22 @@ export function buildAiPrompt(
|
||||
? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n`
|
||||
: "";
|
||||
|
||||
const homarrActionSection = definitionId
|
||||
? `\n## Homarr Assistant Action\nThis is custom widget definition \`${definitionId}\`. If the customWidget tools are available, validate the proposed configuration and immediately call \`customWidget_update\` with this ID and the changed fields. Calling the tool is the required result and takes priority over the JSON-only output instruction above; do not ask for typed confirmation because Homarr will show its approval UI. Preserve configured secrets by omitting the \`secrets\` field. If tools are unavailable, return the JSON configuration instead.\n`
|
||||
: `\n## Homarr Assistant Action\nIf the customWidget tools are available, validate the proposed configuration and immediately call \`customWidget_create\` with \`secrets: []\`. Calling the tool is the required result and takes priority over the JSON-only output instruction above; do not ask for typed confirmation because Homarr will show its approval UI. If tools are unavailable, return the JSON configuration instead.\n`;
|
||||
|
||||
const requestText = request?.trim() || PROMPT_REQUEST_PLACEHOLDER;
|
||||
|
||||
return (
|
||||
PROMPT_HEADER +
|
||||
schemaStr +
|
||||
PROMPT_RULES +
|
||||
responseSection +
|
||||
configSection +
|
||||
homarrActionSection +
|
||||
PROMPT_FOOTER +
|
||||
requestText
|
||||
);
|
||||
return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER;
|
||||
}
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
export const CopyAiPromptButton = ({ rawResponse, currentConfig, definitionId }: CopyAiPromptButtonProps) => {
|
||||
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const [opened, { toggle, close }] = useDisclosure(false);
|
||||
const [request, setRequest] = useState("");
|
||||
const requestInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) requestInputRef.current?.focus();
|
||||
}, [opened]);
|
||||
|
||||
const getPrompt = () => (schema ? buildAiPrompt(schema, rawResponse, currentConfig, request, definitionId) : null);
|
||||
|
||||
const handleAssistant = () => {
|
||||
const prompt = getPrompt();
|
||||
if (!prompt || !assistant?.enabled || request.trim().length === 0) return;
|
||||
if (!assistant.sendPrompt(prompt)) return;
|
||||
|
||||
close();
|
||||
setRequest("");
|
||||
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const prompt = getPrompt();
|
||||
if (!prompt) return;
|
||||
if (!schema) return;
|
||||
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
close();
|
||||
@@ -351,70 +311,31 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig, definitionId }:
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover opened={opened} onClose={close} width="target" position="bottom" shadow="md" withinPortal>
|
||||
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={toggle}
|
||||
onClick={open}
|
||||
loading={isLoading}
|
||||
disabled={!schema}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{t("action.aiPrompt")}
|
||||
{t("action.copyAiPrompt")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("notification.aiPromptDescription")}</Text>
|
||||
<Textarea
|
||||
ref={requestInputRef}
|
||||
label={t("notification.aiPromptRequestLabel")}
|
||||
placeholder={t("notification.aiPromptRequestPlaceholder")}
|
||||
value={request}
|
||||
onChange={(event) => setRequest(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
handleAssistant();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!rawResponse && (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("notification.aiPromptNoResponse")}
|
||||
</Text>
|
||||
)}
|
||||
{assistant?.enabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
leftSection={<IconRobot size={16} />}
|
||||
onClick={handleAssistant}
|
||||
disabled={request.trim().length === 0 || assistant.isRunning}
|
||||
fullWidth
|
||||
>
|
||||
{assistant.isRunning ? t("notification.aiPromptAssistantBusy") : t("notification.aiPromptAssistant")}
|
||||
</Button>
|
||||
) : (
|
||||
<Alert color="gray" icon={<IconRobot size={16} />}>
|
||||
{t("notification.aiPromptAssistantUnavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -688,19 +688,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
}, [previewRefreshSignal]);
|
||||
|
||||
const previewInput = getPreviewInput();
|
||||
const aiPromptCurrentConfig = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: form.values.name,
|
||||
description: form.values.description || undefined,
|
||||
iconUrl: form.values.iconUrl || undefined,
|
||||
url: form.values.url,
|
||||
authType: form.values.authType,
|
||||
headerName: form.values.headerName || undefined,
|
||||
method: form.values.method,
|
||||
requestBody: form.values.requestBody || undefined,
|
||||
displayType: form.values.displayType,
|
||||
displayConfig: previewInput.displayConfig,
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -710,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
|
||||
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
suggestedSearch={mode === "create" ? form.values.name : undefined}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
@@ -822,22 +813,15 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
</Fieldset>
|
||||
|
||||
<Box hiddenFrom="md">
|
||||
<Stack gap="sm">
|
||||
<CopyAiPromptButton
|
||||
rawResponse={previewFetchResult?.rawResponse}
|
||||
currentConfig={aiPromptCurrentConfig}
|
||||
definitionId={definitionId}
|
||||
/>
|
||||
<CustomWidgetPreview
|
||||
getFormValues={getPreviewInput}
|
||||
formValues={previewInput}
|
||||
fetchResult={previewFetchResult}
|
||||
cachedJson={previewJson}
|
||||
onTest={() => void handlePreviewTest()}
|
||||
isTesting={previewMutation.isPending}
|
||||
testError={previewMutation.error?.message ?? null}
|
||||
/>
|
||||
</Stack>
|
||||
<CustomWidgetPreview
|
||||
getFormValues={getPreviewInput}
|
||||
formValues={previewInput}
|
||||
fetchResult={previewFetchResult}
|
||||
cachedJson={previewJson}
|
||||
onTest={() => void handlePreviewTest()}
|
||||
isTesting={previewMutation.isPending}
|
||||
testError={previewMutation.error?.message ?? null}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group justify="end">
|
||||
@@ -865,8 +849,19 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
</Button>
|
||||
<CopyAiPromptButton
|
||||
rawResponse={previewFetchResult?.rawResponse}
|
||||
currentConfig={aiPromptCurrentConfig}
|
||||
definitionId={definitionId}
|
||||
currentConfig={{
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: form.values.name,
|
||||
description: form.values.description,
|
||||
iconUrl: form.values.iconUrl,
|
||||
url: form.values.url,
|
||||
authType: form.values.authType,
|
||||
headerName: form.values.headerName,
|
||||
method: form.values.method,
|
||||
requestBody: form.values.requestBody,
|
||||
displayType: form.values.displayType,
|
||||
displayConfig: previewInput.displayConfig,
|
||||
}}
|
||||
/>
|
||||
<CustomWidgetPreview
|
||||
getFormValues={getPreviewInput}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
@@ -18,11 +16,6 @@ interface EditIntegrationPageProps {
|
||||
|
||||
export default async function EditIntegrationPage(props: EditIntegrationPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("integration-full-all")) {
|
||||
notFound();
|
||||
}
|
||||
const editT = await getScopedI18n("integration.page.edit");
|
||||
const t = await getI18n();
|
||||
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
@@ -23,10 +23,7 @@ interface NewIntegrationPageProps {
|
||||
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
if (
|
||||
!session?.user.permissions.includes("integration-full-all") ||
|
||||
!session?.user.permissions.includes("integration-create")
|
||||
) {
|
||||
if (!session?.user.permissions.includes("integration-create")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
@@ -50,9 +50,6 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
if (!session.user.permissions.includes("integration-full-all")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const integrations = await api.integration.all();
|
||||
const t = await getScopedI18n("integration");
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
IconMailForward,
|
||||
IconPhotoFilled,
|
||||
IconPointerFilled,
|
||||
IconRobot,
|
||||
IconSearch,
|
||||
IconSettingsFilled,
|
||||
IconUserFilled,
|
||||
@@ -28,8 +27,10 @@ import {
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { env } from "@homarr/docker/env";
|
||||
@@ -40,11 +41,29 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
||||
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
|
||||
import { env as nextEnv } from "~/env";
|
||||
|
||||
const logger = createLogger({ module: "manageLayout" });
|
||||
|
||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
const t = await getScopedI18n("management.navbar");
|
||||
const session = await auth();
|
||||
const sessionPromise = auth();
|
||||
const shouldRunManageTourPromise = sessionPromise.then(async (session) => {
|
||||
if (!session || nextEnv.DEMO_MODE) return false;
|
||||
|
||||
try {
|
||||
const user = await getRscUserSettingsAsync(session.user.id);
|
||||
return user !== undefined && !user.completedManageTour;
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to load the management tour status", { cause: error }));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [t, session, shouldRunManageTour] = await Promise.all([
|
||||
getScopedI18n("management.navbar"),
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
@@ -72,7 +91,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session?.user.permissions.includes("integration-full-all"),
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
@@ -172,12 +191,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("items.assistant"),
|
||||
href: "/manage/assistant",
|
||||
icon: IconRobot,
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.settings"),
|
||||
href: "/manage/settings",
|
||||
@@ -232,7 +245,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
</ClientShell>
|
||||
);
|
||||
|
||||
if (!session) return shell;
|
||||
|
||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
||||
return (
|
||||
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
|
||||
{shell}
|
||||
</ManageTourGate>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
{...form.getInputProps("iconUrl")}
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -1,53 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { SwitchSetting } from "./setting-switch";
|
||||
|
||||
interface AnalyticsSettingsProps {
|
||||
initialData: typeof defaultServerSettings.analytics;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section.analytics");
|
||||
const { instanceId: _, ...formDefaults } = initialData;
|
||||
const form = useForm({
|
||||
initialValues: formDefaults,
|
||||
onValuesChange: (updatedValues, _prev) => {
|
||||
if (!form.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void mutateAsync({
|
||||
settingsKey: "analytics",
|
||||
value: updatedValues,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative">
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
<SectionCard title={t("title")}>
|
||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
+21
-20
@@ -5,31 +5,32 @@ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
|
||||
const tApperance = useScopedI18n("management.page.settings.section.appearance");
|
||||
interface AppearanceSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => {
|
||||
const tAppearance = useScopedI18n("management.page.settings.section.appearance");
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<SelectWithCustomItems
|
||||
label={tApperance("defaultColorScheme.label")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tApperance(`defaultColorScheme.options.${scheme}`),
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={ApperanceCustomOption}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tAppearance("title")}>
|
||||
<SelectWithCustomItems
|
||||
label={tAppearance("defaultColorScheme.label")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tAppearance(`defaultColorScheme.options.${scheme}`),
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={AppearanceCustomOption}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,7 +40,7 @@ const appearanceIcons = {
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||
const AppearanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||
const Icon = appearanceIcons[value];
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,49 +3,50 @@
|
||||
import { Switch, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||
interface BoardSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
const tBoard = useScopedI18n("management.page.settings.section.board");
|
||||
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<SectionCard title={tBoard("title")}>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
label={tBoard("status.enableStatusByDefault.label")}
|
||||
description={tBoard("status.enableStatusByDefault.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||
label={tBoard("status.forceDisableStatus.label")}
|
||||
description={tBoard("status.forceDisableStatus.description")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
label={tBoard("status.enableStatusByDefault.label")}
|
||||
description={tBoard("status.enableStatusByDefault.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||
label={tBoard("status.forceDisableStatus.label")}
|
||||
description={tBoard("status.forceDisableStatus.description")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
|
||||
settingKey,
|
||||
defaultValues,
|
||||
children,
|
||||
}: {
|
||||
settingKey: TKey;
|
||||
defaultValues: ServerSettings[TKey];
|
||||
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: tSettings("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: tSettings("notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: defaultValues,
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
|
||||
await mutateAsync({
|
||||
settingsKey: settingKey,
|
||||
value: values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="sm">
|
||||
{children(form)}
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
+21
-54
@@ -1,68 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { SwitchSetting } from "./setting-switch";
|
||||
|
||||
interface CrawlingAndIndexingSettingsProps {
|
||||
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
|
||||
export const CrawlingAndIndexingSettings = ({ form }: CrawlingAndIndexingSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
|
||||
const form = useForm({
|
||||
initialValues: initialData,
|
||||
onValuesChange: (updatedValues, _) => {
|
||||
if (!form.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void mutateAsync({
|
||||
settingsKey: "crawlingAndIndexing",
|
||||
value: updatedValues,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative">
|
||||
<Text c={"dimmed"} mb={"lg"}>
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noTranslate"
|
||||
title={t("noTranslate.title")}
|
||||
text={t("noTranslate.text")}
|
||||
/>
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noSiteLinksSearchBox"
|
||||
title={t("noSiteLinksSearchBox.title")}
|
||||
text={t("noSiteLinksSearchBox.text")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
<SectionCard title={t("title")}>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
<SwitchSetting form={form} formKey="noTranslate" title={t("noTranslate.title")} text={t("noTranslate.text")} />
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noSiteLinksSearchBox"
|
||||
title={t("noSiteLinksSearchBox.title")}
|
||||
text={t("noSiteLinksSearchBox.text")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
+16
-14
@@ -1,26 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { LanguageCombobox } from "~/components/language/language-combobox";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => {
|
||||
interface CultureSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
|
||||
const tCulture = useScopedI18n("management.page.settings.section.culture");
|
||||
const localeInputProps = form.getInputProps("defaultLocale");
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<LanguageCombobox
|
||||
label={tCulture("defaultLocale.label")}
|
||||
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
|
||||
{...form.getInputProps("defaultLocale")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tCulture("title")}>
|
||||
<LanguageCombobox
|
||||
label={tCulture("defaultLocale.label")}
|
||||
{...localeInputProps}
|
||||
value={localeInputProps.value as SupportedLanguage}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -3,27 +3,28 @@
|
||||
import { Select } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
|
||||
interface SearchSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
|
||||
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<Select
|
||||
label={tSearch("defaultSearchEngine.label")}
|
||||
description={tSearch("defaultSearchEngine.description")}
|
||||
data={selectableSearchEngines}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tSearch("title")}>
|
||||
<Select
|
||||
label={tSearch("defaultSearchEngine.label")}
|
||||
description={tSearch("defaultSearchEngine.description")}
|
||||
data={selectableSearchEngines}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -3,10 +3,13 @@ import React from "react";
|
||||
import type { MantineSpacing } from "@mantine/core";
|
||||
import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
|
||||
export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
type BooleanKeys<TFormValue> = {
|
||||
[TKey in keyof TFormValue]: TFormValue[TKey] extends boolean ? TKey : never;
|
||||
}[keyof TFormValue];
|
||||
|
||||
export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
|
||||
form,
|
||||
ms,
|
||||
title,
|
||||
@@ -14,13 +17,8 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
formKey,
|
||||
disabled,
|
||||
}: {
|
||||
form: Modify<
|
||||
UseFormReturnType<TFormValue, TFormValue>,
|
||||
{
|
||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||
}
|
||||
>;
|
||||
formKey: keyof TFormValue;
|
||||
form: UseFormReturnType<TFormValue, TFormValue>;
|
||||
formKey: string & BooleanKeys<TFormValue>;
|
||||
ms?: MantineSpacing;
|
||||
title: string;
|
||||
text: ReactNode;
|
||||
@@ -31,7 +29,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldValue(formKey, (previous) => !previous);
|
||||
form.setFieldValue(formKey, (previous) => !previous as never);
|
||||
}, [form, formKey, disabled]);
|
||||
|
||||
return (
|
||||
@@ -44,7 +42,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
</Text>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={Boolean(form.values[formKey])} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
import { AnalyticsSettings } from "./analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./board-settings-form";
|
||||
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
|
||||
import { CultureSettingsForm } from "./culture-settings-form";
|
||||
import { SearchSettingsForm } from "./search-settings-form";
|
||||
import { UserSettingsForm } from "./user-settings-form";
|
||||
|
||||
const settingsFormSchema = z.object({
|
||||
enableGeneral: z.boolean(),
|
||||
noIndex: z.boolean(),
|
||||
noFollow: z.boolean(),
|
||||
noTranslate: z.boolean(),
|
||||
noSiteLinksSearchBox: z.boolean(),
|
||||
homeBoardId: z.string().nullable(),
|
||||
mobileHomeBoardId: z.string().nullable(),
|
||||
enableStatusByDefault: z.boolean(),
|
||||
forceDisableStatus: z.boolean(),
|
||||
enableGravatar: z.boolean(),
|
||||
defaultSearchEngineId: z.string().nullable(),
|
||||
defaultColorScheme: z.enum(colorSchemes),
|
||||
defaultLocale: z.string(),
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof settingsFormSchema>;
|
||||
|
||||
const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
enableGeneral: initialData.analytics.enableGeneral,
|
||||
noIndex: initialData.crawlingAndIndexing.noIndex,
|
||||
noFollow: initialData.crawlingAndIndexing.noFollow,
|
||||
noTranslate: initialData.crawlingAndIndexing.noTranslate,
|
||||
noSiteLinksSearchBox: initialData.crawlingAndIndexing.noSiteLinksSearchBox,
|
||||
homeBoardId: initialData.board.homeBoardId,
|
||||
mobileHomeBoardId: initialData.board.mobileHomeBoardId,
|
||||
enableStatusByDefault: initialData.board.enableStatusByDefault,
|
||||
forceDisableStatus: initialData.board.forceDisableStatus,
|
||||
enableGravatar: initialData.user.enableGravatar,
|
||||
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
|
||||
defaultColorScheme: initialData.appearance.defaultColorScheme,
|
||||
defaultLocale: initialData.culture.defaultLocale,
|
||||
});
|
||||
|
||||
interface SettingsFormProps {
|
||||
initialData: ServerSettings;
|
||||
}
|
||||
|
||||
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
|
||||
const initialValues = buildInitialValues(initialData);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
|
||||
const form = useZodForm(settingsFormSchema, {
|
||||
initialValues,
|
||||
});
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const saveSettingsMutation = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onError(error) {
|
||||
showErrorNotification({ title: t("common.notification.update.error"), message: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (isDirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||
fields.some((field) => values[field] !== defaults[field]);
|
||||
|
||||
const groups: {
|
||||
settingsKey: (typeof defaultServerSettingsKeys)[number];
|
||||
when: boolean;
|
||||
value: Record<string, unknown>;
|
||||
}[] = [
|
||||
{ settingsKey: "analytics", when: changed("enableGeneral"), value: { enableGeneral: values.enableGeneral } },
|
||||
{
|
||||
settingsKey: "crawlingAndIndexing",
|
||||
when: changed("noIndex", "noFollow", "noTranslate", "noSiteLinksSearchBox"),
|
||||
value: {
|
||||
noIndex: values.noIndex,
|
||||
noFollow: values.noFollow,
|
||||
noTranslate: values.noTranslate,
|
||||
noSiteLinksSearchBox: values.noSiteLinksSearchBox,
|
||||
},
|
||||
},
|
||||
{
|
||||
settingsKey: "board",
|
||||
when: changed("homeBoardId", "mobileHomeBoardId", "enableStatusByDefault", "forceDisableStatus"),
|
||||
value: {
|
||||
homeBoardId: values.homeBoardId,
|
||||
mobileHomeBoardId: values.mobileHomeBoardId,
|
||||
enableStatusByDefault: values.enableStatusByDefault,
|
||||
forceDisableStatus: values.forceDisableStatus,
|
||||
},
|
||||
},
|
||||
{ settingsKey: "user", when: changed("enableGravatar"), value: { enableGravatar: values.enableGravatar } },
|
||||
{
|
||||
settingsKey: "search",
|
||||
when: changed("defaultSearchEngineId"),
|
||||
value: { defaultSearchEngineId: values.defaultSearchEngineId },
|
||||
},
|
||||
{
|
||||
settingsKey: "appearance",
|
||||
when: changed("defaultColorScheme"),
|
||||
value: { defaultColorScheme: values.defaultColorScheme },
|
||||
},
|
||||
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
|
||||
];
|
||||
|
||||
const promises = groups
|
||||
.filter((group) => group.when)
|
||||
.map((group) => saveSettingsMutation.mutateAsync({ settingsKey: group.settingsKey, value: group.value }));
|
||||
if (promises.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(promises);
|
||||
if (!results.every((result) => result.status === "fulfilled")) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialValuesRef.current = values;
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty();
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: tSettings("notification.success.message"),
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
<BoardSettingsForm form={form} />
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isSubmitting} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isSubmitting} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -2,25 +2,26 @@
|
||||
|
||||
import { Switch } from "@mantine/core";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => {
|
||||
interface UserSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const UserSettingsForm = ({ form }: UserSettingsFormProps) => {
|
||||
const tUser = useScopedI18n("management.page.settings.section.user");
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<Switch
|
||||
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
||||
label={tUser("enableGravatar.label")}
|
||||
description={tUser("enableGravatar.description")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tUser("title")}>
|
||||
<Switch
|
||||
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
||||
label={tUser("enableGravatar.label")}
|
||||
description={tUser("enableGravatar.description")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<Center h="40vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,8 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||
import { UserSettingsForm } from "./_components/user-settings-form";
|
||||
import { SettingsForm } from "./_components/settings-form";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
@@ -32,33 +26,13 @@ export default async function SettingsPage() {
|
||||
|
||||
const serverSettings = await api.serverSettings.getAll();
|
||||
const tSettings = await getScopedI18n("management.page.settings");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tSettings("title")}</Title>
|
||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.user.title")}</Title>
|
||||
<UserSettingsForm defaultValues={serverSettings.user} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.search.title")}</Title>
|
||||
<SearchSettingsForm defaultValues={serverSettings.search} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.culture.title")}</Title>
|
||||
<CultureSettingsForm defaultValues={serverSettings.culture} />
|
||||
</Stack>
|
||||
<SettingsForm initialData={serverSettings} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
import { ApiReferenceReact } from "@scalar/api-reference-react";
|
||||
|
||||
import "@scalar/api-reference-react/style.css";
|
||||
import "./scalar-theme.css";
|
||||
|
||||
// The API reference is a whole documentation SPA reachable only from this one admin
|
||||
// tab, and it renders nothing useful on the server. Loading it lazily keeps it out
|
||||
// of the server graph instead of compiling it for an SSR pass that is discarded.
|
||||
const ApiReferenceReact = dynamic(() => import("@scalar/api-reference-react").then((mod) => mod.ApiReferenceReact), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface ScalarApiReferenceProps {
|
||||
document: object;
|
||||
}
|
||||
|
||||
+3
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
@@ -9,6 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const ResetTours = () => {
|
||||
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
|
||||
@@ -18,6 +20,7 @@ export const ResetTours = () => {
|
||||
message: t("resetSuccess.message"),
|
||||
});
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { use, useCallback, useState } from "react";
|
||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
|
||||
import type { Dimensions } from "./_dimension-modal";
|
||||
import { PreviewDimensionsModal } from "./_dimension-modal";
|
||||
|
||||
interface WidgetPreviewPageContentProps {
|
||||
kind: WidgetKind;
|
||||
integrationData: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind: IntegrationKind;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
|
||||
export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps) => {
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
|
||||
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||
width: 128,
|
||||
height: 128,
|
||||
@@ -45,7 +42,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
integrationIds: string[];
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
}>({
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
||||
options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}),
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
@@ -54,31 +51,41 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenEditWidgetModal = useCallback(() => {
|
||||
openWidgetEditModal(
|
||||
{
|
||||
kind,
|
||||
value: state,
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
const handleOpenEditWidgetModal = useCallback(async () => {
|
||||
const hasIntegrationSupport = "supportedIntegrations" in currentDefinition;
|
||||
setIsEditorLoading(true);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
||||
openWidgetEditModal(
|
||||
{
|
||||
kind,
|
||||
definition: currentDefinition,
|
||||
value: state,
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
},
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
|
||||
),
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
return `${t("item.edit.title")} - ${t(`widget.${kind}.name`)}`;
|
||||
{
|
||||
title(translate) {
|
||||
return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
|
||||
|
||||
const Comp = loadWidgetDynamic(kind);
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
setIsEditorLoading(false);
|
||||
}
|
||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((editMode) => !editMode);
|
||||
@@ -94,8 +101,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
});
|
||||
}, [dimensions, openPreviewDimensionsModal]);
|
||||
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
setState({ ...state, options: { ...state.options, newOptions } });
|
||||
const updateOptions = useCallback(
|
||||
({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -105,10 +115,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
>
|
||||
<Comp
|
||||
<Component
|
||||
options={state.options as never}
|
||||
integrationIds={state.integrationIds}
|
||||
width={dimensions.width}
|
||||
@@ -127,7 +137,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={handleOpenEditWidgetModal}
|
||||
onClick={() => void handleOpenEditWidgetModal()}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
loading={isEditorLoading}
|
||||
aria-label={t("common.action.edit")}
|
||||
>
|
||||
<IconPencil size={24} />
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetKinds } from "@homarr/definitions";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
const getLinks = () => {
|
||||
return Object.entries(widgetImports).map(([key, value]) => {
|
||||
return {
|
||||
href: `/widgets/${key}`,
|
||||
icon: value.definition.icon,
|
||||
label: value.definition.kind,
|
||||
};
|
||||
});
|
||||
};
|
||||
const links = widgetKinds.map((kind) => ({
|
||||
href: `/widgets/${kind}`,
|
||||
icon: widgetCatalogIcons[kind],
|
||||
label: kind,
|
||||
}));
|
||||
|
||||
export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
|
||||
const links = getLinks();
|
||||
|
||||
return (
|
||||
<ClientShell hasHeader={false}>
|
||||
<MainNavigation links={links} />
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Suspense } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Center } from "@mantine/core";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { db } from "@homarr/db";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetKinds } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
|
||||
@@ -12,25 +11,21 @@ interface Props {
|
||||
params: Promise<{ kind: string }>;
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return widgetKinds.map((kind) => ({ kind }));
|
||||
}
|
||||
|
||||
export default async function WidgetPreview(props: Props) {
|
||||
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||
const { kind } = await props.params;
|
||||
if (!widgetKinds.includes(kind as WidgetKind)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const integrationData = await db.query.integrations.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
kind: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sort = (await props.params).kind as WidgetKind;
|
||||
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<WidgetPreviewPageContent kind={sort} integrationData={integrationData} />
|
||||
<Suspense fallback={<Loader size="sm" />}>
|
||||
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||
</Suspense>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import crowdinContributors from "@static-data/translators.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(crowdinContributors);
|
||||
};
|
||||
|
||||
export const dynamic = "force-static";
|
||||
@@ -1,9 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import githubContributors from "@static-data/contributors.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(githubContributors);
|
||||
};
|
||||
|
||||
export const dynamic = "force-static";
|
||||
Loaded 100 of 580 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user