Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb0222c04d | ||
|
|
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,7 @@ 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.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,5 @@
|
|||||||
|
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||||
|
|
||||||
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
## [1.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)
|
||||||
|
|||||||
+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"]
|
||||||
@@ -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 |
|
||||||
@@ -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.74.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 |
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ tags:
|
|||||||
- Ping
|
- Ping
|
||||||
---
|
---
|
||||||
|
|
||||||
Apps are the building blocks of your Homarr board.
|
Apps are the building blocks of your Homarr board.
|
||||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||||
Apps are used in the bookmark and app widget.
|
Apps are used in the bookmark and app widget.
|
||||||
|
|
||||||
## Create an app
|
## Create an app
|
||||||
@@ -27,7 +27,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. Direct image URLs are 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.
|
||||||
|
|
||||||
@@ -94,51 +106,65 @@ 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.
|
||||||
@@ -196,7 +229,7 @@ Both actions will execute the same deletion process.
|
|||||||
## Duplicate boards
|
## Duplicate boards
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
Duplicating a board will not copy the users and groups that have access to the board
|
Duplicating a board will not copy the users and groups that have access to the board
|
||||||
nor the integrations the user does not have access to.
|
nor the integrations the user does not have access to.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -255,4 +292,4 @@ For the global home board you can only select boards that are public.
|
|||||||
### Mobile home board
|
### Mobile home board
|
||||||
|
|
||||||
Same as the home board, you can also set a mobile home board.
|
Same as the home board, you can also set a mobile home board.
|
||||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||||
@@ -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.
|
||||||
+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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
import { createContext, Suspense, use, useContext } from "react";
|
||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
|
const TourTargetsEnabledContext = createContext(false);
|
||||||
<OnboardingTour.Target id={id}>
|
let tourLibraryPromise: Promise<typeof import("@gfazioli/mantine-onboarding-tour")> | undefined;
|
||||||
<Box data-tour-target={id}>{children}</Box>
|
|
||||||
</OnboardingTour.Target>
|
const loadTourLibrary = () => {
|
||||||
|
if (tourLibraryPromise) return tourLibraryPromise;
|
||||||
|
|
||||||
|
const currentPromise = import("@gfazioli/mantine-onboarding-tour");
|
||||||
|
tourLibraryPromise = currentPromise;
|
||||||
|
void currentPromise.catch(() => {
|
||||||
|
if (tourLibraryPromise === currentPromise) tourLibraryPromise = undefined;
|
||||||
|
});
|
||||||
|
return currentPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TourTargetsProvider = ({ enabled = true, children }: PropsWithChildren<{ enabled?: boolean }>) => (
|
||||||
|
<TourTargetsEnabledContext value={enabled}>{children}</TourTargetsEnabledContext>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const LoadedTourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
|
||||||
|
const { OnboardingTour } = use(loadTourLibrary());
|
||||||
|
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
|
||||||
|
const isEnabled = useContext(TourTargetsEnabledContext);
|
||||||
|
if (!isEnabled) return children;
|
||||||
|
|
||||||
|
const target = <Box data-tour-target={id}>{children}</Box>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={target}>
|
||||||
|
<LoadedTourTarget id={id}>{target}</LoadedTourTarget>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { Suspense, use } from "react";
|
|
||||||
import { Indicator, Menu, Text } from "@mantine/core";
|
import { Indicator, Menu, Text } from "@mantine/core";
|
||||||
import { IconBellRinging } from "@tabler/icons-react";
|
import { IconBellRinging } from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -9,67 +8,28 @@ import type { RouterOutputs } from "@homarr/api";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface UpdateIndicatorProps extends PropsWithChildren {
|
interface UpdateIndicatorProps extends PropsWithChildren {
|
||||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
|
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => {
|
export const UpdateIndicator = ({ children, availableUpdates, disabled }: UpdateIndicatorProps) => {
|
||||||
if (disabled || availableUpdatesPromise === undefined) {
|
if (disabled || availableUpdates === undefined) {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={children}>
|
<Indicator disabled={availableUpdates.length === 0} size={15} processing withBorder>
|
||||||
<InnerUpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={disabled}>
|
|
||||||
{children}
|
|
||||||
</InnerUpdateIndicator>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface InnerUpdateIndicatorProps extends PropsWithChildren {
|
|
||||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
|
||||||
disabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InnerUpdateIndicator = ({ children, disabled, availableUpdatesPromise }: InnerUpdateIndicatorProps) => {
|
|
||||||
const availableUpdates = use(availableUpdatesPromise);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Indicator
|
|
||||||
disabled={!availableUpdates || availableUpdates.length === 0 || disabled}
|
|
||||||
size={15}
|
|
||||||
processing
|
|
||||||
withBorder
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Indicator>
|
</Indicator>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AvailableUpdatesMenuItemProps {
|
interface AvailableUpdatesMenuItemProps {
|
||||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
|
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: AvailableUpdatesMenuItemProps) => {
|
export const AvailableUpdatesMenuItem = ({ availableUpdates }: AvailableUpdatesMenuItemProps) => {
|
||||||
if (availableUpdatesPromise === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<InnerAvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface InnerAvailableUpdatesMenuItemProps {
|
|
||||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InnerAvailableUpdatesMenuItem = ({ availableUpdatesPromise }: InnerAvailableUpdatesMenuItemProps) => {
|
|
||||||
const t = useScopedI18n("common.userAvatar.menu");
|
const t = useScopedI18n("common.userAvatar.menu");
|
||||||
const availableUpdates = use(availableUpdatesPromise);
|
|
||||||
if (availableUpdates === undefined || availableUpdates.length === 0) {
|
if (availableUpdates === undefined || availableUpdates.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import { Suspense, use, useEffect, useState } from "react";
|
||||||
|
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||||
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import { hotkeys } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { UpdateIndicator } from "./update";
|
||||||
|
|
||||||
|
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
|
||||||
|
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
|
||||||
|
|
||||||
|
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
|
||||||
|
const loadUserAvatarMenu = () => {
|
||||||
|
if (userAvatarMenuPromise) return userAvatarMenuPromise;
|
||||||
|
|
||||||
|
const promise = import("~/components/user-avatar-menu");
|
||||||
|
userAvatarMenuPromise = promise;
|
||||||
|
void promise.catch(() => {
|
||||||
|
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
|
||||||
|
});
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
|
||||||
|
|
||||||
|
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
|
||||||
|
const { UserAvatarMenu } = use(loadUserAvatarMenu());
|
||||||
|
return <UserAvatarMenu {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UserButtonClientProps {
|
||||||
|
avatar: ReactNode;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isDockerEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
||||||
|
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
||||||
|
const [isMenuMounted, setIsMenuMounted] = useState(false);
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const session = useSession();
|
||||||
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
|
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||||
|
const isCurrentSessionAdmin =
|
||||||
|
isAdmin && session.status === "authenticated" && session.data.user.permissions.includes("admin");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCurrentSessionAdmin) {
|
||||||
|
setCanCheckForUpdates(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => setCanCheckForUpdates(true), 2_000);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [isCurrentSessionAdmin]);
|
||||||
|
|
||||||
|
const { data: availableUpdates } = clientApi.updateChecker.getAvailableUpdates.useQuery(undefined, {
|
||||||
|
enabled: canCheckForUpdates,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 60 * 60 * 1_000,
|
||||||
|
});
|
||||||
|
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
||||||
|
const openMenu = () => {
|
||||||
|
setIsMenuMounted(true);
|
||||||
|
setIsMenuOpen(true);
|
||||||
|
};
|
||||||
|
const renderButton = (onClick: () => void, isLoading = false) => (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={onClick}
|
||||||
|
onFocus={preloadUserAvatarMenu}
|
||||||
|
onMouseEnter={preloadUserAvatarMenu}
|
||||||
|
onPointerDown={preloadUserAvatarMenu}
|
||||||
|
aria-busy={isLoading || undefined}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||||
|
<Box pos="relative" style={{ display: "inline-flex" }}>
|
||||||
|
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
|
||||||
|
{isLoading && (
|
||||||
|
<Loader
|
||||||
|
aria-label="Loading user menu"
|
||||||
|
size={18}
|
||||||
|
pos="absolute"
|
||||||
|
top="50%"
|
||||||
|
left="50%"
|
||||||
|
style={{ transform: "translate(-50%, -50%)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</UpdateIndicator>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
const button = renderButton(openMenu);
|
||||||
|
const loadingButton = renderButton(() => undefined, true);
|
||||||
|
|
||||||
|
if (!isMenuMounted) return button;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallbackRender={({ resetErrorBoundary }) =>
|
||||||
|
renderButton(() => {
|
||||||
|
resetErrorBoundary();
|
||||||
|
openMenu();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Suspense fallback={loadingButton}>
|
||||||
|
<LazyUserAvatarMenu
|
||||||
|
availableUpdates={visibleUpdates}
|
||||||
|
isDockerEnabled={isDockerEnabled}
|
||||||
|
opened={isMenuOpen}
|
||||||
|
onOpenChange={setIsMenuOpen}
|
||||||
|
>
|
||||||
|
{button}
|
||||||
|
</LazyUserAvatarMenu>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,28 +1,14 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import { UnstyledButton } from "@mantine/core";
|
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
|
|
||||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
import { UserButtonClient } from "./user-client";
|
||||||
import { UpdateIndicator } from "./update";
|
|
||||||
|
|
||||||
export const UserButton = async () => {
|
export const UserButton = async () => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const isAdmin = session?.user.permissions.includes("admin");
|
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
||||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
|
||||||
return (
|
return (
|
||||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
|
||||||
<UnstyledButton>
|
|
||||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
|
||||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
|
||||||
<CurrentUserAvatar size="md" />
|
|
||||||
</UpdateIndicator>
|
|
||||||
</Suspense>
|
|
||||||
</UnstyledButton>
|
|
||||||
</UserAvatarMenu>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
|
||||||
import { NavLink } from "@mantine/core";
|
import { NavLink } from "@mantine/core";
|
||||||
|
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { TourTarget } from "./header/tour-target";
|
||||||
|
|
||||||
export const CommonNavLink = (props: ClientNavigationLink) =>
|
export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||||
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
||||||
|
|
||||||
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
|
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
|
||||||
if (!id) return <>{children}</>;
|
id ? <TourTarget id={id}>{children}</TourTarget> : children;
|
||||||
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
|
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
|||||||
active={isClient && pathMatches(pathname, props.href)}
|
active={isClient && pathMatches(pathname, props.href)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return <TourTarget id={tourId}>{link}</TourTarget>;
|
return withOptionalTourTarget(tourId, link);
|
||||||
};
|
};
|
||||||
|
|
||||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||||
@@ -56,7 +55,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
|||||||
))}
|
))}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
|
return withOptionalTourTarget(props["data-onboarding-tour-id"], nav);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CommonNavigationLinkProps {
|
interface CommonNavigationLinkProps {
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface DangerZoneRootProps {
|
interface DangerZoneRootProps {
|
||||||
children: React.ReactNode[] | React.ReactNode;
|
children: React.ReactNode[] | React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
|
export const DangerZoneRoot = ({ children }: DangerZoneRootProps) => {
|
||||||
const t = await getI18n();
|
const t = useScopedI18n("common");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Title c="red.8" order={2}>
|
<Title c="red.8" order={2}>
|
||||||
{t("common.dangerZone")}
|
{t("dangerZone")}
|
||||||
</Title>
|
</Title>
|
||||||
<Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
|
<Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Card, Divider, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
interface SectionCardProps {
|
||||||
|
title: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SectionCard = ({ title, children }: SectionCardProps) => {
|
||||||
|
return (
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Title order={3}>{title}</Title>
|
||||||
|
<Divider />
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Card, Group, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
interface UnsavedChangesBarProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnsavedChangesBar = ({ children }: UnsavedChangesBarProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "sticky", bottom: 20 }}>
|
||||||
|
<Card withBorder>
|
||||||
|
<Group justify="space-between" wrap="wrap">
|
||||||
|
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||||
|
<Group>{children}</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export const BoardTourGate = async ({ enabled, children }: PropsWithChildren<{ enabled: boolean }>) => {
|
||||||
|
if (!enabled) return children;
|
||||||
|
|
||||||
|
// Native server-side import keeps the completed-user client graph lean while
|
||||||
|
// giving first-time users one stable board tree instead of swapping a
|
||||||
|
// Suspense fallback after GridStack has mounted.
|
||||||
|
const { BoardTourProvider } = await import("./board-tour");
|
||||||
|
return <BoardTourProvider>{children}</BoardTourProvider>;
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -10,6 +10,7 @@ import { createDocumentationLink } from "@homarr/definitions";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
|
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
|
||||||
import { TourShell } from "./tour-shell";
|
import { TourShell } from "./tour-shell";
|
||||||
import { TourStepContent } from "./tour-step-content";
|
import { TourStepContent } from "./tour-step-content";
|
||||||
|
|
||||||
@@ -17,21 +18,11 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const t = useScopedI18n("onboardingTour.board");
|
const t = useScopedI18n("onboardingTour.board");
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
const utils = clientApi.useUtils();
|
const [started, setStarted] = useState(true);
|
||||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
|
||||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
void utils.user.getTourStatus.invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
|
|
||||||
|
|
||||||
const handleEnd = () => {
|
const handleEnd = () => {
|
||||||
utils.user.getTourStatus.setData(undefined, {
|
setStarted(false);
|
||||||
completedManageTour: tourStatus?.completedManageTour ?? false,
|
|
||||||
completedBoardTour: true,
|
|
||||||
});
|
|
||||||
completeTour({ tour: "board" });
|
completeTour({ tour: "board" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,8 +92,10 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
|||||||
}, [hasChangeAccess, t]);
|
}, [hasChangeAccess, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
<TourTargetsProvider enabled={started}>
|
||||||
{children}
|
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
||||||
</TourShell>
|
{children}
|
||||||
|
</TourShell>
|
||||||
|
</TourTargetsProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export const ManageTourGate = async ({
|
||||||
|
enabled,
|
||||||
|
isAdmin,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ enabled: boolean; isAdmin: boolean }>) => {
|
||||||
|
if (!enabled) return children;
|
||||||
|
|
||||||
|
const { ManageTourProvider } = await import("./manage-tour");
|
||||||
|
return <ManageTourProvider isAdmin={isAdmin}>{children}</ManageTourProvider>;
|
||||||
|
};
|
||||||
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user