Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a2f498a7 | ||
|
|
3dc7cd27bc | ||
|
|
53f97f4b50 | ||
|
|
9a5ea40ef0 | ||
|
|
37e2797874 | ||
|
|
621ea07549 | ||
|
|
f64c01456b | ||
|
|
56198c3f02 | ||
|
|
ca1bb6f341 | ||
|
|
bf6b6663cd | ||
|
|
5c2d86767f | ||
|
|
7c75642d4d | ||
|
|
2513e8454f | ||
|
|
527e911eef | ||
|
|
ff2cdecc86 | ||
|
|
742bde1d27 | ||
|
|
64febbce12 | ||
|
|
98bb1aecf8 | ||
|
|
06c489bd48 | ||
|
|
464d98e124 | ||
|
|
987c9ade18 | ||
|
|
6141d505e4 | ||
|
|
6178843198 | ||
|
|
a7006d746e | ||
|
|
06724cfeb2 | ||
|
|
131a5558b5 | ||
|
|
b32ad82850 | ||
|
|
622e1dcfa5 | ||
|
|
9674bde758 | ||
|
|
b479ada14f | ||
|
|
bff3383ac9 | ||
|
|
d865733838 | ||
|
|
85fcea3837 | ||
|
|
f76a0f240a | ||
|
|
637f03e107 | ||
|
|
55214ccbc5 | ||
|
|
778533a62e | ||
|
|
b4fba47a23 | ||
|
|
76ddfceb13 | ||
|
|
2f6b96e5ba | ||
|
|
f1b7a5146a | ||
|
|
05292ae1f5 | ||
|
|
48fc8db2b4 | ||
|
|
018f84f80d | ||
|
|
d58a47b776 |
No files matched your search
+2
-1
@@ -17,7 +17,8 @@ coverage
|
|||||||
dev
|
dev
|
||||||
.build
|
.build
|
||||||
e2e
|
e2e
|
||||||
apps/docs
|
apps/docs/*
|
||||||
|
!apps/docs/package.json
|
||||||
apps/nextjs/.next
|
apps/nextjs/.next
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
|
- 1.75.0
|
||||||
|
- 1.74.0
|
||||||
- 1.73.0
|
- 1.73.0
|
||||||
- 1.72.0
|
- 1.72.0
|
||||||
- 1.71.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
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Lint, typecheck, and build affected workspaces
|
- name: Lint, typecheck, and build affected workspaces
|
||||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
|
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2
|
||||||
|
|
||||||
- name: Validate workspace configuration
|
- name: Validate workspace configuration
|
||||||
run: pnpm lint:ws
|
run: pnpm lint:ws
|
||||||
|
|||||||
@@ -78,3 +78,4 @@ skills-lock.json
|
|||||||
apps/docs/.docusaurus
|
apps/docs/.docusaurus
|
||||||
apps/docs/build
|
apps/docs/build
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
benchmark-results
|
||||||
@@ -46,6 +46,8 @@ homarr/
|
|||||||
├── tooling/
|
├── tooling/
|
||||||
│ ├── typescript/ # Base tsconfig
|
│ ├── typescript/ # Base tsconfig
|
||||||
│ └── github/ # CI setup action
|
│ └── github/ # CI setup action
|
||||||
|
├── tools/
|
||||||
|
│ └── homarr-dev/ # Go CLI for local and PR Docker images
|
||||||
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
|
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
|
||||||
├── e2e/ # E2E test specs
|
├── e2e/ # E2E test specs
|
||||||
└── Dockerfile # Multi-stage production build
|
└── Dockerfile # Multi-stage production build
|
||||||
@@ -68,6 +70,9 @@ When modifying user-facing code, you MUST also update the corresponding document
|
|||||||
## Monorepo Commands
|
## Monorepo Commands
|
||||||
|
|
||||||
- `pnpm dev` — Next.js app only
|
- `pnpm dev` — Next.js app only
|
||||||
|
- `pnpm dev:cli -- dev` — run the developer CLI without installing a global binary
|
||||||
|
- `pnpm db:seed` — seed default database data explicitly
|
||||||
|
- `pnpm docker:dev:up` — start the Redis development service in the background
|
||||||
- `pnpm dev:docs` — Docusaurus docs site only
|
- `pnpm dev:docs` — Docusaurus docs site only
|
||||||
- `pnpm turbo build` — build all packages
|
- `pnpm turbo build` — build all packages
|
||||||
- `pnpm turbo build --filter=@homarr/docs` — build docs only
|
- `pnpm turbo build --filter=@homarr/docs` — build docs only
|
||||||
@@ -81,3 +86,7 @@ When modifying user-facing code, you MUST also update the corresponding document
|
|||||||
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
|
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
|
||||||
- Icons: @tabler/icons-react
|
- Icons: @tabler/icons-react
|
||||||
- Docs app can import from `@homarr/definitions` for shared types
|
- Docs app can import from `@homarr/definitions` for shared types
|
||||||
|
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
|
||||||
|
- Run `pnpm dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
|
||||||
|
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
|
||||||
|
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
|
||||||
|
|
||||||
|
## [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.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)
|
## [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
|
apps/docs/docs/integrations/uptime-kuma @ajnart
|
||||||
packages/integrations/src/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
|
# Traefik - by @Nerdy-Technician
|
||||||
apps/docs/docs/integrations/traefik @Nerdy-Technician
|
apps/docs/docs/integrations/traefik @Nerdy-Technician
|
||||||
apps/docs/docs/widgets/traefik @Nerdy-Technician
|
apps/docs/docs/widgets/traefik @Nerdy-Technician
|
||||||
|
|||||||
+19
-8
@@ -3,12 +3,18 @@
|
|||||||
FROM node:24.18.0-alpine AS base
|
FROM node:24.18.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
ARG TARGETPLATFORM
|
||||||
WORKDIR /app
|
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
|
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 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
|
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
|
||||||
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
||||||
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
|
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
|
||||||
@@ -19,14 +25,16 @@ COPY patches ./patches
|
|||||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||||
pnpm config set store-dir /pnpm/store && \
|
pnpm config set store-dir /pnpm/store && \
|
||||||
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
|
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
|
||||||
pnpm fetch && \
|
pnpm fetch --ignore-scripts && \
|
||||||
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
||||||
|
|
||||||
COPY . .
|
# Install only from the fetched, committed lockfile so local and Docker builds
|
||||||
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
|
||||||
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
|
# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
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 SKIP_ENV_VALIDATION='true'
|
||||||
ARG CI='true'
|
ARG CI='true'
|
||||||
@@ -37,7 +45,9 @@ RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
|
|||||||
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
|
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
|
||||||
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
|
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
|
||||||
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
|
--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
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -85,5 +95,6 @@ ENV AUTH_PROVIDERS='credentials'
|
|||||||
ENV REDIS_IS_EXTERNAL='false'
|
ENV REDIS_IS_EXTERNAL='false'
|
||||||
ENV NODE_ENV='production'
|
ENV NODE_ENV='production'
|
||||||
|
|
||||||
|
EXPOSE 7575
|
||||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||||
CMD ["sh", "run.sh"]
|
CMD ["sh", "run.sh"]
|
||||||
+2
-2
@@ -55,8 +55,8 @@ pnpm typecheck
|
|||||||
docs/
|
docs/
|
||||||
├── getting-started/ # Installation guides, glossary, prerequisites
|
├── getting-started/ # Installation guides, glossary, prerequisites
|
||||||
├── management/ # Boards, apps, users, integrations, settings, tasks, API
|
├── management/ # Boards, apps, users, integrations, settings, tasks, API
|
||||||
├── integrations/ # 55 integration guides (one folder each)
|
├── integrations/ # 56 integration guides (one folder each)
|
||||||
├── widgets/ # 42 widget guides (one folder each)
|
├── widgets/ # 43 widget guides (one folder each)
|
||||||
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
|
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
|
||||||
└── community/ # FAQ, donate, license, translations, get-in-touch
|
└── community/ # FAQ, donate, license, translations, get-in-touch
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Developer Docker CLI
|
||||||
|
|
||||||
|
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
||||||
|
|
||||||
|
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
|
||||||
|
|
||||||
|
## Browse development images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev:cli --
|
||||||
|
```
|
||||||
|
|
||||||
|
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
|
||||||
|
|
||||||
|
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
|
||||||
|
|
||||||
|
## Build images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev:cli -- build <name>
|
||||||
|
pnpm dev:cli -- build --pr <number> [name]
|
||||||
|
```
|
||||||
|
|
||||||
|
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
|
||||||
|
|
||||||
|
## Rebuild images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev:cli -- rebuild <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
|
||||||
|
|
||||||
|
## Run images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
homarr run <name>
|
||||||
|
homarr run --pr <number>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
|
||||||
@@ -14,11 +14,12 @@ This guide will help you set up your development environment to start contributi
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
|
- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/).
|
||||||
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
|
- **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/).
|
- **Git**: You can download it from the [official website](https://git-scm.com/).
|
||||||
- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
|
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
|
||||||
- **A GitHub account**: You can create one on the [official website](https://github.com/).
|
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
|
||||||
|
- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
|
||||||
|
|
||||||
### Setting up the project
|
### Setting up the project
|
||||||
|
|
||||||
@@ -34,11 +35,11 @@ This guide will help you set up your development environment to start contributi
|
|||||||
|
|
||||||
4. Install the dependencies with `pnpm install`.
|
4. Install the dependencies with `pnpm install`.
|
||||||
|
|
||||||
5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
|
5. Create `.env` from `.env.example`. In a POSIX shell, run `cp .env.example .env`; in PowerShell, run `Copy-Item .env.example .env`. Set `DB_URL` to an absolute path for your SQLite file.
|
||||||
|
|
||||||
6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
|
6. Start Redis in the background with `pnpm docker:dev:up` if your development workflow needs it.
|
||||||
|
|
||||||
7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
|
7. Create or update the database with `pnpm db:migration:sqlite:run`. This also seeds the default data. To run the seed explicitly later, use `pnpm db:seed`.
|
||||||
|
|
||||||
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
|
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
|
||||||
|
|
||||||
@@ -48,9 +49,19 @@ This guide will help you set up your development environment to start contributi
|
|||||||
|
|
||||||
#### During development
|
#### 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.
|
||||||
|
- `pnpm dev:cli -- rebuild <name>`: Rebuild a local image from its recorded checkout or pull request.
|
||||||
|
- `pnpm dev:cli:install`: Optionally install the developer CLI as a `homarr` binary.
|
||||||
- `pnpm cli`: Run cli commands.
|
- `pnpm cli`: Run cli commands.
|
||||||
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
|
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
|
||||||
|
- `pnpm db:seed`: Explicitly seed the default database data.
|
||||||
|
- `pnpm docker:dev:up`: Start the Redis development service in the background.
|
||||||
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
|
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
|
||||||
- `pnpm db:studio`: Open a visual studio for the database.
|
- `pnpm db:studio`: Open a visual studio for the database.
|
||||||
- `pnpm package:new`: Create a new package in the monorepo.
|
- `pnpm package:new`: Create a new package in the monorepo.
|
||||||
@@ -63,6 +74,8 @@ This guide will help you set up your development environment to start contributi
|
|||||||
- `pnpm typecheck`: Run the typescript type checker.
|
- `pnpm typecheck`: Run the typescript type checker.
|
||||||
|
|
||||||
#### Building the project
|
#### Building the project
|
||||||
|
|
||||||
- `pnpm build`: Build the project for production.
|
- `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 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:latest`: Run the created docker image.
|
- `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.
|
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 |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
|
||||||
| ``PUID`` | User ID to run the container as | Any valid user ID | ``0`` |
|
| `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`` |
|
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||||
| ``LOG_LEVEL`` | Log level to use | ``debug`` / ``info`` / ``warn`` / ``error`` | ``info`` |
|
| `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` |
|
| `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` |
|
| `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
|
## 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.
|
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 |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| ----------------------- | ------------------------------------------- | ----------------------- | ------- |
|
||||||
| ``SECRET_ENCRYPTION_KEY`` | Secret used to encrypt secrets in database. | 64 character hex string | - |
|
| `SECRET_ENCRYPTION_KEY` | Secret used to encrypt secrets in database. | 64 character hex string | - |
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
A random secret can be generated by using the following command: `openssl rand -hex 32`
|
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
|
## Docker
|
||||||
|
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||||
| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
| `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_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` | - |
|
| `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
|
## Database
|
||||||
| Environment Variable | Description | Possible values | Default |
|
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| 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_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_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` |
|
| `DB_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` |
|
||||||
| ``DB_HOST`` | Database host to connect to. | Any valid database host | - |
|
| `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` |
|
||||||
| ``DB_PORT`` | Database port to connect to. | Any valid database port | - |
|
| `DB_HOST` | Database host to connect to. | Any valid database host | - |
|
||||||
| ``DB_NAME`` | Database name to connect to. | Any valid database name | - |
|
| `DB_PORT` | Database port to connect to. | Any valid database port | - |
|
||||||
| ``DB_USER`` | Database user to connect with. | Any valid database user | - |
|
| `DB_NAME` | Database name to connect to. | Any valid database name | - |
|
||||||
| ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - |
|
| `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.
|
You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values.
|
||||||
|
|
||||||
|
|
||||||
## Redis
|
## Redis
|
||||||
|
|
||||||
By default Redis is running within the installation. (for example in Docker).
|
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
|
External redis is currently not supported with Proxmox Community Scripts
|
||||||
:::
|
:::
|
||||||
|
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| ---------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------ | ------- |
|
||||||
| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
|
| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
|
||||||
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
|
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
|
||||||
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
|
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
|
||||||
| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
|
| `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_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_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 | - |
|
| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
|
||||||
|
|
||||||
|
|
||||||
## Proxy
|
## Proxy
|
||||||
|
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| -------------------- | ------------------------------------------------------------------- | ------------------------- | ------- |
|
||||||
| ``HTTP_PROXY`` | HTTP proxy to use | Any valid HTTP proxy URL | - |
|
| `HTTP_PROXY` | HTTP proxy to use | Any valid HTTP proxy URL | - |
|
||||||
| ``HTTPS_PROXY`` | HTTPS proxy to use | Any valid HTTPS 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 | - |
|
| `NO_PROXY` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - |
|
||||||
|
|
||||||
|
|
||||||
## Advanced deployments
|
## 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.
|
The advanced deployments environment variables should only be used if you know what you are doing.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
| Environment Variable | Description |
|
| Environment Variable | Description |
|
||||||
| ------------------------ | ----------- |
|
| ------------------------ | -------------------------------------------------- |
|
||||||
| ``DB_MIGRATIONS_DISABLED`` | Disable db migrations. For example for helm charts |
|
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
|
||||||
@@ -9,17 +9,19 @@ tags:
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Icon picker
|
## Icon picker
|
||||||
|
|
||||||
Icons in Homarr are automatically requested from multiple sources:
|
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)
|
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
|
||||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||||
- [Tabler icons](https://tabler-icons.io/)
|
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||||
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
- [Tabler icons](https://tabler-icons.io/)
|
||||||
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
|
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
||||||
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
|
- [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.
|
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
|
### 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.
|
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
|
### 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.
|
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.rule: Host(`your.internal.dns.address.here.com`)
|
||||||
traefik.http.routers.homarr.entrypoints: websecure
|
traefik.http.routers.homarr.entrypoints: websecure
|
||||||
traefik.http.routers.homarr-secure.app: homarr
|
traefik.http.routers.homarr-secure.app: homarr
|
||||||
traefik.http.apps.homarr.loadbalancer.server.port: 7575
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
|
|||||||
@@ -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">
|
<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
|
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 |
|
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
| 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.75.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||||
| ingress.enabled | bool | `false` | Enable ingress |
|
| 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",
|
||||||
|
};
|
||||||
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
|
|||||||
### Icon URL
|
### Icon URL
|
||||||
|
|
||||||
This is the URL to the icon that will be displayed on the app widget.
|
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.
|
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.
|
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
|
### 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.
|
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.
|
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
|
## List of apps
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ tags:
|
|||||||
- Layout
|
- Layout
|
||||||
---
|
---
|
||||||
|
|
||||||
import iconColorImage from './img/icon-color.png'
|
import iconColorImage from "./img/icon-color.png";
|
||||||
import itemRadiusImage from './img/item-radius.png'
|
import itemRadiusImage from "./img/item-radius.png";
|
||||||
import layoutLargeImage from './img/layout-large.png'
|
import layoutLargeImage from "./img/layout-large.png";
|
||||||
import layoutMediumImage from './img/layout-medium.png'
|
import layoutMediumImage from "./img/layout-medium.png";
|
||||||
import layoutSmallImage from './img/layout-small.png'
|
import layoutSmallImage from "./img/layout-small.png";
|
||||||
|
|
||||||
Each dashboard is called a "board".
|
Each dashboard is called a "board".
|
||||||
You can create as many boards as you want.
|
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.
|
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||||
|
|
||||||
## Create a new board
|
## Create a new board
|
||||||
|
|
||||||
When creating a board, the availability of the board name will be checked for you.
|
When creating a board, the availability of the board name will be checked for you.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Name
|
### Name
|
||||||
|
|
||||||
The name must not include any special characters and must be at least one character long.
|
The name must not include any special characters and must be at least one character long.
|
||||||
Underscores and hyphen-minus are allowed.
|
Underscores and hyphen-minus are allowed.
|
||||||
|
|
||||||
### Column count
|
### 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.
|
The column count defines how many horizontal columns are available to drag and drop elements to.
|
||||||
|
|
||||||
### Public
|
### Public
|
||||||
|
|
||||||
This will enable public access for everyone - regardless of whether they are logged in or not.
|
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.
|
Modifications for public boards must be allowed explicitly using user groups and their permissions.
|
||||||
|
|
||||||
## Board settings
|
## Board settings
|
||||||
|
|
||||||
You navigate to the board settings by clicking on these three dots:
|
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
|
### General
|
||||||
|
|
||||||
#### Page title
|
#### Page title
|
||||||
|
|
||||||
This is the title that will be visible at the top left in the navigation bar:
|
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.
|
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
|
||||||
|
|
||||||
#### Meta title
|
#### Meta title
|
||||||
|
|
||||||
This is the secondary title of the page that is not directly visible in the page itself.
|
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:
|
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.
|
Homarr will fall back to the board name and a suffix if nothing is set.
|
||||||
|
|
||||||
#### Logo image URL
|
#### Logo image URL
|
||||||
|
|
||||||
This is the URL that is used for the image at the top left.
|
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.
|
Homarr will fall back to the Homarr logo if nothing is set.
|
||||||
|
|
||||||
#### Favicon image URL
|
#### Favicon image URL
|
||||||
|
|
||||||
This is the URL that is used for the favicon in the tab title.
|
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.
|
Homarr will fall back to the Homarr logo if nothing is set.
|
||||||
|
|
||||||
@@ -95,50 +107,64 @@ Amount of columns for this layout. Defines how many items can be placed in a row
|
|||||||
#### Breakpoint
|
#### Breakpoint
|
||||||
|
|
||||||
Minimum width of the screen where the layout will be used.
|
Minimum width of the screen where the layout will be used.
|
||||||
Once the minmum of the next layout is reached the layout will change.
|
Once the minimum of the next layout is reached the layout will change.
|
||||||
|
|
||||||
#### Preview
|
#### Preview
|
||||||
|
|
||||||
<img src={layoutSmallImage} height={256} />
|
<img src={layoutSmallImage} height={256} />
|
||||||
<img src={layoutMediumImage} height={256} />
|
<img src={layoutMediumImage} height={256} />
|
||||||
<img src={layoutLargeImage} height={256} />
|
<img src={layoutLargeImage} height={256} />
|
||||||
|
|
||||||
### Background
|
### Background
|
||||||
|
|
||||||
#### Background image URL
|
#### Background image URL
|
||||||
|
|
||||||
Homarr can display a background image behind apps and widgets.
|
Homarr can display a background image behind apps and widgets.
|
||||||
This URL can be used to define what image should be shown.
|
This URL can be used to define what image should be shown.
|
||||||
|
|
||||||
#### Background image attachment
|
#### Background image attachment
|
||||||
|
|
||||||
This defines whether the background should move when the user scrolls.
|
This defines whether the background should move when the user scrolls.
|
||||||
|
|
||||||
#### Background image size
|
#### 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.
|
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
|
#### Background image repeat
|
||||||
|
|
||||||
This defines whether the background should be repeated if it cannot cover the entire page.
|
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):
|
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Appearance
|
### Appearance
|
||||||
|
|
||||||
#### Primary color
|
#### Primary color
|
||||||
|
|
||||||
Color that can be chosen freely to give accents to active elements.
|
Color that can be chosen freely to give accents to active elements.
|
||||||
|
|
||||||
#### Secondary color
|
#### Secondary color
|
||||||
|
|
||||||
Color that can be chosen freely to give accents.
|
Color that can be chosen freely to give accents.
|
||||||
|
|
||||||
#### Opacity
|
#### Opacity
|
||||||
|
|
||||||
Opacity of the colors.
|
Opacity of the colors.
|
||||||
|
|
||||||
#### Icon color
|
#### Icon color
|
||||||
|
|
||||||
Change the color of your bookmark / app icons.
|
Change the color of your bookmark / app icons.
|
||||||
|
|
||||||
<img src={iconColorImage} height={100} />
|
<img src={iconColorImage} height={100} />
|
||||||
|
|
||||||
#### Item radius
|
#### Item radius
|
||||||
|
|
||||||
Change the radius of your items / categories.
|
Change the radius of your items / categories.
|
||||||
|
|
||||||
<img src={itemRadiusImage} height={100} />
|
<img src={itemRadiusImage} height={100} />
|
||||||
|
|
||||||
|
|
||||||
### Custom CSS
|
### Custom CSS
|
||||||
|
|
||||||
This is the field where you can enter custom CSS.
|
This is the field where you can enter custom CSS.
|
||||||
The styling will only be applied on the respective board page.
|
The styling will only be applied on the respective board page.
|
||||||
|
|
||||||
@@ -147,14 +173,16 @@ The styling will only be applied on the respective board page.
|
|||||||
### Behaviour
|
### Behaviour
|
||||||
|
|
||||||
#### Disable app status
|
#### Disable app status
|
||||||
|
|
||||||
This will disable the status check for apps.
|
This will disable the status check for apps.
|
||||||
|
|
||||||
### Access control
|
### Access control
|
||||||
|
|
||||||
With the access control, you can permit a single user or a group to your board.
|
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:
|
The users and groups can have one of these three levels of access:
|
||||||
|
|
||||||
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
|
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
|
||||||
|----------------------|------------|-----------|----------|--------------------------|
|
| -------------------- | ---------- | --------- | -------- | ------------------------ |
|
||||||
| View board | X | | | |
|
| View board | X | | | |
|
||||||
| Modify board | X | X | X | |
|
| Modify board | X | X | X | |
|
||||||
| Full access | X | X | X | X |
|
| Full access | X | X | X | X |
|
||||||
@@ -173,22 +201,27 @@ After editing permissions, the save button below the table must be pressed.
|
|||||||
Otherwise, all changes will be discarded.
|
Otherwise, all changes will be discarded.
|
||||||
|
|
||||||
### Danger zone
|
### Danger zone
|
||||||
|
|
||||||
#### Rename
|
#### Rename
|
||||||
|
|
||||||
Renaming a board will break all existing links to it permanently with no redirect.
|
Renaming a board will break all existing links to it permanently with no redirect.
|
||||||
|
|
||||||
#### Board visibility
|
#### Board visibility
|
||||||
|
|
||||||
By default, boards will be private. This means only authenticated users that are authorized have access.
|
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.
|
Making a board public will expose it to everyone - including unauthenticated users.
|
||||||
|
|
||||||
#### Delete
|
#### Delete
|
||||||
|
|
||||||
This will permanently delete the board.
|
This will permanently delete the board.
|
||||||
This action is irreversible and cannot be undone.
|
This action is irreversible and cannot be undone.
|
||||||
|
|
||||||
## Delete boards
|
## Delete boards
|
||||||
|
|
||||||
The deletion of boards is permanent and cannot be undone.
|
The deletion of boards is permanent and cannot be undone.
|
||||||
There are two ways to delete a board:
|
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".
|
- 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.
|
Both actions will execute the same deletion process.
|
||||||
@@ -206,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
|
## 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.
|
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:
|
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:
|
Your home board will have a special small badge at the top right in the list of boards:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### For yourself
|
### For yourself
|
||||||
|
|
||||||
To set the home board yourself, the easiest option is to set it directly in the list of boards:
|
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:
|
Alternatively, you can also define it via your user profile:
|
||||||
|
|
||||||
1. Click on your user profile at the top right
|
1. Click on your user profile at the top right
|
||||||
2. Click on ``Your preferences``
|
2. Click on `Your preferences`
|
||||||
3. Change the home board in the ``home board`` dropdown and click on ``Save``.
|
3. Change the home board in the `home board` dropdown and click on `Save`.
|
||||||
|
|
||||||
### For other users
|
### 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
|
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
|
### For user groups
|
||||||
|
|
||||||
1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage``
|
1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage`
|
||||||
2. Go to tab ``Settings``
|
2. Go to tab `Settings`
|
||||||
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 whole server
|
### 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.
|
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.
|
It will also be used for anonymous users.
|
||||||
|
|
||||||
1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards``
|
1. Navigate to the settings: `Management` > `Settings` > `Boards`
|
||||||
2. Select the desired home board via the dropdown and click on ``Save`` \
|
2. Select the desired home board via the dropdown and click on `Save` \
|
||||||

|

|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
For the global home board you can only select boards that are public.
|
For the global home board you can only select boards that are public.
|
||||||
|
|||||||
@@ -9,97 +9,127 @@ tags:
|
|||||||
---
|
---
|
||||||
|
|
||||||
# Settings
|
# 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
|
## 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.
|
|
||||||
|
|
||||||
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
|
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.
|
||||||
|
|
||||||
We understand that you might not want Homarr to communicate with our services.
|
We do not sell the data. We do not share the data with third parties.
|
||||||
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.
|
The data does not contain personal information and does not identify you or your installation.
|
||||||
If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall.
|
|
||||||
|
|
||||||
Additionally, you opt in to more data collection to help us provide more useful data.
|
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.
|
||||||
|
|
||||||
### Integration data
|
### What we collect
|
||||||
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.
|
|
||||||
|
|
||||||
### Widget data
|
Homarr collects technical data about your installation:
|
||||||
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.
|
|
||||||
|
|
||||||
### Users data
|
- Your Homarr version
|
||||||
Enabling this optional data will send the count of users and whether you activated SSO.
|
- Your database type (SQLite, MySQL, or PostgreSQL)
|
||||||
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
|
- Whether Docker or Kubernetes is enabled
|
||||||
This is disabled by default.
|
- 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
|
||||||
|
|
||||||
|
Homarr does NOT collect:
|
||||||
|
|
||||||
|
- URLs
|
||||||
|
- Names
|
||||||
|
- Usernames, emails, or passwords
|
||||||
|
- Integration secrets or API keys
|
||||||
|
- Error messages or logs
|
||||||
|
- Any data that can identify you
|
||||||
|
|
||||||
|
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
|
||||||
|
|
||||||
|
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)
|
## 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.
|
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
|
||||||
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
|
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
|
||||||
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.
|
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
|
||||||
|
|
||||||
### No 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.
|
This setting asks crawlers to not index your instance.
|
||||||
By default, this setting is enabled. Disabling it will allow indexing.
|
This setting is enabled by default. Disable this setting to allow indexing.
|
||||||
|
|
||||||
### No follow
|
### 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.
|
Crawlers follow links to discover new pages.
|
||||||
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
|
This setting asks crawlers to not follow links. This setting is enabled by default.
|
||||||
|
Crawler behavior can differ between search engines.
|
||||||
|
|
||||||
### No translate
|
### 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,
|
Some browsers offer to translate pages.
|
||||||
you ask crawlers to never translate and never prompt users for translation.
|
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
|
||||||
It should be noted that some browsers will ignore this and translate regardless.
|
|
||||||
|
|
||||||
### No site links search box
|
### 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".
|
This setting applies to Google only.
|
||||||
The user can search them directly in Google.
|
Google can show a search box below your domain.
|
||||||
You can ask the crawlers to not allow searches by enabling this setting.
|
This setting asks Google to not show this box. This setting is disabled by default.
|
||||||
This setting is disabled by default.
|
|
||||||
|
|
||||||
## Boards
|
## Boards
|
||||||
|
|
||||||
### Global home board
|
### 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.
|
You can set a global home board.
|
||||||
Note that the board must be public to be used as 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
|
### 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).
|
You can set a global mobile board.
|
||||||
It will similar to the global home board be as fallback for normal users and for any anonymous users.
|
Homarr shows this board on mobile devices instead of the global home board.
|
||||||
It also has to be public.
|
Homarr detects mobile devices with the User-Agent header.
|
||||||
|
The board must be public.
|
||||||
|
|
||||||
### App status
|
### App status
|
||||||
|
|
||||||
#### Enable status by default
|
#### 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
|
#### 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
|
## Appearance
|
||||||
|
|
||||||
### Default color scheme
|
### 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
|
## Culture
|
||||||
|
|
||||||
### Default language
|
### 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.
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
+14
-14
@@ -21,35 +21,35 @@
|
|||||||
"verify:search": "node ./scripts/verify-algolia-search.mjs"
|
"verify:search": "node ./scripts/verify-algolia-search.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "^3.10.1",
|
"@docusaurus/core": "^3.10.2",
|
||||||
"@docusaurus/faster": "^3.10.1",
|
"@docusaurus/faster": "^3.10.2",
|
||||||
"@docusaurus/plugin-content-docs": "^3.10.1",
|
"@docusaurus/plugin-content-docs": "^3.10.2",
|
||||||
"@docusaurus/plugin-sitemap": "^3.10.1",
|
"@docusaurus/plugin-sitemap": "^3.10.2",
|
||||||
"@docusaurus/preset-classic": "^3.10.1",
|
"@docusaurus/preset-classic": "^3.10.2",
|
||||||
"@homarr/definitions": "workspace:^",
|
"@homarr/definitions": "workspace:^",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@nivo/line": "^0.99.0",
|
"@nivo/line": "^0.99.0",
|
||||||
"@scalar/api-reference-react": "catalog:",
|
"@scalar/api-reference-react": "catalog:",
|
||||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
"@tailwindcss/postcss": "^4.3.2",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||||
"leader-line-new": "^1.1.9",
|
"leader-line-new": "^1.1.9",
|
||||||
"postcss": "^8.5.16",
|
"postcss": "^8.5.26",
|
||||||
"posthog-docusaurus": "^2.0.5",
|
"posthog-docusaurus": "^2.0.5",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"random-word-slugs": "^0.1.7",
|
"random-word-slugs": "^0.1.7",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"swiper": "^14.0.5",
|
"swiper": "^14.1.0",
|
||||||
"tailwindcss": "^4.3.2"
|
"tailwindcss": "^4.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
"@docusaurus/module-type-aliases": "^3.10.2",
|
||||||
"@docusaurus/tsconfig": "^3.10.1",
|
"@docusaurus/tsconfig": "^3.10.2",
|
||||||
"@docusaurus/types": "^3.10.1",
|
"@docusaurus/types": "^3.10.2",
|
||||||
"@fec/remark-a11y-emoji": "^4.0.2",
|
"@fec/remark-a11y-emoji": "^4.0.2",
|
||||||
"@tsconfig/docusaurus": "^2.0.9",
|
"@tsconfig/docusaurus": "^2.0.9",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@docusaurus/theme-common": "^3.10.1",
|
"@docusaurus/theme-common": "^3.10.2",
|
||||||
"@docusaurus/theme-mermaid": "^3.10.1"
|
"@docusaurus/theme-mermaid": "^3.10.2"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
+22
-11
@@ -1,4 +1,6 @@
|
|||||||
// Importing env files here to validate on build
|
// Importing env files here to validate on build
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import "@homarr/auth/env";
|
import "@homarr/auth/env";
|
||||||
import "@homarr/core/infrastructure/db/env";
|
import "@homarr/core/infrastructure/db/env";
|
||||||
import "@homarr/common/env";
|
import "@homarr/common/env";
|
||||||
@@ -13,7 +15,21 @@ const withNextIntl = createNextIntlPlugin({
|
|||||||
requestConfig: "../../packages/translation/src/request.ts",
|
requestConfig: "../../packages/translation/src/request.ts",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getDevelopmentServiceAliases = () => {
|
||||||
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@homarr/tasks": "./src/instrumentation-noop.ts",
|
||||||
|
"@homarr/websocket": "./src/instrumentation-noop.ts",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Next previews otherwise create agent instruction files in the application
|
||||||
|
// directory during development.
|
||||||
|
agentRules: false,
|
||||||
env: {
|
env: {
|
||||||
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
||||||
},
|
},
|
||||||
@@ -30,19 +46,14 @@ const nextConfig: NextConfig = {
|
|||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForBuild: true,
|
||||||
webpackMemoryOptimizations: true,
|
useTypeScriptCli: true,
|
||||||
},
|
},
|
||||||
turbopack: {
|
turbopack: {
|
||||||
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
|
root: path.resolve(import.meta.dirname, "../.."),
|
||||||
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
|
// Development runs tasks and WebSocket as separate processes. These aliases
|
||||||
// (see vercel/next.js#95125). Suppress until upstream fix lands.
|
// keep their production-only instrumentation imports out of the dev graph.
|
||||||
ignoreIssue: [
|
resolveAlias: getDevelopmentServiceAliases(),
|
||||||
{
|
|
||||||
path: "**/*",
|
|
||||||
title: "Encountered unexpected file in NFT list",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||||
images: {
|
images: {
|
||||||
|
|||||||
@@ -59,11 +59,10 @@
|
|||||||
"@modelcontextprotocol/sdk": "catalog:",
|
"@modelcontextprotocol/sdk": "catalog:",
|
||||||
"@scalar/api-reference-react": "catalog:",
|
"@scalar/api-reference-react": "catalog:",
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
"@tanstack/query-async-storage-persister": "catalog:",
|
"@tanstack/react-devtools": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-query-next-experimental": "catalog:",
|
"@tanstack/react-query-next-experimental": "catalog:",
|
||||||
"@tanstack/react-query-persist-client": "catalog:",
|
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/next": "catalog:",
|
"@trpc/next": "catalog:",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "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";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { QueryKey } from "@tanstack/react-query";
|
import dynamic from "next/dynamic";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
|
||||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
|
||||||
import {
|
import {
|
||||||
createWSClient,
|
createWSClient,
|
||||||
httpBatchStreamLink,
|
httpBatchStreamLink,
|
||||||
@@ -25,18 +23,24 @@ import { TRPCClientError } from "@trpc/client";
|
|||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import {
|
import {
|
||||||
isPersistableWidgetQueryKey,
|
dashboardSupportingQueryPolicies,
|
||||||
queryCacheBuster,
|
|
||||||
queryCacheDefaultGcTimeMs,
|
queryCacheDefaultGcTimeMs,
|
||||||
queryCacheDefaultRefetchIntervalMs,
|
queryCacheDefaultRefetchIntervalMs,
|
||||||
queryCacheDefaultStaleTimeMs,
|
queryCacheDefaultStaleTimeMs,
|
||||||
} from "@homarr/api/query-cache";
|
} from "@homarr/api/query-cache";
|
||||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { showWarningNotification } from "@homarr/notifications";
|
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 = () => {
|
const getWebSocketProtocol = () => {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
@@ -59,21 +63,46 @@ const constructWebsocketUrl = () => {
|
|||||||
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
|
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const wsClient = createWSClient({
|
export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||||
url: constructWebsocketUrl(),
|
const { data: session } = useSession();
|
||||||
});
|
const sessionQueryScope = getSessionQueryScope(session);
|
||||||
|
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||||
|
|
||||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
return (
|
||||||
const [persister] = useState(() => createWidgetQueryPersister());
|
<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 [queryClient] = useState(() => {
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: queryCacheDefaultStaleTimeMs,
|
staleTime: queryCacheDefaultStaleTimeMs,
|
||||||
gcTime: queryCacheDefaultGcTimeMs,
|
gcTime: queryCacheDefaultGcTimeMs,
|
||||||
refetchOnWindowFocus: false,
|
retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3),
|
||||||
refetchOnReconnect: false,
|
|
||||||
retry: 3,
|
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
onError(error) {
|
onError(error) {
|
||||||
@@ -91,23 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
client.setQueryDefaults([["widget"]], {
|
||||||
for (const { definition } of Object.values(widgetImports)) {
|
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
||||||
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
|
refetchOnWindowFocus: false,
|
||||||
if (def.refetchInterval === undefined) continue;
|
refetchOnReconnect: false,
|
||||||
const key = def.queryKey ?? [["widget", def.kind]];
|
});
|
||||||
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
|
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
|
||||||
client.setQueryDefaults(key, { refetchInterval: interval });
|
client.setQueryDefaults(queryKey, {
|
||||||
|
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
|
||||||
|
client.setQueryDefaults(queryKey, policy);
|
||||||
}
|
}
|
||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [trpcClient] = useState(() => {
|
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||||
|
|
||||||
|
const trpcClient = useMemo(() => {
|
||||||
return clientApi.createClient({
|
return clientApi.createClient({
|
||||||
links: [
|
links: [
|
||||||
loggerLink({
|
loggerLink({
|
||||||
enabled: (opts) =>
|
enabled: (opts) => opts.direction === "down" && opts.result instanceof Error,
|
||||||
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
|
||||||
}),
|
}),
|
||||||
splitLink({
|
splitLink({
|
||||||
condition: ({ type }) => type === "subscription",
|
condition: ({ type }) => type === "subscription",
|
||||||
@@ -147,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
}, [wsClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<PersistQueryClientProvider
|
<QueryClientProvider client={queryClient}>
|
||||||
client={queryClient}
|
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||||
persistOptions={{
|
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||||
persister,
|
</QueryClientProvider>
|
||||||
buster: queryCacheBuster,
|
|
||||||
maxAge: queryCacheDefaultGcTimeMs,
|
|
||||||
dehydrateOptions: {
|
|
||||||
shouldDehydrateQuery: (query) =>
|
|
||||||
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
|
||||||
</PersistQueryClientProvider>
|
|
||||||
</clientApi.Provider>
|
</clientApi.Provider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { api } from "@homarr/api/server";
|
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
||||||
|
|
||||||
import { createBoardContentPage } from "../_creator";
|
import { createBoardContentPage } from "../_creator";
|
||||||
|
|
||||||
export default createBoardContentPage<{ locale: string }>({
|
export default createBoardContentPage<{ locale: string }>({
|
||||||
async getInitialBoardAsync() {
|
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";
|
import { createBoardContentPage } from "../../_creator";
|
||||||
|
|
||||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||||
async getInitialBoardAsync({ name }) {
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,10 +14,10 @@ export const ClientBoard = () => {
|
|||||||
|
|
||||||
const fullWidthSortedSections = board.sections
|
const fullWidthSortedSections = board.sections
|
||||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||||
<BoardBackgroundVideo />
|
<BoardBackgroundVideo />
|
||||||
<Stack h="100%">
|
<Stack h="100%">
|
||||||
{fullWidthSortedSections.map((section) =>
|
{fullWidthSortedSections.map((section) =>
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { cache } from "react";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
import "~/styles/gridstack.scss";
|
import "~/styles/gridstack.scss";
|
||||||
|
|
||||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
import { makeQueryClient } from "@homarr/api/shared";
|
||||||
import superjson from "superjson";
|
|
||||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
|
||||||
import { getQueryClient } from "@homarr/api/server";
|
|
||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||||
@@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
|
|||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { getQueryCacheAsync } from "@homarr/redis";
|
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||||
|
|
||||||
@@ -29,6 +25,7 @@ import { DynamicClientBoard } from "./_dynamic-client";
|
|||||||
import { BoardContentHeaderActions } from "./_header-actions";
|
import { BoardContentHeaderActions } from "./_header-actions";
|
||||||
|
|
||||||
const logger = createLogger({ module: "createBoardContentPage" });
|
const logger = createLogger({ module: "createBoardContentPage" });
|
||||||
|
const getQueryClient = cache(makeQueryClient);
|
||||||
|
|
||||||
export type Params = Record<string, unknown>;
|
export type Params = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -61,7 +58,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, new Map<WidgetKind, Item[]>());
|
}, new Map<WidgetKind, Item[]>());
|
||||||
|
|
||||||
const [integrations] = await Promise.all([
|
const [integrations] = await Promise.all([
|
||||||
getIntegrationsWithPermissionsAsync(session),
|
getIntegrationsWithPermissionsAsync(session),
|
||||||
...Array.from(itemsMap).map(([kind, items]) =>
|
...Array.from(itemsMap).map(([kind, items]) =>
|
||||||
@@ -77,19 +73,12 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const userId = session?.user.id ?? "anonymous";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<Suspense>
|
<IntegrationProvider integrations={integrations}>
|
||||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
<DynamicClientBoard />
|
||||||
</Suspense>
|
</IntegrationProvider>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
</HydrationBoundary>
|
||||||
<IntegrationProvider integrations={integrations}>
|
|
||||||
<DynamicClientBoard />
|
|
||||||
</IntegrationProvider>
|
|
||||||
</HydrationBoundary>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||||
@@ -120,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";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
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), {
|
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
loading: BoardLoadingShell,
|
||||||
});
|
});
|
||||||
@@ -1,45 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import {
|
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||||
IconBox,
|
|
||||||
IconBoxAlignTop,
|
|
||||||
IconChevronDown,
|
|
||||||
IconLayoutBoard,
|
|
||||||
IconPencil,
|
|
||||||
IconPencilOff,
|
|
||||||
IconPlug,
|
|
||||||
IconPlus,
|
|
||||||
IconReplace,
|
|
||||||
IconResize,
|
|
||||||
IconSettings,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { AppSelectModal } from "@homarr/modals-collection";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
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 { 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 { 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 }) => {
|
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
@@ -52,16 +43,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEditMode && <AddMenu />}
|
{isEditMode && <BoardAddMenu />}
|
||||||
|
|
||||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||||
|
|
||||||
{!demoReadOnly && (
|
{!demoReadOnly && (
|
||||||
<OnboardingTour.Target id="board-settings">
|
<TourTarget id="board-settings">
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||||
<IconSettings stroke={1.5} />
|
<IconSettings stroke={1.5} />
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</OnboardingTour.Target>
|
</TourTarget>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SelectBoardsMenu />
|
<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 EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||||
const [isEditMode, { open, close }] = useEditMode();
|
const [isEditMode, { open, close }] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
@@ -197,21 +100,29 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
|||||||
usePreventLeaveWithDirty(isEditMode);
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingTour.Target id="board-edit-mode">
|
<TourTarget id="board-edit-mode">
|
||||||
<HeaderButton onClick={toggle} loading={isPending}>
|
<HeaderButton
|
||||||
|
onClick={toggle}
|
||||||
|
loading={isPending}
|
||||||
|
onFocus={preloadBoardAddMenu}
|
||||||
|
onPointerEnter={preloadBoardAddMenu}
|
||||||
|
>
|
||||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</OnboardingTour.Target>
|
</TourTarget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SelectBoardsMenu = () => {
|
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 (
|
return (
|
||||||
<OnboardingTour.Target id="board-switcher">
|
<TourTarget id="board-switcher">
|
||||||
<Box>
|
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||||
<Menu position="bottom-end">
|
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<HeaderButton w="auto" px={4}>
|
<HeaderButton w="auto" px={4}>
|
||||||
<IconReplace stroke={1.5} />
|
<IconReplace stroke={1.5} />
|
||||||
@@ -219,6 +130,11 @@ const SelectBoardsMenu = () => {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||||
<ScrollArea.Autosize mah={300}>
|
<ScrollArea.Autosize mah={300}>
|
||||||
|
{isPending && (
|
||||||
|
<Center p="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
{boards.map((board) => (
|
{boards.map((board) => (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={board.id}
|
key={board.id}
|
||||||
@@ -233,7 +149,7 @@ const SelectBoardsMenu = () => {
|
|||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
</OnboardingTour.Target>
|
</TourTarget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,15 +162,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDirty) return;
|
if (!isDirty) return;
|
||||||
|
|
||||||
const handleClick = (event: Event) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
const target = (event.target as HTMLElement).closest("a");
|
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) {
|
if (!target) return;
|
||||||
console.warn("No anchor element found for click event", event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: t("board.action.edit.confirmLeave.title"),
|
title: t("board.action.edit.confirmLeave.title"),
|
||||||
@@ -280,19 +194,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|||||||
event.returnValue = true;
|
event.returnValue = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const anchors = document.querySelectorAll(anchorSelector);
|
document.addEventListener("click", handleClick, true);
|
||||||
anchors.forEach((link) => {
|
|
||||||
link.addEventListener("click", handleClick);
|
|
||||||
});
|
|
||||||
window.addEventListener("popstate", handlePopState);
|
window.addEventListener("popstate", handlePopState);
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
anchors.forEach((link) => {
|
document.removeEventListener("click", handleClick, true);
|
||||||
link.removeEventListener("click", handleClick);
|
|
||||||
});
|
|
||||||
window.removeEventListener("popstate", handlePopState);
|
window.removeEventListener("popstate", handlePopState);
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
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 { BoardOtherHeaderActions } from "../_header-actions";
|
||||||
import { createBoardLayout } from "../_layout-creator";
|
import { createBoardLayout } from "../_layout-creator";
|
||||||
@@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator";
|
|||||||
export default createBoardLayout<{ locale: string; name: string }>({
|
export default createBoardLayout<{ locale: string; name: string }>({
|
||||||
headerActions: <BoardOtherHeaderActions />,
|
headerActions: <BoardOtherHeaderActions />,
|
||||||
async getInitialBoardAsync({ name }) {
|
async getInitialBoardAsync({ name }) {
|
||||||
return await api.board.getBoardByName({ name });
|
return await getBoardByNameAsync(name);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -19,130 +19,106 @@ import {
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { IconX } from "@tabler/icons-react";
|
import { IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useZodForm } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
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 { generateColors } from "../../(content)/_theme";
|
||||||
import { useSavePartialSettingsMutation } from "./_shared";
|
import type { FormValues } from "./_settings-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
board: Board;
|
form: UseFormReturnType<FormValues>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hexRegex = /^#[0-9a-fA-F]{6}$/;
|
const hexRegex = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
const progressPercentageLabel = (value: number) => `${value}%`;
|
const progressPercentageLabel = (value: number) => `${value}%`;
|
||||||
|
|
||||||
export const ColorSettingsContent = ({ board }: Props) => {
|
export const ColorSettingsContent = ({ form }: Props) => {
|
||||||
const form = useZodForm(boardSavePartialSettingsSchema, {
|
|
||||||
initialValues: {
|
|
||||||
primaryColor: board.primaryColor,
|
|
||||||
secondaryColor: board.secondaryColor,
|
|
||||||
opacity: board.opacity,
|
|
||||||
iconColor: board.iconColor ?? "",
|
|
||||||
itemRadius: board.itemRadius,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [showPreview, { toggle }] = useDisclosure(false);
|
const [showPreview, { toggle }] = useDisclosure(false);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<SectionCard title={t("board.setting.section.appearance.title")}>
|
||||||
onSubmit={form.onSubmit((values) => {
|
<Grid>
|
||||||
savePartialSettings({
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
id: board.id,
|
<Stack gap="xs">
|
||||||
...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 }}>
|
|
||||||
<ColorInput
|
<ColorInput
|
||||||
label={t("board.field.secondaryColor.label")}
|
label={t("board.field.primaryColor.label")}
|
||||||
format="hex"
|
format="hex"
|
||||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||||
{...form.getInputProps("secondaryColor")}
|
{...form.getInputProps("primaryColor")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Stack>
|
||||||
<Grid.Col span={12}>
|
</Grid.Col>
|
||||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
</Grid.Col>
|
<ColorInput
|
||||||
<Grid.Col span={12}>
|
label={t("board.field.secondaryColor.label")}
|
||||||
<Collapse expanded={showPreview}>
|
format="hex"
|
||||||
<Stack>
|
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
{...form.getInputProps("secondaryColor")}
|
||||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
/>
|
||||||
</Stack>
|
</Grid.Col>
|
||||||
</Collapse>
|
<Grid.Col span={12}>
|
||||||
</Grid.Col>
|
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
</Grid.Col>
|
||||||
<InputWrapper label={t("board.field.opacity.label")}>
|
<Grid.Col span={12}>
|
||||||
<Slider
|
<Collapse expanded={showPreview}>
|
||||||
my={6}
|
<Stack>
|
||||||
min={0}
|
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||||
max={100}
|
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||||
step={5}
|
</Stack>
|
||||||
label={progressPercentageLabel}
|
</Collapse>
|
||||||
{...form.getInputProps("opacity")}
|
</Grid.Col>
|
||||||
/>
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
</InputWrapper>
|
<InputWrapper label={t("board.field.opacity.label")}>
|
||||||
</Grid.Col>
|
<Slider
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
my={6}
|
||||||
<Group align="end">
|
min={0}
|
||||||
<ColorInput
|
max={100}
|
||||||
label={t("board.field.iconColor.label")}
|
step={5}
|
||||||
format="hex"
|
label={progressPercentageLabel}
|
||||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
{...form.getInputProps("opacity")}
|
||||||
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")}
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</InputWrapper>
|
||||||
</Grid>
|
</Grid.Col>
|
||||||
<Group justify="end">
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
<Button type="submit" loading={isPending}>
|
<Group align="end">
|
||||||
{t("common.action.saveChanges")}
|
<ColorInput
|
||||||
</Button>
|
label={t("board.field.iconColor.label")}
|
||||||
</Group>
|
format="hex"
|
||||||
</Stack>
|
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||||
</form>
|
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";
|
"use client";
|
||||||
|
|
||||||
import { startTransition } from "react";
|
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 { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
|
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
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 { UploadMedia } from "@homarr/forms-collection";
|
||||||
import type { TranslationObject } from "@homarr/translation";
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
||||||
import { SelectWithDescriptionBadge } from "@homarr/ui";
|
import { SelectWithDescriptionBadge } from "@homarr/ui";
|
||||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
import { useSavePartialSettingsMutation } from "./_shared";
|
import type { FormValues } from "./_settings-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
board: Board;
|
form: UseFormReturnType<FormValues>;
|
||||||
}
|
}
|
||||||
export const BackgroundSettingsContent = ({ board }: Props) => {
|
|
||||||
|
export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { data: session } = useSession();
|
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({
|
const medias = clientApi.media.getPaginated.useQuery({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
@@ -53,121 +44,106 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
|||||||
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<SectionCard title={t("board.setting.section.background.title")}>
|
||||||
onSubmit={form.onSubmit((values) => {
|
<Grid>
|
||||||
savePartialSettings({
|
<Grid.Col span={12}>
|
||||||
id: board.id,
|
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||||
...values,
|
<Autocomplete
|
||||||
});
|
flex={1}
|
||||||
})}
|
leftSection={
|
||||||
>
|
form.values.backgroundImageUrl &&
|
||||||
<Stack>
|
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||||
<Grid>
|
<Popover width={300} withArrow>
|
||||||
<Grid.Col span={12}>
|
<Popover.Target>
|
||||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
<Center h="100%">
|
||||||
<Autocomplete
|
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||||
flex={1}
|
</Center>
|
||||||
leftSection={
|
</Popover.Target>
|
||||||
form.values.backgroundImageUrl &&
|
<Popover.Dropdown>
|
||||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||||
<Popover width={300} withArrow>
|
</Popover.Dropdown>
|
||||||
<Popover.Target>
|
</Popover>
|
||||||
<Center h="100%">
|
)
|
||||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
}
|
||||||
</Center>
|
// We filter it on the server
|
||||||
</Popover.Target>
|
filter={({ options }) => options}
|
||||||
<Popover.Dropdown>
|
label={t("board.field.backgroundImageUrl.label")}
|
||||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||||
</Popover.Dropdown>
|
renderOption={({ option }) => {
|
||||||
</Popover>
|
const current = imageMap.get(option.value);
|
||||||
)
|
if (!current) return null;
|
||||||
}
|
|
||||||
// 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 (
|
return (
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<ImagePreview src={option.value} w={20} h={20} />
|
<ImagePreview src={option.value} w={20} h={20} />
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text size="sm">{current.name}</Text>
|
<Text size="sm">{current.name}</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{option.value}
|
{option.value}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</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={[
|
>
|
||||||
{
|
{({ onClick, loading }) => (
|
||||||
group: t("board.field.backgroundImageUrl.group.your"),
|
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||||
items: images
|
<IconUpload size={16} stroke={1.5} />
|
||||||
.filter((media) => media.creatorId === session?.user.id)
|
</ActionIcon>
|
||||||
.map((media) => `/api/user-medias/${media.id}`),
|
)}
|
||||||
},
|
</UploadMedia>
|
||||||
{
|
)}
|
||||||
group: t("board.field.backgroundImageUrl.group.other"),
|
</Group>
|
||||||
items: images
|
</Grid.Col>
|
||||||
.filter((media) => media.creatorId !== session?.user.id)
|
<Grid.Col span={12}>
|
||||||
.map((media) => `/api/user-medias/${media.id}`),
|
<SelectWithDescriptionBadge
|
||||||
},
|
label={t("board.field.backgroundImageAttachment.label")}
|
||||||
]}
|
data={backgroundImageAttachmentData}
|
||||||
{...form.getInputProps("backgroundImageUrl")}
|
{...form.getInputProps("backgroundImageAttachment")}
|
||||||
/>
|
/>
|
||||||
{session?.user.permissions.includes("media-upload") && (
|
</Grid.Col>
|
||||||
<UploadMedia
|
<Grid.Col span={12}>
|
||||||
onSuccess={(medias) => {
|
<SelectWithDescriptionBadge
|
||||||
const first = medias.at(0);
|
label={t("board.field.backgroundImageSize.label")}
|
||||||
if (!first) return;
|
data={backgroundImageSizeData}
|
||||||
|
{...form.getInputProps("backgroundImageSize")}
|
||||||
startTransition(() => {
|
/>
|
||||||
form.setFieldValue("backgroundImageUrl", first.url);
|
</Grid.Col>
|
||||||
});
|
<Grid.Col span={12}>
|
||||||
}}
|
<SelectWithDescriptionBadge
|
||||||
>
|
label={t("board.field.backgroundImageRepeat.label")}
|
||||||
{({ onClick, loading }) => (
|
data={backgroundImageRepeatData}
|
||||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
{...form.getInputProps("backgroundImageRepeat")}
|
||||||
<IconUpload size={16} stroke={1.5} />
|
/>
|
||||||
</ActionIcon>
|
</Grid.Col>
|
||||||
)}
|
</Grid>
|
||||||
</UploadMedia>
|
</SectionCard>
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +1,27 @@
|
|||||||
"use client";
|
"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 { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
import { useSavePartialSettingsMutation } from "./_shared";
|
import type { FormValues } from "./_settings-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
board: Board;
|
form: UseFormReturnType<FormValues>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BehaviorSettingsContent = ({ board }: Props) => {
|
export const BehaviorSettingsContent = ({ form }: Props) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
|
||||||
disableStatus: board.disableStatus,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<SectionCard title={t("board.setting.section.behavior.title")}>
|
||||||
onSubmit={form.onSubmit((values) => {
|
<Switch
|
||||||
savePartialSettings({
|
label={t("board.field.disableStatus.label")}
|
||||||
id: board.id,
|
description={t("board.field.disableStatus.description")}
|
||||||
...values,
|
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||||
});
|
/>
|
||||||
})}
|
</SectionCard>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
|
import { Alert, Input } from "@mantine/core";
|
||||||
import { highlight, languages } from "prismjs";
|
import { highlight, languages } from "prismjs";
|
||||||
import Editor from "react-simple-code-editor";
|
import Editor from "react-simple-code-editor";
|
||||||
|
|
||||||
@@ -8,59 +8,39 @@ import "~/styles/prismjs.scss";
|
|||||||
|
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
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 { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
import { useSavePartialSettingsMutation } from "./_shared";
|
import type { FormValues } from "./_settings-form";
|
||||||
import classes from "./customcss.module.css";
|
import classes from "./customcss.module.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
board: Board;
|
form: UseFormReturnType<FormValues>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomCssSettingsContent = ({ board }: Props) => {
|
export const CustomCssSettingsContent = ({ form }: Props) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const customCssT = useScopedI18n("board.field.customCss");
|
const customCssT = useScopedI18n("board.field.customCss");
|
||||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
|
||||||
customCss: board.customCss ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<SectionCard title={t("board.setting.section.customCss.title")}>
|
||||||
onSubmit={form.onSubmit((values) => {
|
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||||
savePartialSettings({
|
|
||||||
id: board.id,
|
|
||||||
...values,
|
|
||||||
});
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
|
||||||
|
|
||||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||||
{customCssT("customClassesAlert.description")}
|
{customCssT("customClassesAlert.description")}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
</SectionCard>
|
||||||
<Group justify="end">
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
{t("common.action.saveChanges")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CustomCssInputProps {
|
interface CustomCssInputProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
error?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
||||||
const customCssT = useScopedI18n("board.field.customCss");
|
const customCssT = useScopedI18n("board.field.customCss");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,6 +50,7 @@ const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
|||||||
htmlFor: "custom-css",
|
htmlFor: "custom-css",
|
||||||
}}
|
}}
|
||||||
description={customCssT("description")}
|
description={customCssT("description")}
|
||||||
|
error={error}
|
||||||
inputWrapperOrder={["label", "description", "input", "error"]}
|
inputWrapperOrder={["label", "description", "input", "error"]}
|
||||||
>
|
>
|
||||||
<div className={classes.codeEditorRoot}>
|
<div className={classes.codeEditorRoot}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
@@ -10,7 +10,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
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 }) => {
|
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
@@ -82,60 +82,36 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
|||||||
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<DangerZoneRoot>
|
||||||
<Divider />
|
<DangerZoneItem
|
||||||
<DangerZoneRow
|
|
||||||
label={t("section.dangerZone.action.rename.label")}
|
label={t("section.dangerZone.action.rename.label")}
|
||||||
description={t("section.dangerZone.action.rename.description")}
|
description={t("section.dangerZone.action.rename.description")}
|
||||||
buttonText={t("section.dangerZone.action.rename.button")}
|
action={
|
||||||
onClick={onRenameClick}
|
<Button variant="subtle" color="red" onClick={onRenameClick}>
|
||||||
|
{t("section.dangerZone.action.rename.button")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{hideVisibility ? null : (
|
{!hideVisibility && (
|
||||||
<>
|
<DangerZoneItem
|
||||||
<Divider />
|
label={t("section.dangerZone.action.visibility.label")}
|
||||||
<DangerZoneRow
|
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||||
label={t("section.dangerZone.action.visibility.label")}
|
action={
|
||||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
|
||||||
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||||
onClick={onVisibilityClick}
|
</Button>
|
||||||
isPending={isChangeVisibilityPending}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<Divider />
|
<DangerZoneItem
|
||||||
<DangerZoneRow
|
|
||||||
label={t("section.dangerZone.action.delete.label")}
|
label={t("section.dangerZone.action.delete.label")}
|
||||||
description={t("section.dangerZone.action.delete.description")}
|
description={t("section.dangerZone.action.delete.description")}
|
||||||
buttonText={t("section.dangerZone.action.delete.button")}
|
action={
|
||||||
onClick={onDeleteClick}
|
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||||
isPending={isDeletePending}
|
{t("section.dangerZone.action.delete.button")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</DangerZoneRoot>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,128 +1,66 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core";
|
import { Grid, Loader, TextInput } from "@mantine/core";
|
||||||
import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks";
|
import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks";
|
||||||
|
|
||||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||||
import { useZodForm } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { IconPicker } from "@homarr/forms-collection";
|
import { IconPicker } from "@homarr/forms-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
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 { createMetaTitle } from "~/metadata";
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
import { useSavePartialSettingsMutation } from "./_shared";
|
import type { FormValues } from "./_settings-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
board: Board;
|
board: Board;
|
||||||
|
form: UseFormReturnType<FormValues>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GeneralSettingsContent = ({ board }: Props) => {
|
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||||
const t = useI18n();
|
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);
|
useLogoPreview(form.values.logoImageUrl);
|
||||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
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 (
|
return (
|
||||||
<form
|
<SectionCard title={t("board.setting.section.general.title")}>
|
||||||
onSubmit={form.onSubmit((values) => {
|
<Grid>
|
||||||
// Save the current values to the ref so that it does not reset if the form is submitted
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
ref.current = {
|
<TextInput
|
||||||
pageTitle: values.pageTitle,
|
label={t("board.field.pageTitle.label")}
|
||||||
logoImageUrl: values.logoImageUrl,
|
placeholder="Homarr"
|
||||||
};
|
{...form.getInputProps("pageTitle")}
|
||||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
/>
|
||||||
savePartialSettings({
|
</Grid.Col>
|
||||||
id: board.id,
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
...values,
|
<TextInput
|
||||||
});
|
label={t("board.field.metaTitle.label")}
|
||||||
})}
|
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||||
>
|
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||||
<Stack>
|
{...form.getInputProps("metaTitle")}
|
||||||
<Grid>
|
/>
|
||||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
</Grid.Col>
|
||||||
<TextInput
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
label={t("board.field.pageTitle.label")}
|
<IconPicker
|
||||||
placeholder="Homarr"
|
{...form.getInputProps("logoImageUrl")}
|
||||||
{...form.getInputProps("pageTitle")}
|
label={t("board.field.logoImageUrl.label")}
|
||||||
/>
|
placeholder="/logo/logo.png"
|
||||||
</Grid.Col>
|
withAsterisk={false}
|
||||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
/>
|
||||||
<TextInput
|
</Grid.Col>
|
||||||
label={t("board.field.metaTitle.label")}
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
<IconPicker
|
||||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
{...form.getInputProps("faviconImageUrl")}
|
||||||
{...form.getInputProps("metaTitle")}
|
label={t("board.field.faviconImageUrl.label")}
|
||||||
/>
|
placeholder="/logo/logo.png"
|
||||||
</Grid.Col>
|
withAsterisk={false}
|
||||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
/>
|
||||||
<IconPicker
|
</Grid.Col>
|
||||||
{...form.getInputProps("logoImageUrl")}
|
</Grid>
|
||||||
label={t("board.field.logoImageUrl.label")}
|
</SectionCard>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,14 +84,3 @@ const useMetaTitlePreview = (title: string | null) => {
|
|||||||
isPending: (title ?? "") !== metaTitleDebounced,
|
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 { 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 { createId } from "@homarr/common";
|
||||||
import { useZodForm } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
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 {
|
interface Props {
|
||||||
board: Board;
|
form: UseFormReturnType<FormValues>;
|
||||||
}
|
}
|
||||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
|
||||||
|
export const LayoutSettingsContent = ({ form }: Props) => {
|
||||||
const t = useI18n();
|
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 (
|
return (
|
||||||
<form
|
<SectionCard title={t("board.setting.section.layout.title")}>
|
||||||
onSubmit={form.onSubmit((values) => {
|
<Stack gap="sm">
|
||||||
saveLayouts({
|
<Group justify="space-between" align="center">
|
||||||
id: board.id,
|
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||||
...values,
|
<Button
|
||||||
});
|
type="button"
|
||||||
})}
|
variant="subtle"
|
||||||
>
|
onClick={() => {
|
||||||
<Stack>
|
form.setValues((previous) => ({
|
||||||
<Stack gap="sm">
|
...previous,
|
||||||
<Group justify="space-between" align="center">
|
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
|
||||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
}));
|
||||||
<Button
|
}}
|
||||||
variant="subtle"
|
>
|
||||||
onClick={() => {
|
{t("board.setting.section.layout.responsive.action.add")}
|
||||||
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")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</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>
|
</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 { notFound } from "next/navigation";
|
||||||
import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
|
import { Container, Stack, Title } from "@mantine/core";
|
||||||
import {
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconBrush,
|
|
||||||
IconClick,
|
|
||||||
IconFileTypeCss,
|
|
||||||
IconLayout,
|
|
||||||
IconPhoto,
|
|
||||||
IconSettings,
|
|
||||||
IconUser,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { capitalize } from "@homarr/common";
|
import { capitalize } from "@homarr/common";
|
||||||
import { db } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import type { TranslationObject } from "@homarr/translation";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
|
||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
import { BoardSettingsForm } from "./_settings-form";
|
||||||
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";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
name: string;
|
name: string;
|
||||||
}>;
|
}>;
|
||||||
searchParams: Promise<{
|
|
||||||
tab?: keyof TranslationObject["board"]["setting"]["section"];
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
|
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
|
||||||
@@ -57,11 +33,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore not found errors and redirect to 404
|
// Ignore not found errors and redirect to 404
|
||||||
// error is already logged in _layout-creator.tsx
|
// error is already logged in _layout-creator.tsx
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +42,6 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function BoardSettingsPage(props: Props) {
|
export default async function BoardSettingsPage(props: Props) {
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||||
@@ -85,76 +56,13 @@ export default async function BoardSettingsPage(props: Props) {
|
|||||||
<Container>
|
<Container>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
||||||
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
|
<BoardSettingsForm
|
||||||
<AccordionItemFor value="general" icon={IconSettings}>
|
board={board}
|
||||||
<GeneralSettingsContent board={board} />
|
permissions={permissions}
|
||||||
</AccordionItemFor>
|
hasFullAccess={hasFullAccess}
|
||||||
<AccordionItemFor value="layout" icon={IconLayout}>
|
hideVisibility={boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id}
|
||||||
<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>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</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 { AppShellMain } from "@mantine/core";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { BoardProvider } from "@homarr/boards/context";
|
import { BoardProvider } from "@homarr/boards/context";
|
||||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
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 { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
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 { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import type { Board } from "./_types";
|
import type { Board } from "./_types";
|
||||||
import type { Params } from "./(content)/_creator";
|
import type { Params } from "./(content)/_creator";
|
||||||
@@ -21,11 +23,6 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
|
|
||||||
const logger = createLogger({ module: "createBoardLayout" });
|
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> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
@@ -43,8 +40,32 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
}: PropsWithChildren<{
|
}: PropsWithChildren<{
|
||||||
params: Promise<TParams>;
|
params: Promise<TParams>;
|
||||||
}>) => {
|
}>) => {
|
||||||
const session = await auth();
|
const resolvedParams = await params;
|
||||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
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 (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||||
@@ -60,8 +81,8 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
});
|
}
|
||||||
const colorScheme = await getCurrentColorSchemeAsync();
|
const initialBoard = initialBoardResult.board;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardProvider initialBoard={initialBoard}>
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
@@ -69,7 +90,7 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
<EditModeProvider>
|
<EditModeProvider>
|
||||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<CustomCss />
|
<CustomCss />
|
||||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||||
<ClientShell hasNavigation={false}>
|
<ClientShell hasNavigation={false}>
|
||||||
<MainHeader
|
<MainHeader
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
@@ -78,7 +99,7 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
/>
|
/>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
</ClientShell>
|
</ClientShell>
|
||||||
</BoardTourWrapper>
|
</BoardTourGate>
|
||||||
</BoardMantineProvider>
|
</BoardMantineProvider>
|
||||||
</EditModeProvider>
|
</EditModeProvider>
|
||||||
</BoardReadyProvider>
|
</BoardReadyProvider>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
import type { MaybePromise } from "@homarr/common/types";
|
import type { MaybePromise } from "@homarr/common/types";
|
||||||
|
import { db } from "@homarr/db";
|
||||||
import type { OnboardingStep } from "@homarr/definitions";
|
import type { OnboardingStep } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -29,9 +29,16 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
|
|||||||
finish: InitFinish,
|
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() {
|
export default async function InitPage() {
|
||||||
const t = await getScopedI18n("init.step");
|
const t = await getScopedI18n("init.step");
|
||||||
const currentStep = await api.onboard.currentStep();
|
const currentStep = await getCurrentOnboardingStepAsync();
|
||||||
|
|
||||||
const CurrentComponent = stepComponents[currentStep.current];
|
const CurrentComponent = stepComponents[currentStep.current];
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import { notFound } from "next/navigation";
|
|||||||
import type { DayOfWeek } from "@mantine/dates";
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
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 { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { db } from "@homarr/db";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
|
||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
import { Notifications } from "@homarr/notifications";
|
import { Notifications } from "@homarr/notifications";
|
||||||
import { SettingsProvider } from "@homarr/settings";
|
import { SettingsProvider } from "@homarr/settings";
|
||||||
@@ -43,6 +43,8 @@ const fontSans = Inter({
|
|||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "rootLayout" });
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
@@ -78,15 +80,27 @@ export default async function Layout(props: {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ locale: SupportedLanguage }>;
|
params: Promise<{ locale: SupportedLanguage }>;
|
||||||
}) {
|
}) {
|
||||||
if (!isLocaleSupported((await props.params).locale)) {
|
const { locale } = await props.params;
|
||||||
|
if (!isLocaleSupported(locale)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const sessionPromise = auth();
|
||||||
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
const userPromise = sessionPromise.then((session) =>
|
||||||
const serverSettings = await getServerSettingsAsync(db);
|
session
|
||||||
const colorScheme = await getCurrentColorSchemeAsync();
|
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
||||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
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([
|
const StackedProvider = composeWrappers([
|
||||||
(innerProps) => {
|
(innerProps) => {
|
||||||
@@ -125,8 +139,6 @@ export default async function Layout(props: {
|
|||||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { locale } = await props.params;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||||
<html
|
<html
|
||||||
|
|||||||
@@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||||
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
|
<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>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
@@ -39,11 +41,29 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
|||||||
import type { NavigationLink } from "~/components/layout/navigation";
|
import type { NavigationLink } from "~/components/layout/navigation";
|
||||||
import { MainNavigation } from "~/components/layout/navigation";
|
import { MainNavigation } from "~/components/layout/navigation";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
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) {
|
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||||
const t = await getScopedI18n("management.navbar");
|
const sessionPromise = auth();
|
||||||
const session = await 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[] = [
|
const navigationLinks: NavigationLink[] = [
|
||||||
{
|
{
|
||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
@@ -225,7 +245,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
</ClientShell>
|
</ClientShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session) return shell;
|
return (
|
||||||
|
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
|
||||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
{shell}
|
||||||
|
</ManageTourGate>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
|||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
<IconPicker
|
||||||
|
{...form.getInputProps("iconUrl")}
|
||||||
|
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
|
|||||||
@@ -1,53 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
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 { useScopedI18n } from "@homarr/translation/client";
|
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 {
|
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 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 (
|
return (
|
||||||
<>
|
<SectionCard title={t("title")}>
|
||||||
<Title order={2}>{t("title")}</Title>
|
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||||
|
</SectionCard>
|
||||||
<Card pos="relative">
|
|
||||||
<LoadingOverlay visible={isPending} />
|
|
||||||
<Stack>
|
|
||||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+21
-20
@@ -5,31 +5,32 @@ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
import { colorSchemes } 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 { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { SelectWithCustomItems } from "@homarr/ui";
|
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"] }) => {
|
interface AppearanceSettingsFormProps {
|
||||||
const tApperance = useScopedI18n("management.page.settings.section.appearance");
|
form: UseFormReturnType<FormValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => {
|
||||||
|
const tAppearance = useScopedI18n("management.page.settings.section.appearance");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
|
<SectionCard title={tAppearance("title")}>
|
||||||
{(form) => (
|
<SelectWithCustomItems
|
||||||
<>
|
label={tAppearance("defaultColorScheme.label")}
|
||||||
<SelectWithCustomItems
|
data={colorSchemes.map((scheme) => ({
|
||||||
label={tApperance("defaultColorScheme.label")}
|
value: scheme,
|
||||||
data={colorSchemes.map((scheme) => ({
|
label: tAppearance(`defaultColorScheme.options.${scheme}`),
|
||||||
value: scheme,
|
}))}
|
||||||
label: tApperance(`defaultColorScheme.options.${scheme}`),
|
{...form.getInputProps("defaultColorScheme")}
|
||||||
}))}
|
SelectOption={AppearanceCustomOption}
|
||||||
{...form.getInputProps("defaultColorScheme")}
|
/>
|
||||||
SelectOption={ApperanceCustomOption}
|
</SectionCard>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CommonSettingsForm>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ const appearanceIcons = {
|
|||||||
dark: IconMoon,
|
dark: IconMoon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
const AppearanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||||
const Icon = appearanceIcons[value];
|
const Icon = appearanceIcons[value];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,49 +3,50 @@
|
|||||||
import { Switch, Text } from "@mantine/core";
|
import { Switch, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
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 { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { BoardSelect } from "~/components/board/board-select";
|
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 tBoard = useScopedI18n("management.page.settings.section.board");
|
||||||
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
<SectionCard title={tBoard("title")}>
|
||||||
{(form) => (
|
<BoardSelect
|
||||||
<>
|
label={tBoard("homeBoard.label")}
|
||||||
<BoardSelect
|
description={tBoard("homeBoard.description")}
|
||||||
label={tBoard("homeBoard.label")}
|
clearable
|
||||||
description={tBoard("homeBoard.description")}
|
boards={selectableBoards}
|
||||||
clearable
|
{...form.getInputProps("homeBoardId")}
|
||||||
boards={selectableBoards}
|
/>
|
||||||
{...form.getInputProps("homeBoardId")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BoardSelect
|
<BoardSelect
|
||||||
label={tBoard("homeBoard.mobileLabel")}
|
label={tBoard("homeBoard.mobileLabel")}
|
||||||
description={tBoard("homeBoard.description")}
|
description={tBoard("homeBoard.description")}
|
||||||
clearable
|
clearable
|
||||||
boards={selectableBoards}
|
boards={selectableBoards}
|
||||||
{...form.getInputProps("mobileHomeBoardId")}
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||||
<Switch
|
<Switch
|
||||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||||
label={tBoard("status.enableStatusByDefault.label")}
|
label={tBoard("status.enableStatusByDefault.label")}
|
||||||
description={tBoard("status.enableStatusByDefault.description")}
|
description={tBoard("status.enableStatusByDefault.description")}
|
||||||
/>
|
/>
|
||||||
<Switch
|
<Switch
|
||||||
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||||
label={tBoard("status.forceDisableStatus.label")}
|
label={tBoard("status.forceDisableStatus.label")}
|
||||||
description={tBoard("status.forceDisableStatus.description")}
|
description={tBoard("status.forceDisableStatus.description")}
|
||||||
/>
|
/>
|
||||||
</>
|
</SectionCard>
|
||||||
)}
|
|
||||||
</CommonSettingsForm>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import { Text } from "@mantine/core";
|
||||||
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useForm } from "@homarr/form";
|
|
||||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
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 {
|
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 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 (
|
return (
|
||||||
<>
|
<SectionCard title={t("title")}>
|
||||||
<Title order={2}>{t("title")}</Title>
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("warning")}
|
||||||
<Card pos="relative">
|
</Text>
|
||||||
<Text c={"dimmed"} mb={"lg"}>
|
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||||
{t("warning")}
|
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||||
</Text>
|
<SwitchSetting form={form} formKey="noTranslate" title={t("noTranslate.title")} text={t("noTranslate.text")} />
|
||||||
<LoadingOverlay visible={isPending} />
|
<SwitchSetting
|
||||||
<Stack>
|
form={form}
|
||||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
formKey="noSiteLinksSearchBox"
|
||||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
title={t("noSiteLinksSearchBox.title")}
|
||||||
<SwitchSetting
|
text={t("noSiteLinksSearchBox.text")}
|
||||||
form={form}
|
/>
|
||||||
formKey="noTranslate"
|
</SectionCard>
|
||||||
title={t("noTranslate.title")}
|
|
||||||
text={t("noTranslate.text")}
|
|
||||||
/>
|
|
||||||
<SwitchSetting
|
|
||||||
form={form}
|
|
||||||
formKey="noSiteLinksSearchBox"
|
|
||||||
title={t("noSiteLinksSearchBox.title")}
|
|
||||||
text={t("noSiteLinksSearchBox.text")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+16
-14
@@ -1,26 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ServerSettings } from "@homarr/server-settings";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import type { SupportedLanguage } from "@homarr/translation";
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { LanguageCombobox } from "~/components/language/language-combobox";
|
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 tCulture = useScopedI18n("management.page.settings.section.culture");
|
||||||
|
const localeInputProps = form.getInputProps("defaultLocale");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
|
<SectionCard title={tCulture("title")}>
|
||||||
{(form) => (
|
<LanguageCombobox
|
||||||
<>
|
label={tCulture("defaultLocale.label")}
|
||||||
<LanguageCombobox
|
{...localeInputProps}
|
||||||
label={tCulture("defaultLocale.label")}
|
value={localeInputProps.value as SupportedLanguage}
|
||||||
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
|
/>
|
||||||
{...form.getInputProps("defaultLocale")}
|
</SectionCard>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CommonSettingsForm>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -3,27 +3,28 @@
|
|||||||
import { Select } from "@mantine/core";
|
import { Select } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
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 { 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 tSearch = useScopedI18n("management.page.settings.section.search");
|
||||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
|
<SectionCard title={tSearch("title")}>
|
||||||
{(form) => (
|
<Select
|
||||||
<>
|
label={tSearch("defaultSearchEngine.label")}
|
||||||
<Select
|
description={tSearch("defaultSearchEngine.description")}
|
||||||
label={tSearch("defaultSearchEngine.label")}
|
data={selectableSearchEngines}
|
||||||
description={tSearch("defaultSearchEngine.description")}
|
{...form.getInputProps("defaultSearchEngineId")}
|
||||||
data={selectableSearchEngines}
|
/>
|
||||||
{...form.getInputProps("defaultSearchEngineId")}
|
</SectionCard>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CommonSettingsForm>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -3,10 +3,13 @@ import React from "react";
|
|||||||
import type { MantineSpacing } from "@mantine/core";
|
import type { MantineSpacing } from "@mantine/core";
|
||||||
import { Group, Stack, Switch, Text, UnstyledButton } 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";
|
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,
|
form,
|
||||||
ms,
|
ms,
|
||||||
title,
|
title,
|
||||||
@@ -14,13 +17,8 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
|||||||
formKey,
|
formKey,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
form: Modify<
|
form: UseFormReturnType<TFormValue, TFormValue>;
|
||||||
UseFormReturnType<TFormValue, TFormValue>,
|
formKey: string & BooleanKeys<TFormValue>;
|
||||||
{
|
|
||||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
formKey: keyof TFormValue;
|
|
||||||
ms?: MantineSpacing;
|
ms?: MantineSpacing;
|
||||||
title: string;
|
title: string;
|
||||||
text: ReactNode;
|
text: ReactNode;
|
||||||
@@ -31,7 +29,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.setFieldValue(formKey, (previous) => !previous);
|
form.setFieldValue(formKey, (previous) => !previous as never);
|
||||||
}, [form, formKey, disabled]);
|
}, [form, formKey, disabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,7 +42,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
<Switch disabled={disabled} onClick={handleClick} checked={Boolean(form.values[formKey])} />
|
||||||
</Group>
|
</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 { Switch } from "@mantine/core";
|
||||||
|
|
||||||
import type { ServerSettings } from "@homarr/server-settings";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
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");
|
const tUser = useScopedI18n("management.page.settings.section.user");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
|
<SectionCard title={tUser("title")}>
|
||||||
{(form) => (
|
<Switch
|
||||||
<>
|
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
||||||
<Switch
|
label={tUser("enableGravatar.label")}
|
||||||
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
description={tUser("enableGravatar.description")}
|
||||||
label={tUser("enableGravatar.label")}
|
/>
|
||||||
description={tUser("enableGravatar.description")}
|
</SectionCard>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CommonSettingsForm>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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 { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
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 { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
import { SettingsForm } from "./_components/settings-form";
|
||||||
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";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
@@ -32,33 +26,13 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
const serverSettings = await api.serverSettings.getAll();
|
const serverSettings = await api.serverSettings.getAll();
|
||||||
const tSettings = await getScopedI18n("management.page.settings");
|
const tSettings = await getScopedI18n("management.page.settings");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tSettings("title")}</Title>
|
<Title order={1}>{tSettings("title")}</Title>
|
||||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
<SettingsForm initialData={serverSettings} />
|
||||||
<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>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+3
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconRefresh } from "@tabler/icons-react";
|
import { IconRefresh } from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
export const ResetTours = () => {
|
export const ResetTours = () => {
|
||||||
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
|
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
|
||||||
|
const router = useRouter();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||||
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
|
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
|
||||||
@@ -18,6 +20,7 @@ export const ResetTours = () => {
|
|||||||
message: t("resetSuccess.message"),
|
message: t("resetSuccess.message"),
|
||||||
});
|
});
|
||||||
void utils.user.getTourStatus.invalidate();
|
void utils.user.getTourStatus.invalidate();
|
||||||
|
router.refresh();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { use, useCallback, useState } from "react";
|
||||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
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 { useModalAction } from "@homarr/modals";
|
||||||
import { showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
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 type { Dimensions } from "./_dimension-modal";
|
||||||
import { PreviewDimensionsModal } from "./_dimension-modal";
|
import { PreviewDimensionsModal } from "./_dimension-modal";
|
||||||
|
|
||||||
interface WidgetPreviewPageContentProps {
|
interface WidgetPreviewPageContentProps {
|
||||||
kind: WidgetKind;
|
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 settings = useSettings();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
const utils = clientApi.useUtils();
|
||||||
|
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
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 [editMode, setEditMode] = useState(false);
|
||||||
|
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||||
width: 128,
|
width: 128,
|
||||||
height: 128,
|
height: 128,
|
||||||
@@ -45,7 +42,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
integrationIds: string[];
|
integrationIds: string[];
|
||||||
advancedOptions: BoardItemAdvancedOptions;
|
advancedOptions: BoardItemAdvancedOptions;
|
||||||
}>({
|
}>({
|
||||||
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}),
|
||||||
integrationIds: [],
|
integrationIds: [],
|
||||||
advancedOptions: {
|
advancedOptions: {
|
||||||
title: null,
|
title: null,
|
||||||
@@ -54,31 +51,41 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenEditWidgetModal = useCallback(() => {
|
const handleOpenEditWidgetModal = useCallback(async () => {
|
||||||
openWidgetEditModal(
|
const hasIntegrationSupport = "supportedIntegrations" in currentDefinition;
|
||||||
{
|
setIsEditorLoading(true);
|
||||||
kind,
|
preloadWidgetEditModal();
|
||||||
value: state,
|
try {
|
||||||
onSuccessfulEdit: (value) => {
|
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
||||||
setState(value);
|
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) =>
|
title(translate) {
|
||||||
"supportedIntegrations" in currentDefinition &&
|
return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`;
|
||||||
(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`)}`;
|
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
} catch (error) {
|
||||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
|
showErrorNotification({
|
||||||
|
title: t("common.error"),
|
||||||
const Comp = loadWidgetDynamic(kind);
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsEditorLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||||
|
|
||||||
const toggleEditMode = useCallback(() => {
|
const toggleEditMode = useCallback(() => {
|
||||||
setEditMode((editMode) => !editMode);
|
setEditMode((editMode) => !editMode);
|
||||||
@@ -94,8 +101,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
});
|
});
|
||||||
}, [dimensions, openPreviewDimensionsModal]);
|
}, [dimensions, openPreviewDimensionsModal]);
|
||||||
|
|
||||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
const updateOptions = useCallback(
|
||||||
setState({ ...state, options: { ...state.options, newOptions } });
|
({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||||
|
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -105,10 +115,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onReset={reset}
|
onReset={reset}
|
||||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
<WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
<WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Comp
|
<Component
|
||||||
options={state.options as never}
|
options={state.options as never}
|
||||||
integrationIds={state.integrationIds}
|
integrationIds={state.integrationIds}
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
@@ -127,7 +137,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
size={48}
|
size={48}
|
||||||
variant="default"
|
variant="default"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={handleOpenEditWidgetModal}
|
onClick={() => void handleOpenEditWidgetModal()}
|
||||||
|
onFocus={preloadWidgetEditModal}
|
||||||
|
onPointerEnter={preloadWidgetEditModal}
|
||||||
|
loading={isEditorLoading}
|
||||||
aria-label={t("common.action.edit")}
|
aria-label={t("common.action.edit")}
|
||||||
>
|
>
|
||||||
<IconPencil size={24} />
|
<IconPencil size={24} />
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
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 { MainNavigation } from "~/components/layout/navigation";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
|
|
||||||
const getLinks = () => {
|
const links = widgetKinds.map((kind) => ({
|
||||||
return Object.entries(widgetImports).map(([key, value]) => {
|
href: `/widgets/${kind}`,
|
||||||
return {
|
icon: widgetCatalogIcons[kind],
|
||||||
href: `/widgets/${key}`,
|
label: kind,
|
||||||
icon: value.definition.icon,
|
}));
|
||||||
label: value.definition.kind,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
|
export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
|
||||||
const links = getLinks();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientShell hasHeader={false}>
|
<ClientShell hasHeader={false}>
|
||||||
<MainNavigation links={links} />
|
<MainNavigation links={links} />
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import { notFound } from "next/navigation";
|
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 type { WidgetKind } from "@homarr/definitions";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetKinds } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import { WidgetPreviewPageContent } from "./_content";
|
import { WidgetPreviewPageContent } from "./_content";
|
||||||
|
|
||||||
@@ -13,24 +12,16 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function WidgetPreview(props: Props) {
|
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();
|
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 (
|
return (
|
||||||
<Center h="100vh">
|
<Center h="100vh">
|
||||||
<WidgetPreviewPageContent kind={sort} integrationData={integrationData} />
|
<Suspense fallback={<Loader size="sm" />}>
|
||||||
|
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||||
|
</Suspense>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export async function GET() {
|
|||||||
const date = new Date().toISOString().split("T")[0];
|
const date = new Date().toISOString().split("T")[0];
|
||||||
const filename = `homarr-backup-${date}.zip`;
|
const filename = `homarr-backup-${date}.zip`;
|
||||||
|
|
||||||
return new NextResponse(zipBuffer, {
|
return new NextResponse(zipBuffer as unknown as BodyInit, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import type { MutableRefObject } from "react";
|
import type { ComponentType, MutableRefObject } from "react";
|
||||||
import { useRef } from "react";
|
import { Suspense, use, useRef } from "react";
|
||||||
import { Badge, Card } from "@mantine/core";
|
import type { CardProps } from "@mantine/core";
|
||||||
|
import { Badge, Card, Center, Loader } from "@mantine/core";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize } from "@mantine/hooks";
|
||||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||||
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
import { WidgetError } from "@homarr/widgets/errors";
|
||||||
|
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||||
|
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
|
||||||
|
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import classes from "../sections/item.module.css";
|
import classes from "../sections/item.module.css";
|
||||||
@@ -19,7 +23,7 @@ import { useItemActions } from "./item-actions";
|
|||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
import { BoardItemMenu } from "./item-menu";
|
import { BoardItemMenu } from "./item-menu";
|
||||||
import { WidgetContextMenu } from "./widget-context-menu";
|
import { WidgetContextMenu } from "./widget-context-menu";
|
||||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||||
|
|
||||||
interface BoardItemContentProps {
|
interface BoardItemContentProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
@@ -31,6 +35,53 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BoardItemCardProps = CardProps & {
|
||||||
|
item: SectionItem;
|
||||||
|
innerRef: (element: HTMLDivElement | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
{...cardProps}
|
||||||
|
ref={innerRef}
|
||||||
|
className={combineClasses(
|
||||||
|
classes.itemCard,
|
||||||
|
`${item.kind}-wrapper`,
|
||||||
|
"grid-stack-item-content",
|
||||||
|
item.advancedOptions.customCssClasses.join(" "),
|
||||||
|
)}
|
||||||
|
radius={board.itemRadius}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
"--opacity": board.opacity / 100,
|
||||||
|
containerType: "size",
|
||||||
|
overflow: getOverflowFromKind(item.kind),
|
||||||
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
p={0}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const WidgetDefinitionLoadError = ({
|
||||||
|
error,
|
||||||
|
resetErrorBoundary,
|
||||||
|
item,
|
||||||
|
innerRef,
|
||||||
|
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
|
||||||
|
<BoardItemCard item={item} innerRef={innerRef}>
|
||||||
|
<Center h="100%">
|
||||||
|
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||||
|
</Center>
|
||||||
|
</BoardItemCard>
|
||||||
|
);
|
||||||
|
|
||||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
@@ -38,29 +89,28 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
<ErrorBoundary
|
||||||
<Card
|
resetKeys={[item.kind]}
|
||||||
ref={ref}
|
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
||||||
className={combineClasses(
|
>
|
||||||
classes.itemCard,
|
<Suspense
|
||||||
`${item.kind}-wrapper`,
|
fallback={
|
||||||
"grid-stack-item-content",
|
<BoardItemCard item={item} innerRef={ref}>
|
||||||
item.advancedOptions.customCssClasses.join(" "),
|
<Center h="100%">
|
||||||
)}
|
<Loader size="sm" />
|
||||||
radius={board.itemRadius}
|
</Center>
|
||||||
styles={{
|
</BoardItemCard>
|
||||||
root: {
|
}
|
||||||
"--opacity": board.opacity / 100,
|
|
||||||
containerType: "size",
|
|
||||||
overflow: getOverflowFromKind(item.kind),
|
|
||||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
p={0}
|
|
||||||
>
|
>
|
||||||
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
|
<LoadedBoardItemContent
|
||||||
</Card>
|
item={item}
|
||||||
</WidgetContextMenu>
|
width={width}
|
||||||
|
height={height}
|
||||||
|
widgetStateRef={widgetStateRef}
|
||||||
|
innerRef={ref}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
{item.advancedOptions.title?.trim() && (
|
{item.advancedOptions.title?.trim() && (
|
||||||
<Badge
|
<Badge
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
@@ -86,40 +136,72 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
|
||||||
|
innerRef: (element: HTMLDivElement | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
|
||||||
|
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
|
||||||
|
<BoardItemCard item={item} innerRef={innerRef}>
|
||||||
|
<ErrorBoundary
|
||||||
|
resetKeys={[item.kind]}
|
||||||
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
|
<>
|
||||||
|
<BoardItemMenu offset={4} item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||||
|
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</BoardItemCard>
|
||||||
|
</WidgetContextMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface InnerContentProps {
|
interface InnerContentProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||||
|
definition: WidgetDefinition;
|
||||||
|
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const Comp = loadWidgetDynamic(item.kind);
|
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
||||||
const { definition } = widgetImports[item.kind];
|
|
||||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
|
||||||
const newItem = { ...item, options };
|
const newItem = { ...item, options };
|
||||||
const { updateItemOptions } = useItemActions();
|
const { updateItemOptions } = useItemActions();
|
||||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||||
updateItemOptions({ itemId: item.id, newOptions });
|
updateItemOptions({ itemId: item.id, newOptions });
|
||||||
const widgetSupportsIntegrations =
|
const widgetSupportsIntegrations =
|
||||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryErrorResetBoundary>
|
<QueryErrorResetBoundary>
|
||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
removePersistedWidgetQueries(queryClient);
|
removeWidgetDataQueries(queryClient, widgetQueryKeys);
|
||||||
reset();
|
reset();
|
||||||
}}
|
}}
|
||||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
<>
|
<>
|
||||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
<BoardItemMenu
|
||||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
offset={4}
|
||||||
|
item={newItem}
|
||||||
|
definition={definition}
|
||||||
|
resetErrorBoundary={resetErrorBoundary}
|
||||||
|
/>
|
||||||
|
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -131,23 +213,25 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
|||||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<BoardItemMenu offset={4} item={newItem} />
|
<BoardItemMenu offset={4} item={newItem} definition={definition} />
|
||||||
<Comp
|
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
|
||||||
options={options as never}
|
<Component
|
||||||
integrationIds={item.integrationIds}
|
options={options as never}
|
||||||
isEditMode={isEditMode}
|
integrationIds={item.integrationIds}
|
||||||
boardId={board.id}
|
isEditMode={isEditMode}
|
||||||
itemId={item.id}
|
boardId={board.id}
|
||||||
setOptions={(partialNewOptions) =>
|
itemId={item.id}
|
||||||
updateOptions({
|
setOptions={(partialNewOptions) =>
|
||||||
newOptions: {
|
updateOptions({
|
||||||
...options,
|
newOptions: {
|
||||||
...partialNewOptions.newOptions,
|
...options,
|
||||||
},
|
...partialNewOptions.newOptions,
|
||||||
})
|
},
|
||||||
}
|
})
|
||||||
{...dimensions}
|
}
|
||||||
/>
|
{...dimensions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)}
|
)}
|
||||||
</QueryErrorResetBoundary>
|
</QueryErrorResetBoundary>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ActionIcon, Menu } from "@mantine/core";
|
import { ActionIcon, Menu } from "@mantine/core";
|
||||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -8,39 +8,44 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
|||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { useSectionContext } from "../sections/section-context";
|
import { useSectionContext } from "../sections/section-context";
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import { ItemMoveModal } from "./item-move-modal";
|
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
|
||||||
|
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||||
|
|
||||||
interface BoardItemMenuProps {
|
interface BoardItemMenuProps {
|
||||||
offset: number;
|
offset: number;
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
|
definition: WidgetDefinition;
|
||||||
resetErrorBoundary?: () => void;
|
resetErrorBoundary?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
if (!session) return null;
|
const [isEditMode] = useEditMode();
|
||||||
|
if (!session || !isEditMode) return null;
|
||||||
|
|
||||||
return <BoardItemMenuInner {...props} />;
|
return <BoardItemMenuInner {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
|
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||||
const tItem = useScopedI18n("item");
|
const tItem = useScopedI18n("item");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal } = useModalAction(WidgetEditModal);
|
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const [isEditMode] = useEditMode();
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const hasSupportedIntegrations =
|
||||||
|
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||||
useItemActions();
|
useItemActions();
|
||||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
enabled: isMenuOpen && hasSupportedIntegrations,
|
||||||
|
});
|
||||||
const { gridstack } = useSectionContext().refs;
|
const { gridstack } = useSectionContext().refs;
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
|
||||||
@@ -52,12 +57,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
|||||||
}
|
}
|
||||||
}, [item, resetErrorBoundary]);
|
}, [item, resetErrorBoundary]);
|
||||||
|
|
||||||
if (!isEditMode || isPending) return null;
|
|
||||||
|
|
||||||
const openEditModal = () => {
|
const openEditModal = () => {
|
||||||
openModal(
|
openModal(
|
||||||
{
|
{
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
|
definition,
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: item.advancedOptions,
|
advancedOptions: item.advancedOptions,
|
||||||
options: item.options,
|
options: item.options,
|
||||||
@@ -80,10 +84,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
|||||||
},
|
},
|
||||||
integrationData: (integrationData ?? []).filter(
|
integrationData: (integrationData ?? []).filter(
|
||||||
(integration) =>
|
(integration) =>
|
||||||
"supportedIntegrations" in currentDefinition &&
|
"supportedIntegrations" in definition &&
|
||||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||||
),
|
),
|
||||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
integrationSupport: "supportedIntegrations" in definition,
|
||||||
settings,
|
settings,
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
@@ -107,7 +111,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -123,11 +127,19 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown miw={128}>
|
<Menu.Dropdown miw={128}>
|
||||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
<Menu.Item
|
||||||
|
leftSection={<IconPencil size={16} />}
|
||||||
|
onClick={openEditModal}
|
||||||
|
onFocus={preloadWidgetEditModal}
|
||||||
|
onPointerEnter={preloadWidgetEditModal}
|
||||||
|
disabled={hasSupportedIntegrations && isPending}
|
||||||
|
>
|
||||||
{tItem("action.edit")}
|
{tItem("action.edit")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconLayoutKanban size={16} />}
|
leftSection={<IconLayoutKanban size={16} />}
|
||||||
|
onFocus={preloadItemMoveModal}
|
||||||
|
onPointerEnter={preloadItemMoveModal}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!gridstack.current) return;
|
if (!gridstack.current) return;
|
||||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
interface InnerProps {
|
export interface ItemMoveModalProps {
|
||||||
gridStack: GridStack;
|
gridStack: GridStack;
|
||||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerProps }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||||
const form = useZodForm(
|
const form = useZodForm(
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||||
|
|
||||||
|
describe("widget selection lock", () => {
|
||||||
|
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
||||||
|
const lock = { current: false };
|
||||||
|
|
||||||
|
expect(tryLockSelection(lock)).toBe(true);
|
||||||
|
expect(tryLockSelection(lock)).toBe(false);
|
||||||
|
|
||||||
|
unlockSelection(lock);
|
||||||
|
expect(tryLockSelection(lock)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMatchingIntegrationsAsync", () => {
|
||||||
|
it("waits for the shared integration query before a cold integration-backed selection", async () => {
|
||||||
|
const ensureDataAsync = vi.fn(async () => [
|
||||||
|
{ id: "matching", kind: "sonarr" as const },
|
||||||
|
{ id: "other", kind: "radarr" as const },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveMatchingIntegrationsAsync({
|
||||||
|
hasIntegrationSupport: true,
|
||||||
|
supportedIntegrations: ["sonarr"],
|
||||||
|
currentData: undefined,
|
||||||
|
ensureDataAsync,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
|
||||||
|
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not wait for integrations when the widget has no integration support", async () => {
|
||||||
|
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveMatchingIntegrationsAsync({
|
||||||
|
hasIntegrationSupport: false,
|
||||||
|
supportedIntegrations: [],
|
||||||
|
currentData: undefined,
|
||||||
|
ensureDataAsync,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
|
||||||
|
export const tryLockSelection = (lock: { current: boolean }) => {
|
||||||
|
if (lock.current) return false;
|
||||||
|
lock.current = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unlockSelection = (lock: { current: boolean }) => {
|
||||||
|
lock.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({
|
||||||
|
hasIntegrationSupport,
|
||||||
|
supportedIntegrations,
|
||||||
|
currentData,
|
||||||
|
ensureDataAsync,
|
||||||
|
}: {
|
||||||
|
hasIntegrationSupport: boolean;
|
||||||
|
supportedIntegrations: readonly IntegrationKind[];
|
||||||
|
currentData: TIntegration[] | undefined;
|
||||||
|
ensureDataAsync: () => Promise<TIntegration[]>;
|
||||||
|
}) => {
|
||||||
|
if (!hasIntegrationSupport) return [];
|
||||||
|
|
||||||
|
const integrations = currentData ?? (await ensureDataAsync());
|
||||||
|
return integrations.filter((integration) => supportedIntegrations.includes(integration.kind));
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.action {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .action,
|
||||||
|
.card:focus-within .action {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none), (pointer: coarse) {
|
||||||
|
.action {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.action {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +1,65 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import { IconApi } from "@tabler/icons-react";
|
import { IconApi } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createId, objectEntries } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||||
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
|
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||||
|
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||||
|
import classes from "./item-select-modal.module.css";
|
||||||
|
|
||||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
interface ItemSelectModalContentProps {
|
||||||
|
actions: { closeModal: () => void };
|
||||||
|
innerProps: void;
|
||||||
|
integrationData: RouterOutputs["integration"]["all"] | undefined;
|
||||||
|
customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined;
|
||||||
|
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemSelectModalContent = ({
|
||||||
|
actions,
|
||||||
|
integrationData,
|
||||||
|
customWidgetDefs,
|
||||||
|
ensureIntegrationDataAsync,
|
||||||
|
}: ItemSelectModalContentProps) => {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||||
|
const selectionLock = useRef(false);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
|
||||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
|
||||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() =>
|
() =>
|
||||||
objectEntries(widgetImports)
|
widgetKinds
|
||||||
.filter(([kind]) => kind !== "customApi")
|
.filter((kind) => kind !== "customApi")
|
||||||
.map(([kind, value]) => ({
|
.map((kind) => {
|
||||||
kind,
|
return {
|
||||||
supportedIntegrations:
|
kind,
|
||||||
"supportedIntegrations" in value.definition
|
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
|
||||||
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
|
(integration) => integration !== "mock",
|
||||||
: [],
|
),
|
||||||
icon: value.definition.icon,
|
icon: widgetCatalogIcons[kind],
|
||||||
name: t(`widget.${kind}.name`),
|
name: t(`widget.${kind}.name`),
|
||||||
description: t(`widget.${kind}.description`),
|
description: t(`widget.${kind}.description`),
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
.sort((itemA, itemB) => {
|
.sort((itemA, itemB) => {
|
||||||
if (itemA.kind === "app") return -1;
|
if (itemA.kind === "app") return -1;
|
||||||
if (itemB.kind === "app") return 1;
|
if (itemB.kind === "app") return 1;
|
||||||
@@ -66,52 +85,81 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
[customWidgetDefs, search],
|
[customWidgetDefs, search],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddCustomWidget = (definitionId: string) => {
|
const notifyDefinitionLoadError = (error: unknown) => {
|
||||||
const itemId = createId();
|
showErrorNotification({
|
||||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
title: t("common.error"),
|
||||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
message: error instanceof Error ? error.message : String(error),
|
||||||
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
});
|
||||||
actions.closeModal();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = (kind: WidgetKind) => {
|
const handleAddCustomWidget = async (definitionId: string) => {
|
||||||
const definition = widgetImports[kind].definition;
|
if (!tryLockSelection(selectionLock)) return;
|
||||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
setLoadingSelection(`custom:${definitionId}`);
|
||||||
|
try {
|
||||||
|
const definition = await loadWidgetDefinition("customApi");
|
||||||
|
const itemId = createId();
|
||||||
|
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||||
|
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||||
|
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
||||||
|
actions.closeModal();
|
||||||
|
} catch (error) {
|
||||||
|
notifyDefinitionLoadError(error);
|
||||||
|
} finally {
|
||||||
|
unlockSelection(selectionLock);
|
||||||
|
setLoadingSelection(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const matchingIntegrations = hasIntegrationSupport
|
const handleAdd = async (kind: WidgetKind) => {
|
||||||
? (integrationData ?? []).filter((integration) =>
|
if (!tryLockSelection(selectionLock)) return;
|
||||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
setLoadingSelection(kind);
|
||||||
)
|
preloadWidgetEditModal();
|
||||||
: [];
|
try {
|
||||||
|
const definition = await loadWidgetDefinition(kind);
|
||||||
|
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||||
|
|
||||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
|
||||||
const itemId = createId();
|
hasIntegrationSupport,
|
||||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
supportedIntegrations: definition.supportedIntegrations ?? [],
|
||||||
|
currentData: integrationData,
|
||||||
|
ensureDataAsync: ensureIntegrationDataAsync,
|
||||||
|
});
|
||||||
|
|
||||||
createItem({ id: itemId, kind, integrationIds });
|
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||||
actions.closeModal();
|
const itemId = createId();
|
||||||
|
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||||
|
|
||||||
openEditModal(
|
createItem({ id: itemId, kind, integrationIds });
|
||||||
{
|
actions.closeModal();
|
||||||
kind,
|
|
||||||
value: {
|
openEditModal(
|
||||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
{
|
||||||
options: defaultOptions,
|
kind,
|
||||||
integrationIds,
|
definition,
|
||||||
|
value: {
|
||||||
|
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||||
|
options: defaultOptions,
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||||
|
updateItemOptions({ itemId, newOptions: options });
|
||||||
|
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||||
|
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||||
|
},
|
||||||
|
integrationData: matchingIntegrations,
|
||||||
|
integrationSupport: hasIntegrationSupport,
|
||||||
|
settings,
|
||||||
},
|
},
|
||||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
{
|
||||||
updateItemOptions({ itemId, newOptions: options });
|
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
|
||||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
|
||||||
},
|
},
|
||||||
integrationData: matchingIntegrations,
|
);
|
||||||
integrationSupport: hasIntegrationSupport,
|
} catch (error) {
|
||||||
settings,
|
notifyDefinitionLoadError(error);
|
||||||
},
|
} finally {
|
||||||
{
|
unlockSelection(selectionLock);
|
||||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
setLoadingSelection(null);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,9 +168,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
placeholder={`${t("item.create.search")}...`}
|
placeholder={`${t("item.create.search")}...`}
|
||||||
onSearchKeyDown={(event) => {
|
onSearchKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
const [item] = filteredItems;
|
||||||
handleAdd(filteredItems[0]!.kind);
|
if (item) void handleAdd(item.kind);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -130,7 +178,13 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
<WidgetItem
|
<WidgetItem
|
||||||
key={item.kind}
|
key={item.kind}
|
||||||
item={item}
|
item={item}
|
||||||
onSelect={() => handleAdd(item.kind)}
|
onSelect={() => void handleAdd(item.kind)}
|
||||||
|
onIntent={() => {
|
||||||
|
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||||
|
preloadWidgetEditModal();
|
||||||
|
}}
|
||||||
|
disabled={loadingSelection !== null}
|
||||||
|
loading={loadingSelection === item.kind}
|
||||||
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -146,12 +200,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
{filteredCustomWidgets.map((def) => (
|
{filteredCustomWidgets.map((def) => (
|
||||||
<Card
|
<Card
|
||||||
key={def.id}
|
key={def.id}
|
||||||
|
className={classes.card}
|
||||||
h={selectGridCardHeight}
|
h={selectGridCardHeight}
|
||||||
withBorder
|
withBorder
|
||||||
pos="relative"
|
pos="relative"
|
||||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
style={{ overflow: "hidden" }}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
|
||||||
>
|
>
|
||||||
<Stack h="100%" gap="xs">
|
<Stack h="100%" gap="xs">
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
@@ -169,18 +223,24 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box
|
<Box
|
||||||
|
className={classes.action}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
bottom={0}
|
bottom={0}
|
||||||
left={0}
|
left={0}
|
||||||
right={0}
|
right={0}
|
||||||
p="xs"
|
p="xs"
|
||||||
style={{
|
style={{
|
||||||
opacity: "var(--_hover-opacity)",
|
|
||||||
transition: "opacity 150ms ease",
|
|
||||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
|
<Button
|
||||||
|
onClick={() => void handleAddCustomWidget(def.id)}
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
fullWidth
|
||||||
|
disabled={loadingSelection !== null}
|
||||||
|
loading={loadingSelection === `custom:${def.id}`}
|
||||||
|
>
|
||||||
{t("item.create.addToBoard")}
|
{t("item.create.addToBoard")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -196,7 +256,24 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
)}
|
)}
|
||||||
</SelectGridLayout>
|
</SelectGridLayout>
|
||||||
);
|
);
|
||||||
}).withOptions({
|
};
|
||||||
|
|
||||||
|
const ItemSelectModalFrame = (props: Pick<ItemSelectModalContentProps, "actions" | "innerProps">) => {
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||||
|
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemSelectModalContent
|
||||||
|
{...props}
|
||||||
|
integrationData={integrationData}
|
||||||
|
customWidgetDefs={customWidgetDefs}
|
||||||
|
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFrame {...props} />).withOptions({
|
||||||
defaultTitle: (t) => t("item.create.title"),
|
defaultTitle: (t) => t("item.create.title"),
|
||||||
size: modalSizeSelect,
|
size: modalSizeSelect,
|
||||||
});
|
});
|
||||||
@@ -204,6 +281,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
const WidgetItem = ({
|
const WidgetItem = ({
|
||||||
item,
|
item,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onIntent,
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
hasMatchingIntegration,
|
hasMatchingIntegration,
|
||||||
}: {
|
}: {
|
||||||
item: {
|
item: {
|
||||||
@@ -214,23 +294,27 @@ const WidgetItem = ({
|
|||||||
icon: TablerIcon;
|
icon: TablerIcon;
|
||||||
};
|
};
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
onIntent: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
hasMatchingIntegration: boolean;
|
hasMatchingIntegration: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
className={classes.card}
|
||||||
h={selectGridCardHeight}
|
h={selectGridCardHeight}
|
||||||
withBorder
|
withBorder
|
||||||
pos="relative"
|
pos="relative"
|
||||||
style={{
|
style={{
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
"--_hover-opacity": "0",
|
|
||||||
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
||||||
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
onFocus={onIntent}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
onPointerEnter={onIntent}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
>
|
>
|
||||||
<Stack h="100%" gap="xs">
|
<Stack h="100%" gap="xs">
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
@@ -247,18 +331,17 @@ const WidgetItem = ({
|
|||||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box
|
<Box
|
||||||
|
className={classes.action}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
bottom={0}
|
bottom={0}
|
||||||
left={0}
|
left={0}
|
||||||
right={0}
|
right={0}
|
||||||
p="xs"
|
p="xs"
|
||||||
style={{
|
style={{
|
||||||
opacity: "var(--_hover-opacity)",
|
|
||||||
transition: "opacity 150ms ease",
|
|
||||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
|
||||||
{t(`item.create.addToBoard`)}
|
{t(`item.create.addToBoard`)}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, use } from "react";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
|
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||||
|
import { WidgetError } from "@homarr/widgets/errors";
|
||||||
|
|
||||||
|
import type { ItemMoveModalProps } from "./item-move-modal";
|
||||||
|
|
||||||
|
let itemMoveModalPromise: Promise<typeof import("./item-move-modal")> | undefined;
|
||||||
|
|
||||||
|
const loadItemMoveModal = () => {
|
||||||
|
if (itemMoveModalPromise) return itemMoveModalPromise;
|
||||||
|
|
||||||
|
const currentPromise = import("./item-move-modal");
|
||||||
|
itemMoveModalPromise = currentPromise;
|
||||||
|
void currentPromise.catch(() => {
|
||||||
|
if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined;
|
||||||
|
});
|
||||||
|
return currentPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined);
|
||||||
|
|
||||||
|
interface LazyItemMoveModalContentProps {
|
||||||
|
actions: { closeModal: () => void };
|
||||||
|
innerProps: ItemMoveModalProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => {
|
||||||
|
const { ItemMoveModal } = use(loadItemMoveModal());
|
||||||
|
const Component = ItemMoveModal.component;
|
||||||
|
return <Component {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LazyItemMoveModal = createModal<ItemMoveModalProps>((props) => (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||||
|
<Center py="xl">
|
||||||
|
<WidgetError
|
||||||
|
error={error}
|
||||||
|
resetErrorBoundary={() => {
|
||||||
|
itemMoveModalPromise = undefined;
|
||||||
|
resetErrorBoundary();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LazyItemMoveModalContent {...props} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)).withOptions({
|
||||||
|
defaultTitle(t) {
|
||||||
|
return t("item.moveResize.title");
|
||||||
|
},
|
||||||
|
size: modalSizeForm,
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, use } from "react";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||||
|
import { WidgetError } from "@homarr/widgets/errors";
|
||||||
|
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
|
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined;
|
||||||
|
|
||||||
|
export const loadWidgetEditModal = () => {
|
||||||
|
if (widgetEditModalPromise) return widgetEditModalPromise;
|
||||||
|
|
||||||
|
const currentPromise = import("@homarr/widgets/modals");
|
||||||
|
widgetEditModalPromise = currentPromise;
|
||||||
|
void currentPromise.catch(() => {
|
||||||
|
if (widgetEditModalPromise === currentPromise) widgetEditModalPromise = undefined;
|
||||||
|
});
|
||||||
|
return currentPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
||||||
|
|
||||||
|
interface LazyWidgetEditModalContentProps {
|
||||||
|
actions: { closeModal: () => void };
|
||||||
|
innerProps: WidgetEditModalProps<WidgetKind>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||||
|
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||||
|
const Component = WidgetEditModal.component;
|
||||||
|
return <Component {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||||
|
<Center py="xl">
|
||||||
|
<WidgetError
|
||||||
|
error={error}
|
||||||
|
resetErrorBoundary={() => {
|
||||||
|
widgetEditModalPromise = undefined;
|
||||||
|
resetErrorBoundary();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LazyWidgetEditModalContent {...props} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)).withOptions({
|
||||||
|
keepMounted: true,
|
||||||
|
defaultTitle(t) {
|
||||||
|
return t("item.edit.title");
|
||||||
|
},
|
||||||
|
size: modalSizeForm,
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MutableRefObject, ReactNode } from "react";
|
import type { MutableRefObject, ReactNode } from "react";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -25,63 +25,116 @@ import { useSettings } from "@homarr/settings";
|
|||||||
import { translateIfNecessary } from "@homarr/translation";
|
import { translateIfNecessary } from "@homarr/translation";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition";
|
||||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { useSectionContext } from "../sections/section-context";
|
import { useSectionContext } from "../sections/section-context";
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import { ItemMoveModal } from "./item-move-modal";
|
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
|
||||||
|
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||||
|
|
||||||
interface WidgetContextMenuProps {
|
interface WidgetContextMenuProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
|
definition: WidgetDefinition;
|
||||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
|
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
|
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
|
||||||
|
{children}
|
||||||
|
</WidgetContextMenuInner>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const WidgetContextMenuInner = ({
|
||||||
|
item,
|
||||||
|
definition,
|
||||||
|
widgetStateRef,
|
||||||
|
children,
|
||||||
|
settings,
|
||||||
|
}: WidgetContextMenuProps & { settings: ReturnType<typeof useSettings> }) => {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
shadow="md"
|
||||||
|
width={300}
|
||||||
|
closeOnItemClick={false}
|
||||||
|
position="right-start"
|
||||||
|
offset={4}
|
||||||
|
opened={isMenuOpen}
|
||||||
|
onChange={setIsMenuOpen}
|
||||||
|
>
|
||||||
|
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{isMenuOpen && (
|
||||||
|
<WidgetContextMenuDropdown
|
||||||
|
item={item}
|
||||||
|
definition={definition}
|
||||||
|
widgetStateRef={widgetStateRef}
|
||||||
|
settings={settings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> & {
|
||||||
|
settings: ReturnType<typeof useSettings>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const tItem = useScopedI18n("item");
|
const tItem = useScopedI18n("item");
|
||||||
const tMenu = useScopedI18n("item.menu.label");
|
const tMenu = useScopedI18n("item.menu.label");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const settings = useSettings();
|
const hasSupportedIntegrations =
|
||||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||||
const { openModal } = useModalAction(WidgetEditModal);
|
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||||
useItemActions();
|
useItemActions();
|
||||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||||
|
enabled: hasSupportedIntegrations,
|
||||||
|
});
|
||||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
|
||||||
const { gridstack } = useSectionContext().refs;
|
const { gridstack } = useSectionContext().refs;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const widgetQueryKey = useMemo(
|
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||||
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
|
const isWidgetFetching =
|
||||||
[currentDefinition, item.kind],
|
useIsFetching({
|
||||||
);
|
predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
|
||||||
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
|
}) > 0;
|
||||||
const handleRefetch = useCallback(() => {
|
const handleRefetch = useCallback(() => {
|
||||||
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" })));
|
||||||
}, [queryClient, widgetQueryKey]);
|
}, [queryClient, widgetQueryKeys]);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
|
||||||
[item.kind, settings, item.options],
|
[definition, settings, item.options],
|
||||||
);
|
);
|
||||||
|
|
||||||
type OptionDef = { type: string; skipContextMenu?: boolean };
|
type OptionDef = { type: string; skipContextMenu?: boolean };
|
||||||
const toggleOptions = useMemo(() => {
|
const toggleOptions = useMemo(() => {
|
||||||
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||||
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
||||||
}, [currentDefinition, settings]);
|
}, [definition, settings]);
|
||||||
|
|
||||||
const widgetContextActions = useMemo(() => {
|
const widgetContextActions = useMemo(() => {
|
||||||
const def = currentDefinition as Record<string, unknown>;
|
const def = definition as unknown as Record<string, unknown>;
|
||||||
if (typeof def.contextActions !== "function") return [];
|
if (typeof def.contextActions !== "function") return [];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const actions = (def.contextActions as any)({
|
const actions = (def.contextActions as any)({
|
||||||
@@ -94,7 +147,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
widgetStateRef,
|
widgetStateRef,
|
||||||
});
|
});
|
||||||
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
||||||
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
}, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
||||||
|
|
||||||
const persistBoard = useCallback(
|
const persistBoard = useCallback(
|
||||||
(updatedItems: typeof board.items) => {
|
(updatedItems: typeof board.items) => {
|
||||||
@@ -107,6 +160,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
openModal(
|
openModal(
|
||||||
{
|
{
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
|
definition,
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: item.advancedOptions,
|
advancedOptions: item.advancedOptions,
|
||||||
options: item.options,
|
options: item.options,
|
||||||
@@ -133,10 +187,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
},
|
},
|
||||||
integrationData: (integrationData ?? []).filter(
|
integrationData: (integrationData ?? []).filter(
|
||||||
(integration) =>
|
(integration) =>
|
||||||
"supportedIntegrations" in currentDefinition &&
|
"supportedIntegrations" in definition &&
|
||||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||||
),
|
),
|
||||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
integrationSupport: "supportedIntegrations" in definition,
|
||||||
settings,
|
settings,
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
},
|
},
|
||||||
@@ -150,7 +204,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
updateItemAdvancedOptions,
|
updateItemAdvancedOptions,
|
||||||
updateItemIntegrations,
|
updateItemIntegrations,
|
||||||
integrationData,
|
integrationData,
|
||||||
currentDefinition,
|
definition,
|
||||||
settings,
|
settings,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
persistBoard,
|
persistBoard,
|
||||||
@@ -168,135 +222,133 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
[options, item.id, updateItemOptions, persistBoard, board],
|
[options, item.id, updateItemOptions, persistBoard, board],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session) return <>{children}</>;
|
|
||||||
if (!isRightClickEnabled) return <>{children}</>;
|
|
||||||
|
|
||||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
|
<>
|
||||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
{toggleOptions.length > 0 && (
|
||||||
<Menu.Dropdown>
|
|
||||||
{toggleOptions.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Menu.Label>{tMenu("options")}</Menu.Label>
|
|
||||||
{toggleOptions.map(([key]) => (
|
|
||||||
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
|
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
||||||
<Group wrap="nowrap">
|
|
||||||
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
|
||||||
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
|
|
||||||
</Group>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{visibleWidgetActions.length > 0 && (
|
|
||||||
<>
|
|
||||||
{toggleOptions.length > 0 && <Menu.Divider />}
|
|
||||||
<Menu.Label>{tMenu("actions")}</Menu.Label>
|
|
||||||
{visibleWidgetActions.map((action) => {
|
|
||||||
const Icon = action.icon;
|
|
||||||
return (
|
|
||||||
<Menu.Item
|
|
||||||
key={action.key}
|
|
||||||
closeMenuOnClick
|
|
||||||
leftSection={Icon ? <Icon size={16} /> : undefined}
|
|
||||||
onClick={action.onClick}
|
|
||||||
disabled={action.disabled}
|
|
||||||
color={action.color}
|
|
||||||
>
|
|
||||||
{String(translateIfNecessary(t, action.label))}
|
|
||||||
</Menu.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditMode && (
|
|
||||||
<>
|
|
||||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
|
||||||
<Menu.Item
|
|
||||||
closeMenuOnClick
|
|
||||||
leftSection={<IconLayoutKanban size={16} />}
|
|
||||||
onClick={() => {
|
|
||||||
if (!gridstack.current) return;
|
|
||||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tItem("action.moveResize")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
closeMenuOnClick
|
|
||||||
leftSection={<IconCopy size={16} />}
|
|
||||||
onClick={() => duplicateItem({ itemId: item.id })}
|
|
||||||
>
|
|
||||||
{tItem("action.duplicate")}
|
|
||||||
</Menu.Item>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<>
|
<>
|
||||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
<Menu.Label>{tMenu("options")}</Menu.Label>
|
||||||
|
{toggleOptions.map(([key]) => (
|
||||||
|
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
||||||
|
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleWidgetActions.length > 0 && (
|
||||||
|
<>
|
||||||
|
{toggleOptions.length > 0 && <Menu.Divider />}
|
||||||
|
<Menu.Label>{tMenu("actions")}</Menu.Label>
|
||||||
|
{visibleWidgetActions.map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={action.key}
|
||||||
|
closeMenuOnClick
|
||||||
|
leftSection={Icon ? <Icon size={16} /> : undefined}
|
||||||
|
onClick={action.onClick}
|
||||||
|
disabled={action.disabled}
|
||||||
|
color={action.color}
|
||||||
|
>
|
||||||
|
{String(translateIfNecessary(t, action.label))}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditMode && (
|
||||||
|
<>
|
||||||
|
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
closeMenuOnClick
|
||||||
onClick={handleRefetch}
|
leftSection={<IconLayoutKanban size={16} />}
|
||||||
disabled={isWidgetFetching}
|
onFocus={preloadItemMoveModal}
|
||||||
|
onPointerEnter={preloadItemMoveModal}
|
||||||
|
onClick={() => {
|
||||||
|
if (!gridstack.current) return;
|
||||||
|
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
{tItem("action.moveResize")}
|
||||||
{tMenu("refresh")}
|
|
||||||
<WidgetQueryStatus
|
|
||||||
queryClient={queryClient}
|
|
||||||
queryKey={widgetQueryKey}
|
|
||||||
isFetching={isWidgetFetching}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
closeMenuOnClick
|
closeMenuOnClick
|
||||||
leftSection={<IconSettings size={16} />}
|
leftSection={<IconCopy size={16} />}
|
||||||
onClick={openEditModal}
|
onClick={() => duplicateItem({ itemId: item.id })}
|
||||||
disabled={isPending}
|
|
||||||
>
|
>
|
||||||
{tMenu("settings")}
|
{tItem("action.duplicate")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isEditMode && (
|
<>
|
||||||
<>
|
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||||
<Menu.Divider />
|
<Menu.Item
|
||||||
<Menu.Item
|
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||||
closeMenuOnClick
|
onClick={handleRefetch}
|
||||||
color="red"
|
disabled={isWidgetFetching}
|
||||||
leftSection={<IconTrash size={16} />}
|
>
|
||||||
onClick={() => {
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
openConfirmModal({
|
{tMenu("refresh")}
|
||||||
title: tItem("remove.title"),
|
<WidgetQueryStatus
|
||||||
children: tItem("remove.message"),
|
queryClient={queryClient}
|
||||||
onConfirm: () => removeItem({ itemId: item.id }),
|
queryKeys={widgetQueryKeys}
|
||||||
});
|
isFetching={isWidgetFetching}
|
||||||
}}
|
t={t}
|
||||||
>
|
/>
|
||||||
{tItem("action.remove")}
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
<Menu.Item
|
||||||
)}
|
closeMenuOnClick
|
||||||
</Menu.Dropdown>
|
leftSection={<IconSettings size={16} />}
|
||||||
</Menu>
|
onClick={openEditModal}
|
||||||
|
onFocus={preloadWidgetEditModal}
|
||||||
|
onPointerEnter={preloadWidgetEditModal}
|
||||||
|
disabled={hasSupportedIntegrations && isPending}
|
||||||
|
>
|
||||||
|
{tMenu("settings")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
|
||||||
|
{isEditMode && (
|
||||||
|
<>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item
|
||||||
|
closeMenuOnClick
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: tItem("remove.title"),
|
||||||
|
children: tItem("remove.message"),
|
||||||
|
onConfirm: () => removeItem({ itemId: item.id }),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tItem("action.remove")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface WidgetQueryStatusProps {
|
interface WidgetQueryStatusProps {
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
queryKey: QueryKey;
|
queryKeys: QueryKey[];
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
t: TranslationFunction;
|
t: TranslationFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
|
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
|
||||||
const queries = queryClient.getQueryCache().findAll({ queryKey });
|
const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))];
|
||||||
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
||||||
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
||||||
const ageLabel = useTimeAgo(new Date(latest));
|
const ageLabel = useTimeAgo(new Date(latest));
|
||||||
@@ -334,7 +386,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
|
|||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||||
<Text size="xs" c="red.6">
|
<Text size="xs" c="red.6">
|
||||||
{t("item.menu.status.error")} · {ageLabel}
|
{t("item.menu.status.error")}
|
||||||
|
{latest > 0 && ` · ${ageLabel}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -345,7 +398,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
|
|||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{t("item.menu.status.success")} · {ageLabel}
|
{t("item.menu.status.success")}
|
||||||
|
{latest > 0 && ` · ${ageLabel}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import { objectEntries } from "@homarr/common";
|
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
|
||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
|
||||||
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
|
|
||||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
|
||||||
|
|
||||||
export type Translator = {
|
|
||||||
(key: string): string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface HoverMetadataItem {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxMetadataItems = 3;
|
|
||||||
|
|
||||||
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
|
|
||||||
app: true,
|
|
||||||
anchorNote: true,
|
|
||||||
dynamicSelect: true,
|
|
||||||
sortableItemList: true,
|
|
||||||
multiReleasesRepositories: true,
|
|
||||||
umamiEventName: true,
|
|
||||||
umamiEventNames: true,
|
|
||||||
umamiWebsite: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
|
|
||||||
|
|
||||||
type OptionDefinitionWithRules = WidgetOptionDefinition & {
|
|
||||||
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
|
|
||||||
options?: readonly { value: unknown; label: unknown }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
|
|
||||||
|
|
||||||
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
|
|
||||||
const match = definition.options?.find((option) => option.value === value);
|
|
||||||
const label = match?.label;
|
|
||||||
const fallbackLabel = () => String(value);
|
|
||||||
const labelResolvers: Record<string, () => string> = {
|
|
||||||
function: () => (label as (translator: Translator) => string)(t),
|
|
||||||
string: () => label as string,
|
|
||||||
missing: fallbackLabel,
|
|
||||||
};
|
|
||||||
const labelType = typeof label;
|
|
||||||
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
|
|
||||||
return resolveLabel();
|
|
||||||
};
|
|
||||||
|
|
||||||
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
|
|
||||||
switch: (value, _, t) => {
|
|
||||||
const switchLabels: Record<string, string> = {
|
|
||||||
true: t("common.action.show"),
|
|
||||||
false: t("common.action.hide"),
|
|
||||||
};
|
|
||||||
return switchLabels[String(Boolean(value))] ?? null;
|
|
||||||
},
|
|
||||||
text: (value) => {
|
|
||||||
const text = String(value).trim();
|
|
||||||
return text.length > 0 ? text : null;
|
|
||||||
},
|
|
||||||
number: (value) => String(value),
|
|
||||||
slider: (value) => String(value),
|
|
||||||
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
|
|
||||||
multiSelect: (value) => {
|
|
||||||
const items = value as unknown[];
|
|
||||||
return items.length > 0 ? String(items.length) : null;
|
|
||||||
},
|
|
||||||
multiText: (value) => {
|
|
||||||
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
|
|
||||||
return items.length > 0 ? items.join(", ") : null;
|
|
||||||
},
|
|
||||||
location: (value) => {
|
|
||||||
const location = value as { name?: string };
|
|
||||||
const name = location.name?.trim() ?? "";
|
|
||||||
return name.length > 0 ? name : null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatOptionValue = (
|
|
||||||
type: WidgetOptionType,
|
|
||||||
value: unknown,
|
|
||||||
definition: OptionDefinitionWithRules,
|
|
||||||
t: Translator,
|
|
||||||
) => {
|
|
||||||
const formatter = optionTypeFormatters[type];
|
|
||||||
return formatter?.(value, definition, t) ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
|
|
||||||
const labelKey = `widget.${kind}.option.${key}.label`;
|
|
||||||
const translated = t(labelKey);
|
|
||||||
return translated === labelKey ? key : translated;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const collectWidgetHoverMetadata = (
|
|
||||||
item: SectionItem,
|
|
||||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
|
||||||
integrationKinds: IntegrationKind[],
|
|
||||||
t: Translator,
|
|
||||||
): HoverMetadataItem[] => {
|
|
||||||
const { definition } = widgetImports[item.kind];
|
|
||||||
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
|
|
||||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
|
||||||
|
|
||||||
return objectEntries(optionDefinitions)
|
|
||||||
.filter(([key, optionDefinition]) => {
|
|
||||||
const skip = skippedOptionTypes[optionDefinition.type];
|
|
||||||
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
|
|
||||||
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
|
|
||||||
return !skip && !hidden && !unchanged;
|
|
||||||
})
|
|
||||||
.map(([key, optionDefinition]) => {
|
|
||||||
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
|
|
||||||
return {
|
|
||||||
label: getOptionLabel(item.kind, key, t),
|
|
||||||
value: formattedValue ?? "",
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((entry) => entry.value.length > 0)
|
|
||||||
.slice(0, maxMetadataItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DisplayNameSource {
|
|
||||||
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
|
|
||||||
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayNameSources: DisplayNameSource[] = [
|
|
||||||
{
|
|
||||||
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
|
|
||||||
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
matches: (item, options) =>
|
|
||||||
item.kind === "clock" &&
|
|
||||||
Boolean(options.customTitleToggle) &&
|
|
||||||
String(options.customTitle ?? "").trim().length > 0,
|
|
||||||
resolve: (_, options) => String(options.customTitle).trim(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const resolveWidgetDisplayName = (
|
|
||||||
item: SectionItem,
|
|
||||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
|
||||||
t: Translator,
|
|
||||||
) => {
|
|
||||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
|
||||||
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
|
|
||||||
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
|
|
||||||
};
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, use } from "react";
|
||||||
import { Avatar, Text, Tooltip } from "@mantine/core";
|
import { Avatar, Text, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { getIconUrl } from "@homarr/definitions";
|
import { getIconUrl } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { loadWidgetDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import classes from "./widget-hover-overlay.module.css";
|
import classes from "./widget-hover-overlay.module.css";
|
||||||
@@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps {
|
|||||||
|
|
||||||
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
|
|
||||||
|
if (!isEditMode) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoadedWidgetHoverOverlay item={item} integrations={integrations} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { definition } = widgetImports[item.kind];
|
const definition = use(loadWidgetDefinition(item.kind));
|
||||||
const WidgetIcon = definition.icon;
|
const WidgetIcon = definition.icon;
|
||||||
|
|
||||||
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||||
@@ -28,10 +40,6 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
|
|||||||
item.integrationIds.includes(integration.id),
|
item.integrationIds.includes(integration.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isEditMode) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.wrapper}>
|
<div className={classes.wrapper}>
|
||||||
<div className={classes.panel}>
|
<div className={classes.panel}>
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||||
|
|
||||||
describe("removePersistedWidgetQueries", () => {
|
describe("removeWidgetDataQueries", () => {
|
||||||
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
test("removes only the failed widget prefix and allows fresh data to be fetched", async () => {
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||||
|
const sameWidgetQueryKey = [["widget", "mediaServer", "getLibraries"], { input: {} }];
|
||||||
|
const unrelatedWidgetQueryKey = [["widget", "calendar", "getEvents"], { input: {} }];
|
||||||
|
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||||
|
|
||||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||||
|
queryClient.setQueryData(sameWidgetQueryKey, "related");
|
||||||
|
queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget");
|
||||||
|
queryClient.setQueryData(appQueryKey, "app");
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]);
|
||||||
|
|
||||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||||
|
expect(queryClient.getQueryData(sameWidgetQueryKey)).toBeUndefined();
|
||||||
|
expect(queryClient.getQueryData(unrelatedWidgetQueryKey)).toBe("other-widget");
|
||||||
|
expect(queryClient.getQueryData(appQueryKey)).toBe("app");
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||||
await expect(
|
await expect(
|
||||||
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
||||||
).resolves.toBe("fresh");
|
).resolves.toBe("fresh");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("removes persisted queries outside the widget router", () => {
|
test("uses an explicit supporting query prefix without purging other supporting data", () => {
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
||||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||||
@@ -31,14 +40,14 @@ describe("removePersistedWidgetQueries", () => {
|
|||||||
queryClient.setQueryData(appQueryKey, "stale");
|
queryClient.setQueryData(appQueryKey, "stale");
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]);
|
||||||
|
|
||||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
expect(queryClient.getQueryData(appQueryKey)).toBe("stale");
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("removes persisted widget queries when the widget kind and query path differ", () => {
|
test("uses the definition query prefix when the widget kind and query path differ", () => {
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
||||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||||
@@ -46,7 +55,7 @@ describe("removePersistedWidgetQueries", () => {
|
|||||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]);
|
||||||
|
|
||||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => {
|
||||||
|
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
|
||||||
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
|
||||||
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
|
||||||
};
|
};
|
||||||
@@ -105,12 +105,14 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
|||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const openAllInNewTabs = useCallback(async () => {
|
const openAllInNewTabs = useCallback(async () => {
|
||||||
const currentLayoutId = getCurrentLayout(board);
|
const currentLayoutId = getCurrentLayout(board);
|
||||||
const appIds = filterByItemKind(
|
const appIds = (
|
||||||
board.items.filter(
|
await filterByItemKind(
|
||||||
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
board.items.filter(
|
||||||
),
|
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||||
settings,
|
),
|
||||||
"app",
|
settings,
|
||||||
|
"app",
|
||||||
|
)
|
||||||
).map((item) => {
|
).map((item) => {
|
||||||
return item.options.appId;
|
return item.options.appId;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
import type { WidgetComponentProps } from "@homarr/widgets/definition";
|
||||||
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { Item } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
export const filterByItemKind = <TKind extends WidgetKind>(
|
export const filterByItemKind = async <TKind extends WidgetKind>(
|
||||||
items: Item[],
|
items: Item[],
|
||||||
settings: SettingsContextProps,
|
settings: SettingsContextProps,
|
||||||
kind: TKind,
|
kind: TKind,
|
||||||
) => {
|
) => {
|
||||||
|
const definition = await loadWidgetDefinition(kind);
|
||||||
|
|
||||||
return items
|
return items
|
||||||
.filter((item) => item.kind === kind)
|
.filter((item) => item.kind === kind)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
options: reduceWidgetOptionsWithDefaultValues(
|
options: reduceWidgetOptionsWithDefinition(
|
||||||
kind,
|
definition,
|
||||||
settings,
|
settings,
|
||||||
item.options,
|
item.options,
|
||||||
) as WidgetComponentProps<TKind>["options"],
|
) as WidgetComponentProps<TKind>["options"],
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { GridItemHTMLElement } from "@homarr/gridstack";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
|
|
||||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { BoardItemContent } from "../items/item-content";
|
import { BoardItemContent } from "../items/item-content";
|
||||||
@@ -15,7 +17,11 @@ import { useSectionItems } from "./use-section-items";
|
|||||||
|
|
||||||
export const SectionContent = () => {
|
export const SectionContent = () => {
|
||||||
const { innerSections, items, refs } = useSectionContext();
|
const { innerSections, items, refs } = useSectionContext();
|
||||||
const { data: integrations } = clientApi.integration.all.useQuery();
|
const { data: session } = useSession();
|
||||||
|
const [isEditMode] = useEditMode();
|
||||||
|
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
|
||||||
|
enabled: Boolean(session) && isEditMode,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { objectEntries } from "@homarr/common";
|
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import {
|
import {
|
||||||
featuredIntegrations,
|
featuredIntegrations,
|
||||||
@@ -6,30 +5,8 @@ import {
|
|||||||
hiddenFromOnboarding,
|
hiddenFromOnboarding,
|
||||||
integrationDefs,
|
integrationDefs,
|
||||||
integrationKinds,
|
integrationKinds,
|
||||||
|
getWidgetKindsForIntegration,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
|
||||||
|
|
||||||
const buildWidgetsByIntegration = () => {
|
|
||||||
const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record<
|
|
||||||
IntegrationKind,
|
|
||||||
WidgetKind[]
|
|
||||||
>;
|
|
||||||
|
|
||||||
for (const [widgetKind, widget] of objectEntries(widgetImports)) {
|
|
||||||
const supported =
|
|
||||||
"supportedIntegrations" in widget.definition
|
|
||||||
? (widget.definition.supportedIntegrations as IntegrationKind[])
|
|
||||||
: [];
|
|
||||||
for (const kind of supported) {
|
|
||||||
if (kind in map) {
|
|
||||||
map[kind].push(widgetKind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const widgetsByIntegration = buildWidgetsByIntegration();
|
|
||||||
|
|
||||||
export const categoryTranslationKeys: Record<string, string> = {
|
export const categoryTranslationKeys: Record<string, string> = {
|
||||||
dnsHole: "integration.category.dnsHole",
|
dnsHole: "integration.category.dnsHole",
|
||||||
@@ -70,11 +47,11 @@ export interface IntegrationGridItem {
|
|||||||
|
|
||||||
export const buildSortedIntegrations = (
|
export const buildSortedIntegrations = (
|
||||||
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
||||||
): IntegrationGridItem[] =>
|
): IntegrationGridItem[] => {
|
||||||
integrationKinds
|
return integrationKinds
|
||||||
.filter((kind) => {
|
.filter((kind) => {
|
||||||
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
||||||
if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
|
if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false;
|
||||||
if (!options.enableMockIntegration && kind === "mock") return false;
|
if (!options.enableMockIntegration && kind === "mock") return false;
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
@@ -82,9 +59,9 @@ export const buildSortedIntegrations = (
|
|||||||
kind,
|
kind,
|
||||||
name: getIntegrationName(kind),
|
name: getIntegrationName(kind),
|
||||||
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
|
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
|
||||||
widgets: widgetsByIntegration[kind],
|
widgets: getWidgetKindsForIntegration(kind),
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => {
|
.toSorted((left, right) => {
|
||||||
const leftIdx = featuredIntegrations.indexOf(left.kind);
|
const leftIdx = featuredIntegrations.indexOf(left.kind);
|
||||||
const rightIdx = featuredIntegrations.indexOf(right.kind);
|
const rightIdx = featuredIntegrations.indexOf(right.kind);
|
||||||
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
|
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
|
||||||
@@ -92,6 +69,7 @@ export const buildSortedIntegrations = (
|
|||||||
if (rightIdx !== -1) return 1;
|
if (rightIdx !== -1) return 1;
|
||||||
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
|
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
|
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
|
||||||
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
|
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
import { Spotlight } from "@homarr/spotlight";
|
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { ClientBurger } from "./header/burger";
|
import { ClientBurger } from "./header/burger";
|
||||||
@@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
|||||||
import { TourTarget } from "./header/tour-target";
|
import { TourTarget } from "./header/tour-target";
|
||||||
import { UserButton } from "./header/user";
|
import { UserButton } from "./header/user";
|
||||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||||
|
import { LazySpotlight } from "./header/lazy-spotlight";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logo?: ReactNode;
|
logo?: ReactNode;
|
||||||
@@ -37,7 +37,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
|||||||
</TourTarget>
|
</TourTarget>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Spotlight />
|
<LazySpotlight />
|
||||||
</AppShellHeader>
|
</AppShellHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -14,6 +14,9 @@ type HeaderButtonProps = (
|
|||||||
}
|
}
|
||||||
) & {
|
) & {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onPointerDown?: () => void;
|
||||||
|
onPointerEnter?: () => void;
|
||||||
} & Partial<ActionIconProps>;
|
} & Partial<ActionIconProps>;
|
||||||
|
|
||||||
const headerButtonActionIconProps: ActionIconProps = {
|
const headerButtonActionIconProps: ActionIconProps = {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { Center, Loader, Paper } from "@mantine/core";
|
||||||
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
|
|
||||||
|
import { hotkeys } from "@homarr/definitions";
|
||||||
|
import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open";
|
||||||
|
|
||||||
|
const loadSpotlight = () => import("@homarr/spotlight/component");
|
||||||
|
const SpotlightLoading = () => (
|
||||||
|
<Center
|
||||||
|
data-homarr-dev-benchmark-spotlight-feedback
|
||||||
|
pos="fixed"
|
||||||
|
inset={0}
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading search"
|
||||||
|
style={{ zIndex: 1000, background: "rgb(0 0 0 / 20%)", backdropFilter: "blur(2px)" }}
|
||||||
|
>
|
||||||
|
<Paper withBorder radius="md" p="xl" shadow="xl">
|
||||||
|
<Loader />
|
||||||
|
</Paper>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), {
|
||||||
|
ssr: false,
|
||||||
|
loading: SpotlightLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined);
|
||||||
|
|
||||||
|
export const LazySpotlight = () => {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [isPreloaded, setIsPreloaded] = useState(false);
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
isMounted
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
[
|
||||||
|
hotkeys.openSpotlight,
|
||||||
|
() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
openSpotlight();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isActive = true;
|
||||||
|
const mount = () => setIsMounted(true);
|
||||||
|
window.addEventListener(spotlightOpenEvent, mount);
|
||||||
|
if (hasPendingSpotlightOpen()) mount();
|
||||||
|
|
||||||
|
const preload = () => {
|
||||||
|
void loadSpotlight().then(
|
||||||
|
() => {
|
||||||
|
if (isActive) setIsPreloaded(true);
|
||||||
|
},
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let idleId: number | undefined;
|
||||||
|
const delayId = window.setTimeout(() => {
|
||||||
|
idleId = window.requestIdleCallback?.(preload, { timeout: 1_000 });
|
||||||
|
if (idleId === undefined) preload();
|
||||||
|
}, 1_000);
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
window.removeEventListener(spotlightOpenEvent, mount);
|
||||||
|
window.clearTimeout(delayId);
|
||||||
|
if (idleId !== undefined) window.cancelIdleCallback?.(idleId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMounted || isPreloaded ? (
|
||||||
|
<>
|
||||||
|
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||||
|
{isMounted && (
|
||||||
|
<>
|
||||||
|
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
|
||||||
|
<Spotlight />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
@@ -3,23 +3,28 @@
|
|||||||
import { TextInput, UnstyledButton } from "@mantine/core";
|
import { TextInput, UnstyledButton } from "@mantine/core";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { openSpotlight } from "@homarr/spotlight";
|
import { openSpotlight } from "@homarr/spotlight/open";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { HeaderButton } from "./button";
|
import { HeaderButton } from "./button";
|
||||||
import classes from "./search.module.css";
|
import classes from "./search.module.css";
|
||||||
|
import { preloadSpotlight } from "./lazy-spotlight";
|
||||||
|
|
||||||
export const DesktopSearchInput = () => {
|
export const DesktopSearchInput = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
data-homarr-dev-benchmark-interaction="search"
|
||||||
component={UnstyledButton}
|
component={UnstyledButton}
|
||||||
className={classes.desktopSearch}
|
className={classes.desktopSearch}
|
||||||
w={400}
|
w={400}
|
||||||
size="sm"
|
size="sm"
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
onClick={openSpotlight}
|
onClick={openSpotlight}
|
||||||
|
onFocus={preloadSpotlight}
|
||||||
|
onPointerDown={preloadSpotlight}
|
||||||
|
onPointerEnter={preloadSpotlight}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
>
|
>
|
||||||
{`${t("search.placeholder")}...`}
|
{`${t("search.placeholder")}...`}
|
||||||
@@ -29,7 +34,14 @@ export const DesktopSearchInput = () => {
|
|||||||
|
|
||||||
export const MobileSearchButton = () => {
|
export const MobileSearchButton = () => {
|
||||||
return (
|
return (
|
||||||
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch}>
|
<HeaderButton
|
||||||
|
data-homarr-dev-benchmark-interaction="search"
|
||||||
|
onClick={openSpotlight}
|
||||||
|
onFocus={preloadSpotlight}
|
||||||
|
onPointerDown={preloadSpotlight}
|
||||||
|
onPointerEnter={preloadSpotlight}
|
||||||
|
className={classes.mobileSearch}
|
||||||
|
>
|
||||||
<IconSearch size={20} stroke={1.5} />
|
<IconSearch size={20} stroke={1.5} />
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { act, createElement } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { TourTarget, TourTargetsProvider } from "./tour-target";
|
||||||
|
|
||||||
|
vi.mock("@gfazioli/mantine-onboarding-tour", () => ({
|
||||||
|
OnboardingTour: {
|
||||||
|
Target: ({ children }: { children: ReactNode }) => children,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mountedRoots: Array<ReturnType<typeof createRoot>> = [];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => undefined,
|
||||||
|
removeListener: () => undefined,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
for (const root of mountedRoots.splice(0)) root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderTargetAsync = async (enabled: boolean) => {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
const root = createRoot(host);
|
||||||
|
mountedRoots.push(root);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
createElement(
|
||||||
|
MantineProvider,
|
||||||
|
null,
|
||||||
|
createElement(TourTargetsProvider, {
|
||||||
|
enabled,
|
||||||
|
children: createElement(TourTarget, { id: "example", children: createElement("button", null, "Target") }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return host;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("TourTarget", () => {
|
||||||
|
it("keeps a stable route-transition marker while a tour is active", async () => {
|
||||||
|
const host = await renderTargetAsync(true);
|
||||||
|
expect(host.querySelector('[data-tour-target="example"] button')?.textContent).toBe("Target");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds no wrapper for users without an active tour", async () => {
|
||||||
|
const host = await renderTargetAsync(false);
|
||||||
|
expect(host.querySelector("[data-tour-target]")).toBeNull();
|
||||||
|
expect(host.querySelector("button")?.parentElement).toBe(host);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user