Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae2a1df453 | ||
|
|
ca7e50fb96 | ||
|
|
db6aa0b26e | ||
|
|
d096aecd59 | ||
|
|
49a41431b9 | ||
|
|
df587ec76a | ||
|
|
3b450eb04a | ||
|
|
406fb4c0b6 | ||
|
|
f23c35645c | ||
|
|
667bf62396 | ||
|
|
c5a056b8e5 | ||
|
|
934b6e69f4 | ||
|
|
c8c5dbc506 | ||
|
|
b1e79e4641 | ||
|
|
5b04ed1ab5 | ||
|
|
b733ed6a79 | ||
|
|
ae3e9f7793 | ||
|
|
c8485ed7fc | ||
|
|
e97eb7fc58 |
No files matched your search
+1
-2
@@ -17,8 +17,7 @@ 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,7 +33,6 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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"
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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 --concurrency=2
|
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
|
||||||
|
|
||||||
- name: Validate workspace configuration
|
- name: Validate workspace configuration
|
||||||
run: pnpm lint:ws
|
run: pnpm lint:ws
|
||||||
|
|||||||
@@ -78,4 +78,3 @@ skills-lock.json
|
|||||||
apps/docs/.docusaurus
|
apps/docs/.docusaurus
|
||||||
apps/docs/build
|
apps/docs/build
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
benchmark-results
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
## [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)
|
||||||
|
|||||||
+8
-19
@@ -3,18 +3,12 @@
|
|||||||
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
|
||||||
# Native dependencies normally use prebuilds, but the build must remain
|
RUN apk add --no-cache libc6-compat curl bash && apk update
|
||||||
# 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 .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY 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
|
||||||
@@ -25,16 +19,14 @@ COPY --parents ./packages/definitions/src ./
|
|||||||
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 --ignore-scripts && \
|
pnpm fetch && \
|
||||||
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
||||||
|
|
||||||
# Install only from the fetched, committed lockfile so local and Docker builds
|
|
||||||
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
|
|
||||||
# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
|
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
|
||||||
npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
||||||
|
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||||
|
pnpm install --recursive
|
||||||
|
|
||||||
ARG SKIP_ENV_VALIDATION='true'
|
ARG SKIP_ENV_VALIDATION='true'
|
||||||
ARG CI='true'
|
ARG CI='true'
|
||||||
@@ -45,9 +37,7 @@ 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 \
|
||||||
--mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \
|
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
|
||||||
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
|
||||||
@@ -95,6 +85,5 @@ 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"]
|
||||||
@@ -14,8 +14,8 @@ This guide will help you set up your development environment to start contributi
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/).
|
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
|
||||||
- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm version.
|
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
|
||||||
- **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**: Required for the Redis service and the developer Docker CLI.
|
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
|
||||||
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
|
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
|
||||||
@@ -49,10 +49,7 @@ This guide will help you set up your development environment to start contributi
|
|||||||
|
|
||||||
#### During development
|
#### During development
|
||||||
|
|
||||||
- `pnpm dev`: Start the Next.js development server.
|
- `pnpm dev`: Start the 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 -- 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 <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 -- build --pr <number>`: Build a pull request locally from a temporary checkout.
|
||||||
@@ -77,5 +74,4 @@ This guide will help you set up your development environment to start contributi
|
|||||||
|
|
||||||
- `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.
|
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
|
||||||
- `docker build -t homarr .`: Build a docker image for the project.
|
|
||||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
|
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
|
||||||
@@ -25,14 +25,15 @@ 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 Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
|
| ``NO_EXTERNAL_CONNECTION`` | Disables some requests that need internet connection | `true` or `false` | `false` |
|
||||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
| ``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
|
||||||
|
|
||||||
@@ -42,9 +43,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`
|
||||||
@@ -52,27 +53,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_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_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` |
|
| ``DB_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` |
|
||||||
| `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` |
|
| ``DB_HOST`` | Database host to connect to. | Any valid database host | - |
|
||||||
| `DB_HOST` | Database host to connect to. | Any valid database host | - |
|
| ``DB_PORT`` | Database port to connect to. | Any valid database port | - |
|
||||||
| `DB_PORT` | Database port to connect to. | Any valid database port | - |
|
| ``DB_NAME`` | Database name to connect to. | Any valid database name | - |
|
||||||
| `DB_NAME` | Database name to connect to. | Any valid database name | - |
|
| ``DB_USER`` | Database user to connect with. | Any valid database user | - |
|
||||||
| `DB_USER` | Database user to connect with. | Any valid database user | - |
|
| ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - |
|
||||||
| `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).
|
||||||
@@ -82,23 +83,25 @@ 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
|
||||||
|
|
||||||
@@ -106,6 +109,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,6 +47,7 @@ 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.74.0"` | Overrides the image tag whose default is the chart appVersion |
|
| image.tag | string | `"v1.73.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 |
|
||||||
|
|||||||
@@ -40,14 +40,11 @@ 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,29 +24,24 @@ 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 friends.
|
This can be really useful if you want to have public boards in your company or for family and fiends.
|
||||||
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:
|
||||||
|
|
||||||

|

|
||||||
@@ -55,12 +50,8 @@ 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:
|
||||||
|
|
||||||

|

|
||||||
@@ -68,7 +59,6 @@ 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:
|
||||||
|
|
||||||
@@ -77,12 +67,10 @@ 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.
|
||||||
|
|
||||||
@@ -107,64 +95,50 @@ 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 minimum of the next layout is reached the layout will change.
|
Once the minmum 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.
|
||||||
|
|
||||||
@@ -173,16 +147,14 @@ 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 |
|
||||||
@@ -201,27 +173,22 @@ 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" section and click on the "delete this board" button.
|
- Navigate to the settings of your board, open the "Danger zone" dropdown 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.
|
||||||
@@ -239,51 +206,47 @@ 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`
|
- ``my-homarr.com/boards``
|
||||||
- `my-homarr.com/boards`
|
|
||||||
|
|
||||||
Your home board will have a special small badge at the top right in the list of boards:
|
Your home board will have a special small badge at the top right in the list of boards:
|
||||||
|
|
||||||

|

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

|

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

|

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

|

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

|

|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
For the global home board you can only select boards that are public.
|
For the global home board you can only select boards that are public.
|
||||||
|
|||||||
@@ -9,127 +9,97 @@ tags:
|
|||||||
---
|
---
|
||||||
|
|
||||||
# Settings
|
# Settings
|
||||||
|
The settings page has a few options that will be applied to the entire Homarr application.
|
||||||
The settings page applies options to the whole Homarr application.
|
They may require a full page reload for changes to apply.
|
||||||
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.
|
||||||
|
|
||||||
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
|
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
|
||||||
We collect this data to see what features people ACTUALLY use.
|
|
||||||
We use this data to decide where we spend our development time.
|
|
||||||
|
|
||||||
We do not sell the data. We do not share the data with third parties.
|
We understand that you might not want Homarr to communicate with our services.
|
||||||
The data does not contain personal information and does not identify you or your installation.
|
By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
|
||||||
|
If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall.
|
||||||
|
|
||||||
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
|
Additionally, you opt in to more data collection to help us provide more useful data.
|
||||||
The server sends the data once a week and when the server starts.
|
|
||||||
|
|
||||||
### What we collect
|
### Integration data
|
||||||
|
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.
|
||||||
|
|
||||||
Homarr collects technical data about your installation:
|
### Widget data
|
||||||
|
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.
|
||||||
|
|
||||||
- Your Homarr version
|
### Users data
|
||||||
- Your database type (SQLite, MySQL, or PostgreSQL)
|
Enabling this optional data will send the count of users and whether you activated SSO.
|
||||||
- Whether Docker or Kubernetes is enabled
|
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
|
||||||
- Your enabled authentication providers
|
This is disabled by default.
|
||||||
- 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.
|
||||||
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
|
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
|
||||||
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
|
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
|
||||||
|
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
|
||||||
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
|
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.
|
||||||
|
|
||||||
### No index
|
### No index
|
||||||
|
This will ask crawlers to not index your Homarr instance on any indexes.
|
||||||
This setting asks crawlers to not index your instance.
|
It is up to the crawler to properly respect this request.
|
||||||
This setting is enabled by default. Disable this setting to allow indexing.
|
By default, this setting is enabled. Disabling it will 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.
|
||||||
Crawlers follow links to discover new pages.
|
By default, this setting is enabled and will ask crawlers to not follow any links.
|
||||||
This setting asks crawlers to not follow links. This setting is enabled by default.
|
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
|
||||||
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.
|
||||||
Some browsers offer to translate pages.
|
This can lead to some shifting and can be undesired in some cases. By enabling this,
|
||||||
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
|
you ask crawlers to never translate and never prompt users for translation.
|
||||||
|
It should be noted that some browsers will ignore this and translate regardless.
|
||||||
|
|
||||||
### No site links search box
|
### No site links search box
|
||||||
|
This setting is exclusive to Google.
|
||||||
This setting applies to Google only.
|
[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".
|
||||||
Google can show a search box below your domain.
|
The user can search them directly in Google.
|
||||||
This setting asks Google to not show this box. This setting is disabled by default.
|
You can ask the crawlers to not allow searches by enabling this setting.
|
||||||
|
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.
|
||||||
You can set a global home board.
|
It will also be used for anonymous users.
|
||||||
This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
|
Note that the board must be public to be used as a global home board.
|
||||||
The board must be public.
|
|
||||||
|
|
||||||
### Global mobile board
|
### Global mobile board
|
||||||
|
|
||||||
You can set a 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).
|
||||||
Homarr shows this board on mobile devices instead of the global home board.
|
It will similar to the global home board be as fallback for normal users and for any anonymous users.
|
||||||
Homarr detects mobile devices with the User-Agent header.
|
It also has to be public.
|
||||||
The board must be public.
|
|
||||||
|
|
||||||
### App status
|
### App status
|
||||||
|
|
||||||
#### Enable status by default
|
#### Enable status by default
|
||||||
|
|
||||||
You can decide if app status is enabled when you add a new app.
|
You can decide if the app status should be enabled by default when adding a new app to the board.
|
||||||
|
|
||||||
#### Force disable status
|
#### Force disable status
|
||||||
|
|
||||||
You can decide if app status is disabled on all boards.
|
You can decide if the app status should be disabled for 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.
|
|
||||||
+11
-22
@@ -1,6 +1,4 @@
|
|||||||
// 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";
|
||||||
@@ -15,21 +13,7 @@ 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",
|
||||||
},
|
},
|
||||||
@@ -46,14 +30,19 @@ 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"],
|
||||||
turbopackFileSystemCacheForBuild: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
useTypeScriptCli: true,
|
webpackMemoryOptimizations: true,
|
||||||
},
|
},
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: path.resolve(import.meta.dirname, "../.."),
|
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
|
||||||
// Development runs tasks and WebSocket as separate processes. These aliases
|
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
|
||||||
// keep their production-only instrumentation imports out of the dev graph.
|
// (see vercel/next.js#95125). Suppress until upstream fix lands.
|
||||||
resolveAlias: getDevelopmentServiceAliases(),
|
ignoreIssue: [
|
||||||
|
{
|
||||||
|
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,10 +59,11 @@
|
|||||||
"@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/react-devtools": "catalog:",
|
"@tanstack/query-async-storage-persister": "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:",
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
"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} />
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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),
|
||||||
|
});
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
// @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());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient } 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,
|
||||||
@@ -23,24 +25,18 @@ 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 {
|
||||||
dashboardSupportingQueryPolicies,
|
isPersistableWidgetQueryKey,
|
||||||
|
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 { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||||
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") {
|
||||||
@@ -63,46 +59,21 @@ const constructWebsocketUrl = () => {
|
|||||||
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
|
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TRPCReactProvider({ children }: PropsWithChildren) {
|
const wsClient = createWSClient({
|
||||||
const { data: session } = useSession();
|
url: constructWebsocketUrl(),
|
||||||
const sessionQueryScope = getSessionQueryScope(session);
|
});
|
||||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
|
||||||
|
|
||||||
return (
|
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||||
<SessionQueryScopeGuard
|
const [persister] = useState(() => createWidgetQueryPersister());
|
||||||
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,
|
||||||
retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3),
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: 3,
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
onError(error) {
|
onError(error) {
|
||||||
@@ -120,29 +91,23 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
client.setQueryDefaults([["widget"]], {
|
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
||||||
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
for (const { definition } of Object.values(widgetImports)) {
|
||||||
refetchOnWindowFocus: false,
|
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
|
||||||
refetchOnReconnect: false,
|
if (def.refetchInterval === undefined) continue;
|
||||||
});
|
const key = def.queryKey ?? [["widget", def.kind]];
|
||||||
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
|
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
|
||||||
client.setQueryDefaults(queryKey, {
|
client.setQueryDefaults(key, { refetchInterval: interval });
|
||||||
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
|
|
||||||
client.setQueryDefaults(queryKey, policy);
|
|
||||||
}
|
}
|
||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
const [trpcClient] = useState(() => {
|
||||||
|
|
||||||
const trpcClient = useMemo(() => {
|
|
||||||
return clientApi.createClient({
|
return clientApi.createClient({
|
||||||
links: [
|
links: [
|
||||||
loggerLink({
|
loggerLink({
|
||||||
enabled: (opts) => opts.direction === "down" && opts.result instanceof Error,
|
enabled: (opts) =>
|
||||||
|
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||||
}),
|
}),
|
||||||
splitLink({
|
splitLink({
|
||||||
condition: ({ type }) => type === "subscription",
|
condition: ({ type }) => type === "subscription",
|
||||||
@@ -182,14 +147,25 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}, [wsClient]);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
client={queryClient}
|
||||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
persistOptions={{
|
||||||
</QueryClientProvider>
|
persister,
|
||||||
|
buster: queryCacheBuster,
|
||||||
|
maxAge: queryCacheDefaultGcTimeMs,
|
||||||
|
dehydrateOptions: {
|
||||||
|
shouldDehydrateQuery: (query) =>
|
||||||
|
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</PersistQueryClientProvider>
|
||||||
</clientApi.Provider>
|
</clientApi.Provider>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
import { api } from "@homarr/api/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 getHomeBoardAsync();
|
return await api.board.getHomeBoard();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
import { api } from "@homarr/api/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 getBoardByNameAsync(name);
|
return await api.board.getBoardByName({ name });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"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")
|
||||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
<Box h="100%" pos="relative">
|
||||||
<BoardBackgroundVideo />
|
<BoardBackgroundVideo />
|
||||||
<Stack h="100%">
|
<Stack h="100%">
|
||||||
{fullWidthSortedSections.map((section) =>
|
{fullWidthSortedSections.map((section) =>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cache } from "react";
|
import { Suspense } 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 { makeQueryClient } from "@homarr/api/shared";
|
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { queryCacheBuster } from "@homarr/api/query-cache";
|
||||||
|
import { getQueryClient } from "@homarr/api/server";
|
||||||
import { 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";
|
||||||
@@ -14,6 +17,7 @@ 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";
|
||||||
|
|
||||||
@@ -25,7 +29,6 @@ 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>;
|
||||||
|
|
||||||
@@ -58,6 +61,7 @@ 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]) =>
|
||||||
@@ -73,12 +77,19 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const userId = session?.user.id ?? "anonymous";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<>
|
||||||
<IntegrationProvider integrations={integrations}>
|
<Suspense>
|
||||||
<DynamicClientBoard />
|
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||||
</IntegrationProvider>
|
</Suspense>
|
||||||
</HydrationBoundary>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<IntegrationProvider integrations={integrations}>
|
||||||
|
<DynamicClientBoard />
|
||||||
|
</IntegrationProvider>
|
||||||
|
</HydrationBoundary>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||||
@@ -109,3 +120,19 @@ 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,24 +1,7 @@
|
|||||||
"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,36 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||||
|
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
|
import {
|
||||||
|
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 } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } 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();
|
||||||
@@ -43,16 +52,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEditMode && <BoardAddMenu />}
|
{isEditMode && <AddMenu />}
|
||||||
|
|
||||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||||
|
|
||||||
{!demoReadOnly && (
|
{!demoReadOnly && (
|
||||||
<TourTarget id="board-settings">
|
<OnboardingTour.Target 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>
|
||||||
</TourTarget>
|
</OnboardingTour.Target>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SelectBoardsMenu />
|
<SelectBoardsMenu />
|
||||||
@@ -60,6 +69,94 @@ 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();
|
||||||
@@ -100,29 +197,21 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
|||||||
usePreventLeaveWithDirty(isEditMode);
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TourTarget id="board-edit-mode">
|
<OnboardingTour.Target id="board-edit-mode">
|
||||||
<HeaderButton
|
<HeaderButton onClick={toggle} loading={isPending}>
|
||||||
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>
|
||||||
</TourTarget>
|
</OnboardingTour.Target>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SelectBoardsMenu = () => {
|
const SelectBoardsMenu = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
|
||||||
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TourTarget id="board-switcher">
|
<OnboardingTour.Target id="board-switcher">
|
||||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
<Box>
|
||||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
<Menu position="bottom-end">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<HeaderButton w="auto" px={4}>
|
<HeaderButton w="auto" px={4}>
|
||||||
<IconReplace stroke={1.5} />
|
<IconReplace stroke={1.5} />
|
||||||
@@ -130,11 +219,6 @@ 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}
|
||||||
@@ -149,7 +233,7 @@ const SelectBoardsMenu = () => {
|
|||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
</TourTarget>
|
</OnboardingTour.Target>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,13 +246,15 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDirty) return;
|
if (!isDirty) return;
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: Event) => {
|
||||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
const target = (event.target as HTMLElement).closest("a");
|
||||||
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
|
|
||||||
if (!target) return;
|
if (!target) {
|
||||||
|
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"),
|
||||||
@@ -194,14 +280,19 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|||||||
event.returnValue = true;
|
event.returnValue = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("click", handleClick, true);
|
const anchors = document.querySelectorAll(anchorSelector);
|
||||||
|
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 () => {
|
||||||
document.removeEventListener("click", handleClick, true);
|
anchors.forEach((link) => {
|
||||||
|
link.removeEventListener("click", handleClick);
|
||||||
|
});
|
||||||
window.removeEventListener("popstate", handlePopState);
|
window.removeEventListener("popstate", handlePopState);
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
};
|
};
|
||||||
}, [isDirty, openConfirmModal, router, t]);
|
}, [isDirty]);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
import { api } from "@homarr/api/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 getBoardByNameAsync(name);
|
return await api.board.getBoardByName({ name });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -19,106 +19,130 @@ import {
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { IconX } from "@tabler/icons-react";
|
import { IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import type { Board } from "../../_types";
|
||||||
import { generateColors } from "../../(content)/_theme";
|
import { generateColors } from "../../(content)/_theme";
|
||||||
import type { FormValues } from "./_settings-form";
|
import { useSavePartialSettingsMutation } from "./_shared";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
form: UseFormReturnType<FormValues>;
|
board: Board;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = ({ form }: Props) => {
|
export const ColorSettingsContent = ({ board }: Props) => {
|
||||||
|
const form = useZodForm(boardSavePartialSettingsSchema, {
|
||||||
|
initialValues: {
|
||||||
|
primaryColor: board.primaryColor,
|
||||||
|
secondaryColor: board.secondaryColor,
|
||||||
|
opacity: board.opacity,
|
||||||
|
iconColor: board.iconColor ?? "",
|
||||||
|
itemRadius: board.itemRadius,
|
||||||
|
},
|
||||||
|
});
|
||||||
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 (
|
||||||
<SectionCard title={t("board.setting.section.appearance.title")}>
|
<form
|
||||||
<Grid>
|
onSubmit={form.onSubmit((values) => {
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
savePartialSettings({
|
||||||
<Stack gap="xs">
|
id: board.id,
|
||||||
<ColorInput
|
...values,
|
||||||
label={t("board.field.primaryColor.label")}
|
});
|
||||||
format="hex"
|
})}
|
||||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
>
|
||||||
{...form.getInputProps("primaryColor")}
|
<Stack>
|
||||||
/>
|
<Grid>
|
||||||
</Stack>
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
</Grid.Col>
|
<Stack gap="xs">
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
<ColorInput
|
||||||
<ColorInput
|
label={t("board.field.primaryColor.label")}
|
||||||
label={t("board.field.secondaryColor.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("primaryColor")}
|
||||||
{...form.getInputProps("secondaryColor")}
|
/>
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={12}>
|
|
||||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={12}>
|
|
||||||
<Collapse expanded={showPreview}>
|
|
||||||
<Stack>
|
|
||||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
|
||||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Collapse>
|
</Grid.Col>
|
||||||
</Grid.Col>
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
|
||||||
<InputWrapper label={t("board.field.opacity.label")}>
|
|
||||||
<Slider
|
|
||||||
my={6}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
label={progressPercentageLabel}
|
|
||||||
{...form.getInputProps("opacity")}
|
|
||||||
/>
|
|
||||||
</InputWrapper>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
|
||||||
<Group align="end">
|
|
||||||
<ColorInput
|
<ColorInput
|
||||||
label={t("board.field.iconColor.label")}
|
label={t("board.field.secondaryColor.label")}
|
||||||
format="hex"
|
format="hex"
|
||||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||||
flex={1}
|
{...form.getInputProps("secondaryColor")}
|
||||||
{...form.getInputProps("iconColor")}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
</Grid.Col>
|
||||||
type="button"
|
<Grid.Col span={12}>
|
||||||
variant="subtle"
|
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||||
leftSection={<IconX />}
|
</Grid.Col>
|
||||||
onClick={() => form.setFieldValue("iconColor", "")}
|
<Grid.Col span={12}>
|
||||||
disabled={!form.values.iconColor}
|
<Collapse expanded={showPreview}>
|
||||||
>
|
<Stack>
|
||||||
{t("board.field.clearColor.label")}
|
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||||
</Button>
|
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||||
</Group>
|
</Stack>
|
||||||
</Grid.Col>
|
</Collapse>
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
</Grid.Col>
|
||||||
<Select
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
label={t("board.field.itemRadius.label")}
|
<InputWrapper label={t("board.field.opacity.label")}>
|
||||||
description={t("board.field.itemRadius.description")}
|
<Slider
|
||||||
data={[
|
my={6}
|
||||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
min={0}
|
||||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
max={100}
|
||||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
step={5}
|
||||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
label={progressPercentageLabel}
|
||||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
{...form.getInputProps("opacity")}
|
||||||
]}
|
/>
|
||||||
{...form.getInputProps("itemRadius")}
|
</InputWrapper>
|
||||||
/>
|
</Grid.Col>
|
||||||
</Grid.Col>
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
</Grid>
|
<Group align="end">
|
||||||
</SectionCard>
|
<ColorInput
|
||||||
|
label={t("board.field.iconColor.label")}
|
||||||
|
format="hex"
|
||||||
|
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||||
|
flex={1}
|
||||||
|
{...form.getInputProps("iconColor")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
leftSection={<IconX />}
|
||||||
|
onClick={() => form.setFieldValue("iconColor", "")}
|
||||||
|
disabled={!form.values.iconColor}
|
||||||
|
>
|
||||||
|
{t("board.field.clearColor.label")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
|
<Select
|
||||||
|
label={t("board.field.itemRadius.label")}
|
||||||
|
description={t("board.field.itemRadius.description")}
|
||||||
|
data={[
|
||||||
|
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||||
|
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||||
|
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||||
|
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||||
|
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||||
|
]}
|
||||||
|
{...form.getInputProps("itemRadius")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { startTransition } from "react";
|
import { startTransition } from "react";
|
||||||
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Autocomplete, Button, 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 type { UseFormReturnType } from "@homarr/form";
|
import { useZodForm } 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 { SectionCard } from "~/components/manage/section-card";
|
import type { Board } from "../../_types";
|
||||||
import type { FormValues } from "./_settings-form";
|
import { useSavePartialSettingsMutation } from "./_shared";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
form: UseFormReturnType<FormValues>;
|
board: Board;
|
||||||
}
|
}
|
||||||
|
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,
|
||||||
@@ -44,106 +53,121 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
|||||||
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={t("board.setting.section.background.title")}>
|
<form
|
||||||
<Grid>
|
onSubmit={form.onSubmit((values) => {
|
||||||
<Grid.Col span={12}>
|
savePartialSettings({
|
||||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
id: board.id,
|
||||||
<Autocomplete
|
...values,
|
||||||
flex={1}
|
});
|
||||||
leftSection={
|
})}
|
||||||
form.values.backgroundImageUrl &&
|
>
|
||||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
<Stack>
|
||||||
<Popover width={300} withArrow>
|
<Grid>
|
||||||
<Popover.Target>
|
<Grid.Col span={12}>
|
||||||
<Center h="100%">
|
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
<Autocomplete
|
||||||
</Center>
|
flex={1}
|
||||||
</Popover.Target>
|
leftSection={
|
||||||
<Popover.Dropdown>
|
form.values.backgroundImageUrl &&
|
||||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||||
</Popover.Dropdown>
|
<Popover width={300} withArrow>
|
||||||
</Popover>
|
<Popover.Target>
|
||||||
)
|
<Center h="100%">
|
||||||
}
|
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||||
// We filter it on the server
|
</Center>
|
||||||
filter={({ options }) => options}
|
</Popover.Target>
|
||||||
label={t("board.field.backgroundImageUrl.label")}
|
<Popover.Dropdown>
|
||||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||||
renderOption={({ option }) => {
|
</Popover.Dropdown>
|
||||||
const current = imageMap.get(option.value);
|
</Popover>
|
||||||
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 }) => (
|
{
|
||||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
group: t("board.field.backgroundImageUrl.group.your"),
|
||||||
<IconUpload size={16} stroke={1.5} />
|
items: images
|
||||||
</ActionIcon>
|
.filter((media) => media.creatorId === session?.user.id)
|
||||||
)}
|
.map((media) => `/api/user-medias/${media.id}`),
|
||||||
</UploadMedia>
|
},
|
||||||
)}
|
{
|
||||||
</Group>
|
group: t("board.field.backgroundImageUrl.group.other"),
|
||||||
</Grid.Col>
|
items: images
|
||||||
<Grid.Col span={12}>
|
.filter((media) => media.creatorId !== session?.user.id)
|
||||||
<SelectWithDescriptionBadge
|
.map((media) => `/api/user-medias/${media.id}`),
|
||||||
label={t("board.field.backgroundImageAttachment.label")}
|
},
|
||||||
data={backgroundImageAttachmentData}
|
]}
|
||||||
{...form.getInputProps("backgroundImageAttachment")}
|
{...form.getInputProps("backgroundImageUrl")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
{session?.user.permissions.includes("media-upload") && (
|
||||||
<Grid.Col span={12}>
|
<UploadMedia
|
||||||
<SelectWithDescriptionBadge
|
onSuccess={(medias) => {
|
||||||
label={t("board.field.backgroundImageSize.label")}
|
const first = medias.at(0);
|
||||||
data={backgroundImageSizeData}
|
if (!first) return;
|
||||||
{...form.getInputProps("backgroundImageSize")}
|
|
||||||
/>
|
startTransition(() => {
|
||||||
</Grid.Col>
|
form.setFieldValue("backgroundImageUrl", first.url);
|
||||||
<Grid.Col span={12}>
|
});
|
||||||
<SelectWithDescriptionBadge
|
}}
|
||||||
label={t("board.field.backgroundImageRepeat.label")}
|
>
|
||||||
data={backgroundImageRepeatData}
|
{({ onClick, loading }) => (
|
||||||
{...form.getInputProps("backgroundImageRepeat")}
|
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||||
/>
|
<IconUpload size={16} stroke={1.5} />
|
||||||
</Grid.Col>
|
</ActionIcon>
|
||||||
</Grid>
|
)}
|
||||||
</SectionCard>
|
</UploadMedia>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<SelectWithDescriptionBadge
|
||||||
|
label={t("board.field.backgroundImageAttachment.label")}
|
||||||
|
data={backgroundImageAttachmentData}
|
||||||
|
{...form.getInputProps("backgroundImageAttachment")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<SelectWithDescriptionBadge
|
||||||
|
label={t("board.field.backgroundImageSize.label")}
|
||||||
|
data={backgroundImageSizeData}
|
||||||
|
{...form.getInputProps("backgroundImageSize")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<SelectWithDescriptionBadge
|
||||||
|
label={t("board.field.backgroundImageRepeat.label")}
|
||||||
|
data={backgroundImageRepeatData}
|
||||||
|
{...form.getInputProps("backgroundImageRepeat")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,48 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Switch } from "@mantine/core";
|
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||||
|
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import { useForm } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import type { Board } from "../../_types";
|
||||||
import type { FormValues } from "./_settings-form";
|
import { useSavePartialSettingsMutation } from "./_shared";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
form: UseFormReturnType<FormValues>;
|
board: Board;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BehaviorSettingsContent = ({ form }: Props) => {
|
export const BehaviorSettingsContent = ({ board }: Props) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
disableStatus: board.disableStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={t("board.setting.section.behavior.title")}>
|
<form
|
||||||
<Switch
|
onSubmit={form.onSubmit((values) => {
|
||||||
label={t("board.field.disableStatus.label")}
|
savePartialSettings({
|
||||||
description={t("board.field.disableStatus.description")}
|
id: board.id,
|
||||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
...values,
|
||||||
/>
|
});
|
||||||
</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, Input } from "@mantine/core";
|
import { Alert, Button, Group, Input, Stack } 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,39 +8,59 @@ import "~/styles/prismjs.scss";
|
|||||||
|
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import { useForm } from "@homarr/form";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import type { Board } from "../../_types";
|
||||||
import type { FormValues } from "./_settings-form";
|
import { useSavePartialSettingsMutation } from "./_shared";
|
||||||
import classes from "./customcss.module.css";
|
import classes from "./customcss.module.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
form: UseFormReturnType<FormValues>;
|
board: Board;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomCssSettingsContent = ({ form }: Props) => {
|
export const CustomCssSettingsContent = ({ board }: 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 (
|
||||||
<SectionCard title={t("board.setting.section.customCss.title")}>
|
<form
|
||||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
onSubmit={form.onSubmit((values) => {
|
||||||
|
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, error }: CustomCssInputProps) => {
|
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||||
const customCssT = useScopedI18n("board.field.customCss");
|
const customCssT = useScopedI18n("board.field.customCss");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +70,6 @@ const CustomCssInput = ({ value, onChange, error }: 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 } from "@mantine/core";
|
import { Button, Divider, Group, Stack, Text } 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 { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
import classes from "./danger.module.css";
|
||||||
|
|
||||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
@@ -82,36 +82,60 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
|||||||
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DangerZoneRoot>
|
<Stack gap="sm">
|
||||||
<DangerZoneItem
|
<Divider />
|
||||||
|
<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")}
|
||||||
action={
|
buttonText={t("section.dangerZone.action.rename.button")}
|
||||||
<Button variant="subtle" color="red" onClick={onRenameClick}>
|
onClick={onRenameClick}
|
||||||
{t("section.dangerZone.action.rename.button")}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{!hideVisibility && (
|
{hideVisibility ? null : (
|
||||||
<DangerZoneItem
|
<>
|
||||||
label={t("section.dangerZone.action.visibility.label")}
|
<Divider />
|
||||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
<DangerZoneRow
|
||||||
action={
|
label={t("section.dangerZone.action.visibility.label")}
|
||||||
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
|
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||||
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||||
</Button>
|
onClick={onVisibilityClick}
|
||||||
}
|
isPending={isChangeVisibilityPending}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<DangerZoneItem
|
<Divider />
|
||||||
|
<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")}
|
||||||
action={
|
buttonText={t("section.dangerZone.action.delete.button")}
|
||||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
onClick={onDeleteClick}
|
||||||
{t("section.dangerZone.action.delete.button")}
|
isPending={isDeletePending}
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</DangerZoneRoot>
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DangerZoneRowProps {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
buttonText: string;
|
||||||
|
isPending?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw="bold" size="sm">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{description}</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||||
|
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,66 +1,128 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Grid, Loader, TextInput } from "@mantine/core";
|
import { Button, Grid, Group, Loader, Stack, 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 type { UseFormReturnType } from "@homarr/form";
|
import { useZodForm } 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 { SectionCard } from "~/components/manage/section-card";
|
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
import type { FormValues } from "./_settings-form";
|
import { useSavePartialSettingsMutation } from "./_shared";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
board: Board;
|
board: Board;
|
||||||
form: UseFormReturnType<FormValues>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
export const GeneralSettingsContent = ({ board }: 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 (
|
||||||
<SectionCard title={t("board.setting.section.general.title")}>
|
<form
|
||||||
<Grid>
|
onSubmit={form.onSubmit((values) => {
|
||||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
// Save the current values to the ref so that it does not reset if the form is submitted
|
||||||
<TextInput
|
ref.current = {
|
||||||
label={t("board.field.pageTitle.label")}
|
pageTitle: values.pageTitle,
|
||||||
placeholder="Homarr"
|
logoImageUrl: values.logoImageUrl,
|
||||||
{...form.getInputProps("pageTitle")}
|
};
|
||||||
/>
|
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||||
</Grid.Col>
|
savePartialSettings({
|
||||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
id: board.id,
|
||||||
<TextInput
|
...values,
|
||||||
label={t("board.field.metaTitle.label")}
|
});
|
||||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
})}
|
||||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
>
|
||||||
{...form.getInputProps("metaTitle")}
|
<Stack>
|
||||||
/>
|
<Grid>
|
||||||
</Grid.Col>
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
<TextInput
|
||||||
<IconPicker
|
label={t("board.field.pageTitle.label")}
|
||||||
{...form.getInputProps("logoImageUrl")}
|
placeholder="Homarr"
|
||||||
label={t("board.field.logoImageUrl.label")}
|
{...form.getInputProps("pageTitle")}
|
||||||
placeholder="/logo/logo.png"
|
/>
|
||||||
withAsterisk={false}
|
</Grid.Col>
|
||||||
/>
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
</Grid.Col>
|
<TextInput
|
||||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
label={t("board.field.metaTitle.label")}
|
||||||
<IconPicker
|
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||||
{...form.getInputProps("faviconImageUrl")}
|
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||||
label={t("board.field.faviconImageUrl.label")}
|
{...form.getInputProps("metaTitle")}
|
||||||
placeholder="/logo/logo.png"
|
/>
|
||||||
withAsterisk={false}
|
</Grid.Col>
|
||||||
/>
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
</Grid.Col>
|
<IconPicker
|
||||||
</Grid>
|
{...form.getInputProps("logoImageUrl")}
|
||||||
</SectionCard>
|
label={t("board.field.logoImageUrl.label")}
|
||||||
|
placeholder="/logo/logo.png"
|
||||||
|
withAsterisk={false}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
|
<IconPicker
|
||||||
|
{...form.getInputProps("faviconImageUrl")}
|
||||||
|
label={t("board.field.faviconImageUrl.label")}
|
||||||
|
placeholder="/logo/logo.png"
|
||||||
|
withAsterisk={false}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,3 +146,14 @@ 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,79 +2,114 @@
|
|||||||
|
|
||||||
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 type { UseFormReturnType } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import type { Board } from "../../_types";
|
||||||
import type { FormValues } from "./_settings-form";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
form: UseFormReturnType<FormValues>;
|
board: Board;
|
||||||
}
|
}
|
||||||
|
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 (
|
||||||
<SectionCard title={t("board.setting.section.layout.title")}>
|
<form
|
||||||
<Stack gap="sm">
|
onSubmit={form.onSubmit((values) => {
|
||||||
<Group justify="space-between" align="center">
|
saveLayouts({
|
||||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
id: board.id,
|
||||||
<Button
|
...values,
|
||||||
type="button"
|
});
|
||||||
variant="subtle"
|
})}
|
||||||
onClick={() => {
|
>
|
||||||
form.setValues((previous) => ({
|
<Stack>
|
||||||
...previous,
|
<Stack gap="sm">
|
||||||
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
|
<Group justify="space-between" align="center">
|
||||||
}));
|
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||||
}}
|
<Button
|
||||||
>
|
variant="subtle"
|
||||||
{t("board.setting.section.layout.responsive.action.add")}
|
onClick={() => {
|
||||||
|
form.setValues({
|
||||||
|
layouts: [
|
||||||
|
...form.values.layouts,
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
name: "",
|
||||||
|
columnCount: 10,
|
||||||
|
breakpoint: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("board.setting.section.layout.responsive.action.add")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{form.values.layouts.map((layout, index) => (
|
||||||
|
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
|
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
|
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||||
|
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||||
|
</Input.Wrapper>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
|
<NumberInput
|
||||||
|
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||||
|
label={t("layout.field.breakpoint.label")}
|
||||||
|
description={t("layout.field.breakpoint.description")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
{form.values.layouts.length >= 2 && (
|
||||||
|
<Group justify="end">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => {
|
||||||
|
form.setValues((previous) =>
|
||||||
|
previous.layouts !== undefined && previous.layouts.length >= 2
|
||||||
|
? {
|
||||||
|
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||||
|
}
|
||||||
|
: previous,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.action.remove")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Fieldset>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
</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>
|
||||||
</SectionCard>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
"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,13 +11,3 @@ 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();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@media (min-width: 36em) {
|
||||||
|
.dangerZoneGroup {
|
||||||
|
--group-wrap: nowrap !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,44 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Container, Stack, Title } from "@mantine/core";
|
import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconBrush,
|
||||||
|
IconClick,
|
||||||
|
IconFileTypeCss,
|
||||||
|
IconLayout,
|
||||||
|
IconPhoto,
|
||||||
|
IconSettings,
|
||||||
|
IconUser,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import { 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 { BoardSettingsForm } from "./_settings-form";
|
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||||
|
import { ColorSettingsContent } from "./_appereance";
|
||||||
|
import { BackgroundSettingsContent } from "./_background";
|
||||||
|
import { BehaviorSettingsContent } from "./_behavior";
|
||||||
|
import { BoardAccessSettings } from "./_board-access";
|
||||||
|
import { CustomCssSettingsContent } from "./_customCss";
|
||||||
|
import { DangerZoneSettingsContent } from "./_danger";
|
||||||
|
import { GeneralSettingsContent } from "./_general";
|
||||||
|
import { LayoutSettingsContent } from "./_layout";
|
||||||
|
|
||||||
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"]>) => {
|
||||||
@@ -33,7 +57,11 @@ 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" || error.code === "BAD_REQUEST")) {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +70,7 @@ 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");
|
||||||
@@ -56,13 +85,76 @@ 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>
|
||||||
<BoardSettingsForm
|
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
|
||||||
board={board}
|
<AccordionItemFor value="general" icon={IconSettings}>
|
||||||
permissions={permissions}
|
<GeneralSettingsContent board={board} />
|
||||||
hasFullAccess={hasFullAccess}
|
</AccordionItemFor>
|
||||||
hideVisibility={boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id}
|
<AccordionItemFor value="layout" icon={IconLayout}>
|
||||||
/>
|
<LayoutSettingsContent board={board} />
|
||||||
|
</AccordionItemFor>
|
||||||
|
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||||
|
<BackgroundSettingsContent board={board} />
|
||||||
|
</AccordionItemFor>
|
||||||
|
<AccordionItemFor value="appearance" icon={IconBrush}>
|
||||||
|
<ColorSettingsContent board={board} />
|
||||||
|
</AccordionItemFor>
|
||||||
|
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||||
|
<CustomCssSettingsContent board={board} />
|
||||||
|
</AccordionItemFor>
|
||||||
|
<AccordionItemFor value="behavior" icon={IconClick}>
|
||||||
|
<BehaviorSettingsContent board={board} />
|
||||||
|
</AccordionItemFor>
|
||||||
|
{hasFullAccess && (
|
||||||
|
<>
|
||||||
|
<AccordionItemFor value="access" icon={IconUser}>
|
||||||
|
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||||
|
</AccordionItemFor>
|
||||||
|
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||||
|
<DangerZoneSettingsContent
|
||||||
|
hideVisibility={
|
||||||
|
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AccordionItemFor>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ActiveTabAccordion>
|
||||||
</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,7 +3,6 @@ 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";
|
||||||
@@ -12,8 +11,7 @@ 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 { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
||||||
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";
|
||||||
@@ -23,6 +21,11 @@ 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>;
|
||||||
@@ -40,32 +43,8 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
}: PropsWithChildren<{
|
}: PropsWithChildren<{
|
||||||
params: Promise<TParams>;
|
params: Promise<TParams>;
|
||||||
}>) => {
|
}>) => {
|
||||||
const resolvedParams = await params;
|
const session = await auth();
|
||||||
const sessionPromise = auth();
|
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||||
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");
|
||||||
@@ -81,8 +60,8 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
});
|
||||||
const initialBoard = initialBoardResult.board;
|
const colorScheme = await getCurrentColorSchemeAsync();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardProvider initialBoard={initialBoard}>
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
@@ -90,7 +69,7 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
<EditModeProvider>
|
<EditModeProvider>
|
||||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<CustomCss />
|
<CustomCss />
|
||||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||||
<ClientShell hasNavigation={false}>
|
<ClientShell hasNavigation={false}>
|
||||||
<MainHeader
|
<MainHeader
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
@@ -99,7 +78,7 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
/>
|
/>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
</ClientShell>
|
</ClientShell>
|
||||||
</BoardTourGate>
|
</BoardTourWrapper>
|
||||||
</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,16 +29,9 @@ 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 getCurrentOnboardingStepAsync();
|
const currentStep = await api.onboard.currentStep();
|
||||||
|
|
||||||
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 { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
import { api } from "@homarr/api/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 { createLogger } from "@homarr/core/infrastructure/logs";
|
import { db } from "@homarr/db";
|
||||||
|
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,8 +43,6 @@ 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",
|
||||||
@@ -80,27 +78,15 @@ export default async function Layout(props: {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ locale: SupportedLanguage }>;
|
params: Promise<{ locale: SupportedLanguage }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await props.params;
|
if (!isLocaleSupported((await props.params).locale)) {
|
||||||
if (!isLocaleSupported(locale)) {
|
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionPromise = auth();
|
const session = await auth();
|
||||||
const userPromise = sessionPromise.then((session) =>
|
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
||||||
session
|
const serverSettings = await getServerSettingsAsync(db);
|
||||||
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
const colorScheme = await getCurrentColorSchemeAsync();
|
||||||
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
|
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||||
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) => {
|
||||||
@@ -139,6 +125,8 @@ 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,10 +27,8 @@ 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";
|
||||||
@@ -41,29 +39,11 @@ 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 { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
|
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
||||||
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 sessionPromise = auth();
|
const t = await getScopedI18n("management.navbar");
|
||||||
const shouldRunManageTourPromise = sessionPromise.then(async (session) => {
|
const session = await auth();
|
||||||
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"),
|
||||||
@@ -245,9 +225,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
</ClientShell>
|
</ClientShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
if (!session) return shell;
|
||||||
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
|
|
||||||
{shell}
|
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
||||||
</ManageTourGate>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,53 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import React from "react";
|
||||||
|
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useForm } from "@homarr/form";
|
||||||
|
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||||
import type { FormValues } from "./settings-form";
|
|
||||||
import { SwitchSetting } from "./setting-switch";
|
|
||||||
|
|
||||||
interface AnalyticsSettingsProps {
|
interface AnalyticsSettingsProps {
|
||||||
form: UseFormReturnType<FormValues>;
|
initialData: typeof defaultServerSettings.analytics;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => {
|
export const AnalyticsSettings = ({ initialData }: 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")}>
|
<>
|
||||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
<Title order={2}>{t("title")}</Title>
|
||||||
</SectionCard>
|
|
||||||
|
<Card pos="relative">
|
||||||
|
<LoadingOverlay visible={isPending} />
|
||||||
|
<Stack>
|
||||||
|
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+20
-21
@@ -5,32 +5,31 @@ 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 { UseFormReturnType } from "@homarr/form";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { SelectWithCustomItems } from "@homarr/ui";
|
import { SelectWithCustomItems } from "@homarr/ui";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { CommonSettingsForm } from "./common-form";
|
||||||
import type { FormValues } from "./settings-form";
|
|
||||||
|
|
||||||
interface AppearanceSettingsFormProps {
|
export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
|
||||||
form: UseFormReturnType<FormValues>;
|
const tApperance = useScopedI18n("management.page.settings.section.appearance");
|
||||||
}
|
|
||||||
|
|
||||||
export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => {
|
|
||||||
const tAppearance = useScopedI18n("management.page.settings.section.appearance");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={tAppearance("title")}>
|
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
|
||||||
<SelectWithCustomItems
|
{(form) => (
|
||||||
label={tAppearance("defaultColorScheme.label")}
|
<>
|
||||||
data={colorSchemes.map((scheme) => ({
|
<SelectWithCustomItems
|
||||||
value: scheme,
|
label={tApperance("defaultColorScheme.label")}
|
||||||
label: tAppearance(`defaultColorScheme.options.${scheme}`),
|
data={colorSchemes.map((scheme) => ({
|
||||||
}))}
|
value: scheme,
|
||||||
{...form.getInputProps("defaultColorScheme")}
|
label: tApperance(`defaultColorScheme.options.${scheme}`),
|
||||||
SelectOption={AppearanceCustomOption}
|
}))}
|
||||||
/>
|
{...form.getInputProps("defaultColorScheme")}
|
||||||
</SectionCard>
|
SelectOption={ApperanceCustomOption}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommonSettingsForm>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ const appearanceIcons = {
|
|||||||
dark: IconMoon,
|
dark: IconMoon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppearanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||||
const Icon = appearanceIcons[value];
|
const Icon = appearanceIcons[value];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,50 +3,49 @@
|
|||||||
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 { UseFormReturnType } from "@homarr/form";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
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 { SectionCard } from "~/components/manage/section-card";
|
import { CommonSettingsForm } from "./common-form";
|
||||||
import type { FormValues } from "./settings-form";
|
|
||||||
|
|
||||||
interface BoardSettingsFormProps {
|
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||||
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 (
|
||||||
<SectionCard title={tBoard("title")}>
|
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||||
<BoardSelect
|
{(form) => (
|
||||||
label={tBoard("homeBoard.label")}
|
<>
|
||||||
description={tBoard("homeBoard.description")}
|
<BoardSelect
|
||||||
clearable
|
label={tBoard("homeBoard.label")}
|
||||||
boards={selectableBoards}
|
description={tBoard("homeBoard.description")}
|
||||||
{...form.getInputProps("homeBoardId")}
|
clearable
|
||||||
/>
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+54
-21
@@ -1,35 +1,68 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Text } from "@mantine/core";
|
import React from "react";
|
||||||
|
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
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 { SectionCard } from "~/components/manage/section-card";
|
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||||
import type { FormValues } from "./settings-form";
|
|
||||||
import { SwitchSetting } from "./setting-switch";
|
|
||||||
|
|
||||||
interface CrawlingAndIndexingSettingsProps {
|
interface CrawlingAndIndexingSettingsProps {
|
||||||
form: UseFormReturnType<FormValues>;
|
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CrawlingAndIndexingSettings = ({ form }: CrawlingAndIndexingSettingsProps) => {
|
export const CrawlingAndIndexingSettings = ({ initialData }: 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")}>
|
<>
|
||||||
<Text c="dimmed" size="sm">
|
<Title order={2}>{t("title")}</Title>
|
||||||
{t("warning")}
|
|
||||||
</Text>
|
<Card pos="relative">
|
||||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
<Text c={"dimmed"} mb={"lg"}>
|
||||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
{t("warning")}
|
||||||
<SwitchSetting form={form} formKey="noTranslate" title={t("noTranslate.title")} text={t("noTranslate.text")} />
|
</Text>
|
||||||
<SwitchSetting
|
<LoadingOverlay visible={isPending} />
|
||||||
form={form}
|
<Stack>
|
||||||
formKey="noSiteLinksSearchBox"
|
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||||
title={t("noSiteLinksSearchBox.title")}
|
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||||
text={t("noSiteLinksSearchBox.text")}
|
<SwitchSetting
|
||||||
/>
|
form={form}
|
||||||
</SectionCard>
|
formKey="noTranslate"
|
||||||
|
title={t("noTranslate.title")}
|
||||||
|
text={t("noTranslate.text")}
|
||||||
|
/>
|
||||||
|
<SwitchSetting
|
||||||
|
form={form}
|
||||||
|
formKey="noSiteLinksSearchBox"
|
||||||
|
title={t("noSiteLinksSearchBox.title")}
|
||||||
|
text={t("noSiteLinksSearchBox.text")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+14
-16
@@ -1,28 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
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 { SectionCard } from "~/components/manage/section-card";
|
import { CommonSettingsForm } from "./common-form";
|
||||||
import type { FormValues } from "./settings-form";
|
|
||||||
|
|
||||||
interface CultureSettingsFormProps {
|
export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => {
|
||||||
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 (
|
||||||
<SectionCard title={tCulture("title")}>
|
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
|
||||||
<LanguageCombobox
|
{(form) => (
|
||||||
label={tCulture("defaultLocale.label")}
|
<>
|
||||||
{...localeInputProps}
|
<LanguageCombobox
|
||||||
value={localeInputProps.value as SupportedLanguage}
|
label={tCulture("defaultLocale.label")}
|
||||||
/>
|
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
|
||||||
</SectionCard>
|
{...form.getInputProps("defaultLocale")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommonSettingsForm>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -3,28 +3,27 @@
|
|||||||
import { Select } from "@mantine/core";
|
import { Select } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { CommonSettingsForm } from "./common-form";
|
||||||
import type { FormValues } from "./settings-form";
|
|
||||||
|
|
||||||
interface SearchSettingsFormProps {
|
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
|
||||||
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 (
|
||||||
<SectionCard title={tSearch("title")}>
|
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
|
||||||
<Select
|
{(form) => (
|
||||||
label={tSearch("defaultSearchEngine.label")}
|
<>
|
||||||
description={tSearch("defaultSearchEngine.description")}
|
<Select
|
||||||
data={selectableSearchEngines}
|
label={tSearch("defaultSearchEngine.label")}
|
||||||
{...form.getInputProps("defaultSearchEngineId")}
|
description={tSearch("defaultSearchEngine.description")}
|
||||||
/>
|
data={selectableSearchEngines}
|
||||||
</SectionCard>
|
{...form.getInputProps("defaultSearchEngineId")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommonSettingsForm>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -3,13 +3,10 @@ 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";
|
||||||
|
|
||||||
type BooleanKeys<TFormValue> = {
|
export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||||
[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,
|
||||||
@@ -17,8 +14,13 @@ export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
|
|||||||
formKey,
|
formKey,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
form: UseFormReturnType<TFormValue, TFormValue>;
|
form: Modify<
|
||||||
formKey: string & BooleanKeys<TFormValue>;
|
UseFormReturnType<TFormValue, 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;
|
||||||
@@ -29,7 +31,7 @@ export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.setFieldValue(formKey, (previous) => !previous as never);
|
form.setFieldValue(formKey, (previous) => !previous);
|
||||||
}, [form, formKey, disabled]);
|
}, [form, formKey, disabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +44,7 @@ export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Switch disabled={disabled} onClick={handleClick} checked={Boolean(form.values[formKey])} />
|
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
"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,26 +2,25 @@
|
|||||||
|
|
||||||
import { Switch } from "@mantine/core";
|
import { Switch } from "@mantine/core";
|
||||||
|
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { CommonSettingsForm } from "./common-form";
|
||||||
import type { FormValues } from "./settings-form";
|
|
||||||
|
|
||||||
interface UserSettingsFormProps {
|
export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => {
|
||||||
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 (
|
||||||
<SectionCard title={tUser("title")}>
|
<CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
|
||||||
<Switch
|
{(form) => (
|
||||||
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
<>
|
||||||
label={tUser("enableGravatar.label")}
|
<Switch
|
||||||
description={tUser("enableGravatar.description")}
|
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
||||||
/>
|
label={tUser("enableGravatar.label")}
|
||||||
</SectionCard>
|
description={tUser("enableGravatar.description")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommonSettingsForm>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Center, Loader } from "@mantine/core";
|
|
||||||
|
|
||||||
export default function SettingsLoading() {
|
|
||||||
return (
|
|
||||||
<Center h="40vh">
|
|
||||||
<Loader />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,14 @@ 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 { SettingsForm } from "./_components/settings-form";
|
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||||
|
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||||
|
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||||
|
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||||
|
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||||
|
import { UserSettingsForm } from "./_components/user-settings-form";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
@@ -26,13 +32,33 @@ 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>
|
||||||
<SettingsForm initialData={serverSettings} />
|
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||||
|
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||||
|
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.user.title")}</Title>
|
||||||
|
<UserSettingsForm defaultValues={serverSettings.user} />
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.search.title")}</Title>
|
||||||
|
<SearchSettingsForm defaultValues={serverSettings.search} />
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||||
|
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.culture.title")}</Title>
|
||||||
|
<CultureSettingsForm defaultValues={serverSettings.culture} />
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
-3
@@ -1,6 +1,5 @@
|
|||||||
"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";
|
||||||
|
|
||||||
@@ -10,7 +9,6 @@ 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({
|
||||||
@@ -20,7 +18,6 @@ export const ResetTours = () => {
|
|||||||
message: t("resetSuccess.message"),
|
message: t("resetSuccess.message"),
|
||||||
});
|
});
|
||||||
void utils.user.getTourStatus.invalidate();
|
void utils.user.getTourStatus.invalidate();
|
||||||
router.refresh();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use, useCallback, useState } from "react";
|
import { useCallback, useMemo, 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 { clientApi } from "@homarr/api/client";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { 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 { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
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 }: WidgetPreviewPageContentProps) => {
|
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const utils = clientApi.useUtils();
|
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
||||||
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
|
||||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||||
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
const currentDefinition = useMemo(() => widgetImports[kind].definition, [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,
|
||||||
@@ -42,7 +45,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
integrationIds: string[];
|
integrationIds: string[];
|
||||||
advancedOptions: BoardItemAdvancedOptions;
|
advancedOptions: BoardItemAdvancedOptions;
|
||||||
}>({
|
}>({
|
||||||
options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}),
|
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
||||||
integrationIds: [],
|
integrationIds: [],
|
||||||
advancedOptions: {
|
advancedOptions: {
|
||||||
title: null,
|
title: null,
|
||||||
@@ -51,41 +54,31 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenEditWidgetModal = useCallback(async () => {
|
const handleOpenEditWidgetModal = useCallback(() => {
|
||||||
const hasIntegrationSupport = "supportedIntegrations" in currentDefinition;
|
openWidgetEditModal(
|
||||||
setIsEditorLoading(true);
|
{
|
||||||
preloadWidgetEditModal();
|
kind,
|
||||||
try {
|
value: state,
|
||||||
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
onSuccessfulEdit: (value) => {
|
||||||
openWidgetEditModal(
|
setState(value);
|
||||||
{
|
|
||||||
kind,
|
|
||||||
definition: currentDefinition,
|
|
||||||
value: state,
|
|
||||||
onSuccessfulEdit: (value) => {
|
|
||||||
setState(value);
|
|
||||||
},
|
|
||||||
integrationData: integrationData.filter((integration) =>
|
|
||||||
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
|
|
||||||
),
|
|
||||||
integrationSupport: hasIntegrationSupport,
|
|
||||||
settings,
|
|
||||||
},
|
},
|
||||||
{
|
integrationData: integrationData.filter(
|
||||||
title(translate) {
|
(integration) =>
|
||||||
return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`;
|
"supportedIntegrations" in currentDefinition &&
|
||||||
},
|
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||||
|
),
|
||||||
|
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||||
|
settings,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title(t) {
|
||||||
|
return `${t("item.edit.title")} - ${t(`widget.${kind}.name`)}`;
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
} catch (error) {
|
);
|
||||||
showErrorNotification({
|
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
|
||||||
title: t("common.error"),
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
const Comp = loadWidgetDynamic(kind);
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsEditorLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
|
||||||
|
|
||||||
const toggleEditMode = useCallback(() => {
|
const toggleEditMode = useCallback(() => {
|
||||||
setEditMode((editMode) => !editMode);
|
setEditMode((editMode) => !editMode);
|
||||||
@@ -101,11 +94,8 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
});
|
});
|
||||||
}, [dimensions, openPreviewDimensionsModal]);
|
}, [dimensions, openPreviewDimensionsModal]);
|
||||||
|
|
||||||
const updateOptions = useCallback(
|
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||||
({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
setState({ ...state, options: { ...state.options, newOptions } });
|
||||||
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -115,10 +105,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onReset={reset}
|
onReset={reset}
|
||||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
<WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
<WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Component
|
<Comp
|
||||||
options={state.options as never}
|
options={state.options as never}
|
||||||
integrationIds={state.integrationIds}
|
integrationIds={state.integrationIds}
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
@@ -137,10 +127,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
size={48}
|
size={48}
|
||||||
variant="default"
|
variant="default"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={() => void handleOpenEditWidgetModal()}
|
onClick={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,18 +1,23 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
import { widgetKinds } from "@homarr/definitions";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
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 links = widgetKinds.map((kind) => ({
|
const getLinks = () => {
|
||||||
href: `/widgets/${kind}`,
|
return Object.entries(widgetImports).map(([key, value]) => {
|
||||||
icon: widgetCatalogIcons[kind],
|
return {
|
||||||
label: kind,
|
href: `/widgets/${key}`,
|
||||||
}));
|
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,9 +1,10 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Center, Loader } from "@mantine/core";
|
import { Center } 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 { widgetKinds } from "@homarr/widgets/manifest";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
import { WidgetPreviewPageContent } from "./_content";
|
import { WidgetPreviewPageContent } from "./_content";
|
||||||
|
|
||||||
@@ -12,16 +13,24 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function WidgetPreview(props: Props) {
|
export default async function WidgetPreview(props: Props) {
|
||||||
const { kind } = await props.params;
|
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||||
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">
|
||||||
<Suspense fallback={<Loader size="sm" />}>
|
<WidgetPreviewPageContent kind={sort} integrationData={integrationData} />
|
||||||
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
|
||||||
</Suspense>
|
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
import type { ComponentType, MutableRefObject } from "react";
|
import type { MutableRefObject } from "react";
|
||||||
import { Suspense, use, useRef } from "react";
|
import { useRef } from "react";
|
||||||
import type { CardProps } from "@mantine/core";
|
import { Badge, Card } 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";
|
||||||
@@ -23,7 +19,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 { removeWidgetDataQueries } from "./widget-query-recovery";
|
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||||
|
|
||||||
interface BoardItemContentProps {
|
interface BoardItemContentProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
@@ -35,53 +31,6 @@ 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();
|
||||||
@@ -89,28 +38,29 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ErrorBoundary
|
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||||
resetKeys={[item.kind]}
|
<Card
|
||||||
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
ref={ref}
|
||||||
>
|
className={combineClasses(
|
||||||
<Suspense
|
classes.itemCard,
|
||||||
fallback={
|
`${item.kind}-wrapper`,
|
||||||
<BoardItemCard item={item} innerRef={ref}>
|
"grid-stack-item-content",
|
||||||
<Center h="100%">
|
item.advancedOptions.customCssClasses.join(" "),
|
||||||
<Loader size="sm" />
|
)}
|
||||||
</Center>
|
radius={board.itemRadius}
|
||||||
</BoardItemCard>
|
styles={{
|
||||||
}
|
root: {
|
||||||
|
"--opacity": board.opacity / 100,
|
||||||
|
containerType: "size",
|
||||||
|
overflow: getOverflowFromKind(item.kind),
|
||||||
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
p={0}
|
||||||
>
|
>
|
||||||
<LoadedBoardItemContent
|
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
|
||||||
item={item}
|
</Card>
|
||||||
width={width}
|
</WidgetContextMenu>
|
||||||
height={height}
|
|
||||||
widgetStateRef={widgetStateRef}
|
|
||||||
innerRef={ref}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
{item.advancedOptions.title?.trim() && (
|
{item.advancedOptions.title?.trim() && (
|
||||||
<Badge
|
<Badge
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
@@ -136,72 +86,40 @@ 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, definition, Component, ...dimensions }: InnerContentProps) => {
|
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
const Comp = loadWidgetDynamic(item.kind);
|
||||||
|
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 ?? 0) >= 1;
|
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryErrorResetBoundary>
|
<QueryErrorResetBoundary>
|
||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
removeWidgetDataQueries(queryClient, widgetQueryKeys);
|
removePersistedWidgetQueries(queryClient);
|
||||||
reset();
|
reset();
|
||||||
}}
|
}}
|
||||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
<>
|
<>
|
||||||
<BoardItemMenu
|
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||||
offset={4}
|
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||||
item={newItem}
|
|
||||||
definition={definition}
|
|
||||||
resetErrorBoundary={resetErrorBoundary}
|
|
||||||
/>
|
|
||||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -213,25 +131,23 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
|
|||||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<BoardItemMenu offset={4} item={newItem} definition={definition} />
|
<BoardItemMenu offset={4} item={newItem} />
|
||||||
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
|
<Comp
|
||||||
<Component
|
options={options as never}
|
||||||
options={options as never}
|
integrationIds={item.integrationIds}
|
||||||
integrationIds={item.integrationIds}
|
isEditMode={isEditMode}
|
||||||
isEditMode={isEditMode}
|
boardId={board.id}
|
||||||
boardId={board.id}
|
itemId={item.id}
|
||||||
itemId={item.id}
|
setOptions={(partialNewOptions) =>
|
||||||
setOptions={(partialNewOptions) =>
|
updateOptions({
|
||||||
updateOptions({
|
newOptions: {
|
||||||
newOptions: {
|
...options,
|
||||||
...options,
|
...partialNewOptions.newOptions,
|
||||||
...partialNewOptions.newOptions,
|
},
|
||||||
},
|
})
|
||||||
})
|
}
|
||||||
}
|
{...dimensions}
|
||||||
{...dimensions}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)}
|
)}
|
||||||
</QueryErrorResetBoundary>
|
</QueryErrorResetBoundary>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef } 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,44 +8,39 @@ 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 type { WidgetDefinition } from "@homarr/widgets/definition";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
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 { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
|
import { ItemMoveModal } from "./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();
|
||||||
const [isEditMode] = useEditMode();
|
if (!session) return null;
|
||||||
if (!session || !isEditMode) return null;
|
|
||||||
|
|
||||||
return <BoardItemMenuInner {...props} />;
|
return <BoardItemMenuInner {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
const BoardItemMenuInner = ({ offset, item, 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(LazyWidgetEditModal);
|
const { openModal } = useModalAction(WidgetEditModal);
|
||||||
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isEditMode] = useEditMode();
|
||||||
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(undefined, {
|
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||||
enabled: isMenuOpen && hasSupportedIntegrations,
|
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||||
});
|
|
||||||
const { gridstack } = useSectionContext().refs;
|
const { gridstack } = useSectionContext().refs;
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
|
||||||
@@ -57,11 +52,12 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
|
|||||||
}
|
}
|
||||||
}, [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,
|
||||||
@@ -84,10 +80,10 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
|
|||||||
},
|
},
|
||||||
integrationData: (integrationData ?? []).filter(
|
integrationData: (integrationData ?? []).filter(
|
||||||
(integration) =>
|
(integration) =>
|
||||||
"supportedIntegrations" in definition &&
|
"supportedIntegrations" in currentDefinition &&
|
||||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||||
),
|
),
|
||||||
integrationSupport: "supportedIntegrations" in definition,
|
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||||
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,
|
||||||
@@ -111,7 +107,7 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
|
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -127,19 +123,11 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
|
|||||||
</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
|
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||||
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";
|
||||||
|
|
||||||
export interface ItemMoveModalProps {
|
interface InnerProps {
|
||||||
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<ItemMoveModalProps>(({ actions, innerProps }) => {
|
export const ItemMoveModal = createModal<InnerProps>(({ 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(
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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));
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
.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,65 +1,46 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, 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 } from "@homarr/common";
|
import { createId, objectEntries } from "@homarr/common";
|
||||||
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
import { getIconUrl, getIntegrationName } 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 { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
interface ItemSelectModalContentProps {
|
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||||
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(LazyWidgetEditModal);
|
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||||
|
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(
|
||||||
() =>
|
() =>
|
||||||
widgetKinds
|
objectEntries(widgetImports)
|
||||||
.filter((kind) => kind !== "customApi")
|
.filter(([kind]) => kind !== "customApi")
|
||||||
.map((kind) => {
|
.map(([kind, value]) => ({
|
||||||
return {
|
kind,
|
||||||
kind,
|
supportedIntegrations:
|
||||||
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
|
"supportedIntegrations" in value.definition
|
||||||
(integration) => integration !== "mock",
|
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
|
||||||
),
|
: [],
|
||||||
icon: widgetCatalogIcons[kind],
|
icon: value.definition.icon,
|
||||||
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;
|
||||||
@@ -85,81 +66,52 @@ const ItemSelectModalContent = ({
|
|||||||
[customWidgetDefs, search],
|
[customWidgetDefs, search],
|
||||||
);
|
);
|
||||||
|
|
||||||
const notifyDefinitionLoadError = (error: unknown) => {
|
const handleAddCustomWidget = (definitionId: string) => {
|
||||||
showErrorNotification({
|
const itemId = createId();
|
||||||
title: t("common.error"),
|
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
||||||
message: error instanceof Error ? error.message : String(error),
|
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||||
});
|
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
||||||
|
actions.closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddCustomWidget = async (definitionId: string) => {
|
const handleAdd = (kind: WidgetKind) => {
|
||||||
if (!tryLockSelection(selectionLock)) return;
|
const definition = widgetImports[kind].definition;
|
||||||
setLoadingSelection(`custom:${definitionId}`);
|
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||||
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 handleAdd = async (kind: WidgetKind) => {
|
const matchingIntegrations = hasIntegrationSupport
|
||||||
if (!tryLockSelection(selectionLock)) return;
|
? (integrationData ?? []).filter((integration) =>
|
||||||
setLoadingSelection(kind);
|
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||||
preloadWidgetEditModal();
|
)
|
||||||
try {
|
: [];
|
||||||
const definition = await loadWidgetDefinition(kind);
|
|
||||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
|
||||||
|
|
||||||
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
|
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||||
hasIntegrationSupport,
|
const itemId = createId();
|
||||||
supportedIntegrations: definition.supportedIntegrations ?? [],
|
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
||||||
currentData: integrationData,
|
|
||||||
ensureDataAsync: ensureIntegrationDataAsync,
|
|
||||||
});
|
|
||||||
|
|
||||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
createItem({ id: itemId, kind, integrationIds });
|
||||||
const itemId = createId();
|
actions.closeModal();
|
||||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
|
||||||
|
|
||||||
createItem({ id: itemId, kind, integrationIds });
|
openEditModal(
|
||||||
actions.closeModal();
|
{
|
||||||
|
kind,
|
||||||
openEditModal(
|
value: {
|
||||||
{
|
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||||
kind,
|
options: defaultOptions,
|
||||||
definition,
|
integrationIds,
|
||||||
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 }) => {
|
||||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
updateItemOptions({ itemId, newOptions: options });
|
||||||
|
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||||
|
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||||
},
|
},
|
||||||
);
|
integrationData: matchingIntegrations,
|
||||||
} catch (error) {
|
integrationSupport: hasIntegrationSupport,
|
||||||
notifyDefinitionLoadError(error);
|
settings,
|
||||||
} finally {
|
},
|
||||||
unlockSelection(selectionLock);
|
{
|
||||||
setLoadingSelection(null);
|
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||||
}
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -168,9 +120,9 @@ const ItemSelectModalContent = ({
|
|||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
placeholder={`${t("item.create.search")}...`}
|
placeholder={`${t("item.create.search")}...`}
|
||||||
onSearchKeyDown={(event) => {
|
onSearchKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||||
const [item] = filteredItems;
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
if (item) void handleAdd(item.kind);
|
handleAdd(filteredItems[0]!.kind);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -178,13 +130,7 @@ const ItemSelectModalContent = ({
|
|||||||
<WidgetItem
|
<WidgetItem
|
||||||
key={item.kind}
|
key={item.kind}
|
||||||
item={item}
|
item={item}
|
||||||
onSelect={() => void handleAdd(item.kind)}
|
onSelect={() => 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))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -200,12 +146,12 @@ const ItemSelectModalContent = ({
|
|||||||
{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" }}
|
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||||
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||||
|
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">
|
||||||
@@ -223,24 +169,18 @@ const ItemSelectModalContent = ({
|
|||||||
</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
|
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
|
||||||
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>
|
||||||
@@ -256,24 +196,7 @@ const ItemSelectModalContent = ({
|
|||||||
)}
|
)}
|
||||||
</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,
|
||||||
});
|
});
|
||||||
@@ -281,9 +204,6 @@ export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFram
|
|||||||
const WidgetItem = ({
|
const WidgetItem = ({
|
||||||
item,
|
item,
|
||||||
onSelect,
|
onSelect,
|
||||||
onIntent,
|
|
||||||
disabled,
|
|
||||||
loading,
|
|
||||||
hasMatchingIntegration,
|
hasMatchingIntegration,
|
||||||
}: {
|
}: {
|
||||||
item: {
|
item: {
|
||||||
@@ -294,27 +214,23 @@ 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,
|
||||||
}}
|
}}
|
||||||
onFocus={onIntent}
|
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||||
onPointerEnter={onIntent}
|
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||||
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">
|
||||||
@@ -331,17 +247,18 @@ 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 disabled={disabled} loading={loading}>
|
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||||
{t(`item.create.addToBoard`)}
|
{t(`item.create.addToBoard`)}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
"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,
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"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, useState } from "react";
|
import { useCallback, useMemo } 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 { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
import { 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,116 +25,63 @@ 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, WidgetDefinition } from "@homarr/widgets/definition";
|
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
||||||
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
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 { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
|
import { ItemMoveModal } from "./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, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
|
export const WidgetContextMenu = ({ item, 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 hasSupportedIntegrations =
|
const settings = useSettings();
|
||||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
const { openModal } = useModalAction(WidgetEditModal);
|
||||||
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||||
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(undefined, {
|
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||||
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 widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
const widgetQueryKey = useMemo(
|
||||||
const isWidgetFetching =
|
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
|
||||||
useIsFetching({
|
[currentDefinition, item.kind],
|
||||||
predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
|
);
|
||||||
}) > 0;
|
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
|
||||||
const handleRefetch = useCallback(() => {
|
const handleRefetch = useCallback(() => {
|
||||||
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" })));
|
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
||||||
}, [queryClient, widgetQueryKeys]);
|
}, [queryClient, widgetQueryKey]);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
|
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
||||||
[definition, settings, item.options],
|
[item.kind, settings, item.options],
|
||||||
);
|
);
|
||||||
|
|
||||||
type OptionDef = { type: string; skipContextMenu?: boolean };
|
type OptionDef = { type: string; skipContextMenu?: boolean };
|
||||||
const toggleOptions = useMemo(() => {
|
const toggleOptions = useMemo(() => {
|
||||||
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
const rawOptions = currentDefinition.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);
|
||||||
}, [definition, settings]);
|
}, [currentDefinition, settings]);
|
||||||
|
|
||||||
const widgetContextActions = useMemo(() => {
|
const widgetContextActions = useMemo(() => {
|
||||||
const def = definition as unknown as Record<string, unknown>;
|
const def = currentDefinition 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)({
|
||||||
@@ -147,7 +94,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
|||||||
widgetStateRef,
|
widgetStateRef,
|
||||||
});
|
});
|
||||||
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
||||||
}, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
||||||
|
|
||||||
const persistBoard = useCallback(
|
const persistBoard = useCallback(
|
||||||
(updatedItems: typeof board.items) => {
|
(updatedItems: typeof board.items) => {
|
||||||
@@ -160,7 +107,6 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
|||||||
openModal(
|
openModal(
|
||||||
{
|
{
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
definition,
|
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: item.advancedOptions,
|
advancedOptions: item.advancedOptions,
|
||||||
options: item.options,
|
options: item.options,
|
||||||
@@ -187,10 +133,10 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
|||||||
},
|
},
|
||||||
integrationData: (integrationData ?? []).filter(
|
integrationData: (integrationData ?? []).filter(
|
||||||
(integration) =>
|
(integration) =>
|
||||||
"supportedIntegrations" in definition &&
|
"supportedIntegrations" in currentDefinition &&
|
||||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||||
),
|
),
|
||||||
integrationSupport: "supportedIntegrations" in definition,
|
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||||
settings,
|
settings,
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
},
|
},
|
||||||
@@ -204,7 +150,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
|||||||
updateItemAdvancedOptions,
|
updateItemAdvancedOptions,
|
||||||
updateItemIntegrations,
|
updateItemIntegrations,
|
||||||
integrationData,
|
integrationData,
|
||||||
definition,
|
currentDefinition,
|
||||||
settings,
|
settings,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
persistBoard,
|
persistBoard,
|
||||||
@@ -222,133 +168,135 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
|||||||
[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}>
|
||||||
{toggleOptions.length > 0 && (
|
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
||||||
<>
|
<Menu.Dropdown>
|
||||||
<Menu.Label>{tMenu("options")}</Menu.Label>
|
{toggleOptions.length > 0 && (
|
||||||
{toggleOptions.map(([key]) => (
|
<>
|
||||||
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
|
<Menu.Label>{tMenu("options")}</Menu.Label>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{toggleOptions.map(([key]) => (
|
||||||
<Group wrap="nowrap">
|
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
|
||||||
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
|
<Group wrap="nowrap">
|
||||||
</Group>
|
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
||||||
</Menu.Item>
|
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
|
||||||
))}
|
</Group>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isEditMode && (
|
|
||||||
<>
|
<>
|
||||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
closeMenuOnClick
|
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||||
leftSection={<IconLayoutKanban size={16} />}
|
onClick={handleRefetch}
|
||||||
onFocus={preloadItemMoveModal}
|
disabled={isWidgetFetching}
|
||||||
onPointerEnter={preloadItemMoveModal}
|
|
||||||
onClick={() => {
|
|
||||||
if (!gridstack.current) return;
|
|
||||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{tItem("action.moveResize")}
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
{tMenu("refresh")}
|
||||||
|
<WidgetQueryStatus
|
||||||
|
queryClient={queryClient}
|
||||||
|
queryKey={widgetQueryKey}
|
||||||
|
isFetching={isWidgetFetching}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
closeMenuOnClick
|
closeMenuOnClick
|
||||||
leftSection={<IconCopy size={16} />}
|
leftSection={<IconSettings size={16} />}
|
||||||
onClick={() => duplicateItem({ itemId: item.id })}
|
onClick={openEditModal}
|
||||||
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
{tItem("action.duplicate")}
|
{tMenu("settings")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
|
|
||||||
<>
|
{isEditMode && (
|
||||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
<>
|
||||||
<Menu.Item
|
<Menu.Divider />
|
||||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
<Menu.Item
|
||||||
onClick={handleRefetch}
|
closeMenuOnClick
|
||||||
disabled={isWidgetFetching}
|
color="red"
|
||||||
>
|
leftSection={<IconTrash size={16} />}
|
||||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
onClick={() => {
|
||||||
{tMenu("refresh")}
|
openConfirmModal({
|
||||||
<WidgetQueryStatus
|
title: tItem("remove.title"),
|
||||||
queryClient={queryClient}
|
children: tItem("remove.message"),
|
||||||
queryKeys={widgetQueryKeys}
|
onConfirm: () => removeItem({ itemId: item.id }),
|
||||||
isFetching={isWidgetFetching}
|
});
|
||||||
t={t}
|
}}
|
||||||
/>
|
>
|
||||||
</Group>
|
{tItem("action.remove")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
</>
|
||||||
closeMenuOnClick
|
)}
|
||||||
leftSection={<IconSettings size={16} />}
|
</Menu.Dropdown>
|
||||||
onClick={openEditModal}
|
</Menu>
|
||||||
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;
|
||||||
queryKeys: QueryKey[];
|
queryKey: QueryKey;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
t: TranslationFunction;
|
t: TranslationFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
|
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
|
||||||
const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))];
|
const queries = 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));
|
||||||
@@ -386,8 +334,7 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer
|
|||||||
<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")}
|
{t("item.menu.status.error")} · {ageLabel}
|
||||||
{latest > 0 && ` · ${ageLabel}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -398,8 +345,7 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer
|
|||||||
<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")}
|
{t("item.menu.status.success")} · {ageLabel}
|
||||||
{latest > 0 && ` · ${ageLabel}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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,13 +1,12 @@
|
|||||||
"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 { loadWidgetDefinition } from "@homarr/widgets/manifest";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
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";
|
||||||
@@ -19,19 +18,8 @@ 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 = use(loadWidgetDefinition(item.kind));
|
const { definition } = widgetImports[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`);
|
||||||
@@ -40,6 +28,10 @@ const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProp
|
|||||||
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,36 +1,27 @@
|
|||||||
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 { removeWidgetDataQueries } from "./widget-query-recovery";
|
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||||
|
|
||||||
describe("removeWidgetDataQueries", () => {
|
describe("removePersistedWidgetQueries", () => {
|
||||||
test("removes only the failed widget prefix and allows fresh data to be fetched", async () => {
|
test("removes stale widget queries 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");
|
||||||
|
|
||||||
removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]);
|
removePersistedWidgetQueries(queryClient);
|
||||||
|
|
||||||
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("uses an explicit supporting query prefix without purging other supporting data", () => {
|
test("removes persisted queries outside the widget router", () => {
|
||||||
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: {} }];
|
||||||
@@ -40,14 +31,14 @@ describe("removeWidgetDataQueries", () => {
|
|||||||
queryClient.setQueryData(appQueryKey, "stale");
|
queryClient.setQueryData(appQueryKey, "stale");
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]);
|
removePersistedWidgetQueries(queryClient);
|
||||||
|
|
||||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(appQueryKey)).toBe("stale");
|
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses the definition query prefix when the widget kind and query path differ", () => {
|
test("removes persisted widget queries 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: {} }];
|
||||||
@@ -55,7 +46,7 @@ describe("removeWidgetDataQueries", () => {
|
|||||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]);
|
removePersistedWidgetQueries(queryClient);
|
||||||
|
|
||||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => {
|
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
||||||
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
|
|
||||||
|
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
||||||
|
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
||||||
};
|
};
|
||||||
@@ -105,14 +105,12 @@ 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 = (
|
const appIds = filterByItemKind(
|
||||||
await filterByItemKind(
|
board.items.filter(
|
||||||
board.items.filter(
|
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||||
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
),
|
||||||
),
|
settings,
|
||||||
settings,
|
"app",
|
||||||
"app",
|
|
||||||
)
|
|
||||||
).map((item) => {
|
).map((item) => {
|
||||||
return item.options.appId;
|
return item.options.appId;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
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/definition";
|
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { Item } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
export const filterByItemKind = async <TKind extends WidgetKind>(
|
export const filterByItemKind = <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: reduceWidgetOptionsWithDefinition(
|
options: reduceWidgetOptionsWithDefaultValues(
|
||||||
definition,
|
kind,
|
||||||
settings,
|
settings,
|
||||||
item.options,
|
item.options,
|
||||||
) as WidgetComponentProps<TKind>["options"],
|
) as WidgetComponentProps<TKind>["options"],
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ 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";
|
||||||
@@ -17,11 +15,7 @@ import { useSectionItems } from "./use-section-items";
|
|||||||
|
|
||||||
export const SectionContent = () => {
|
export const SectionContent = () => {
|
||||||
const { innerSections, items, refs } = useSectionContext();
|
const { innerSections, items, refs } = useSectionContext();
|
||||||
const { data: session } = useSession();
|
const { data: integrations } = clientApi.integration.all.useQuery();
|
||||||
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,3 +1,4 @@
|
|||||||
|
import { objectEntries } from "@homarr/common";
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import {
|
import {
|
||||||
featuredIntegrations,
|
featuredIntegrations,
|
||||||
@@ -5,8 +6,30 @@ 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",
|
||||||
@@ -47,11 +70,11 @@ export interface IntegrationGridItem {
|
|||||||
|
|
||||||
export const buildSortedIntegrations = (
|
export const buildSortedIntegrations = (
|
||||||
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
||||||
): IntegrationGridItem[] => {
|
): IntegrationGridItem[] =>
|
||||||
return integrationKinds
|
integrationKinds
|
||||||
.filter((kind) => {
|
.filter((kind) => {
|
||||||
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
||||||
if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false;
|
if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
|
||||||
if (!options.enableMockIntegration && kind === "mock") return false;
|
if (!options.enableMockIntegration && kind === "mock") return false;
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
@@ -59,9 +82,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: getWidgetKindsForIntegration(kind),
|
widgets: widgetsByIntegration[kind],
|
||||||
}))
|
}))
|
||||||
.toSorted((left, right) => {
|
.sort((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;
|
||||||
@@ -69,7 +92,6 @@ 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,6 +1,7 @@
|
|||||||
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";
|
||||||
@@ -8,7 +9,6 @@ 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>
|
||||||
<LazySpotlight />
|
<Spotlight />
|
||||||
</AppShellHeader>
|
</AppShellHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -14,9 +14,6 @@ type HeaderButtonProps = (
|
|||||||
}
|
}
|
||||||
) & {
|
) & {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onFocus?: () => void;
|
|
||||||
onPointerDown?: () => void;
|
|
||||||
onPointerEnter?: () => void;
|
|
||||||
} & Partial<ActionIconProps>;
|
} & Partial<ActionIconProps>;
|
||||||
|
|
||||||
const headerButtonActionIconProps: ActionIconProps = {
|
const headerButtonActionIconProps: ActionIconProps = {
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
"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,28 +3,23 @@
|
|||||||
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/open";
|
import { openSpotlight } from "@homarr/spotlight";
|
||||||
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")}...`}
|
||||||
@@ -34,14 +29,7 @@ export const DesktopSearchInput = () => {
|
|||||||
|
|
||||||
export const MobileSearchButton = () => {
|
export const MobileSearchButton = () => {
|
||||||
return (
|
return (
|
||||||
<HeaderButton
|
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch}>
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
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,41 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren, ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createContext, Suspense, use, useContext } from "react";
|
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
const TourTargetsEnabledContext = createContext(false);
|
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
|
||||||
let tourLibraryPromise: Promise<typeof import("@gfazioli/mantine-onboarding-tour")> | undefined;
|
<OnboardingTour.Target id={id}>
|
||||||
|
<Box data-tour-target={id}>{children}</Box>
|
||||||
const loadTourLibrary = () => {
|
</OnboardingTour.Target>
|
||||||
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,6 +1,7 @@
|
|||||||
"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";
|
||||||
|
|
||||||
@@ -8,28 +9,67 @@ 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 {
|
||||||
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
|
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateIndicator = ({ children, availableUpdates, disabled }: UpdateIndicatorProps) => {
|
export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => {
|
||||||
if (disabled || availableUpdates === undefined) {
|
if (disabled || availableUpdatesPromise === undefined) {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Indicator disabled={availableUpdates.length === 0} size={15} processing withBorder>
|
<Suspense fallback={children}>
|
||||||
|
<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 {
|
||||||
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
|
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AvailableUpdatesMenuItem = ({ availableUpdates }: AvailableUpdatesMenuItemProps) => {
|
export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
"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,14 +1,28 @@
|
|||||||
|
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 { UserButtonClient } from "./user-client";
|
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||||
|
import { UpdateIndicator } from "./update";
|
||||||
|
|
||||||
export const UserButton = async () => {
|
export const UserButton = async () => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
const isAdmin = 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 (
|
||||||
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
|
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
||||||
|
<UnstyledButton>
|
||||||
|
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||||
|
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||||
|
<CurrentUserAvatar size="md" />
|
||||||
|
</UpdateIndicator>
|
||||||
|
</Suspense>
|
||||||
|
</UnstyledButton>
|
||||||
|
</UserAvatarMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { 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 withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
|
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
|
||||||
id ? <TourTarget id={id}>{children}</TourTarget> : children;
|
if (!id) return <>{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}/`);
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
|||||||
active={isClient && pathMatches(pathname, props.href)}
|
active={isClient && pathMatches(pathname, props.href)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return withOptionalTourTarget(tourId, link);
|
return <TourTarget id={tourId}>{link}</TourTarget>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||||
@@ -55,7 +56,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
|||||||
))}
|
))}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
return withOptionalTourTarget(props["data-onboarding-tour-id"], nav);
|
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CommonNavigationLinkProps {
|
interface CommonNavigationLinkProps {
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
"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 { useScopedI18n } from "@homarr/translation/client";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
interface DangerZoneRootProps {
|
interface DangerZoneRootProps {
|
||||||
children: React.ReactNode[] | React.ReactNode;
|
children: React.ReactNode[] | React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DangerZoneRoot = ({ children }: DangerZoneRootProps) => {
|
export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
|
||||||
const t = useScopedI18n("common");
|
const t = await getI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Title c="red.8" order={2}>
|
<Title c="red.8" order={2}>
|
||||||
{t("dangerZone")}
|
{t("common.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">
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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, useState } from "react";
|
import { useMemo } 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,7 +10,6 @@ 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";
|
||||||
|
|
||||||
@@ -18,11 +17,21 @@ 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 [started, setStarted] = useState(true);
|
const utils = clientApi.useUtils();
|
||||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
|
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||||
|
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
void utils.user.getTourStatus.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
|
||||||
|
|
||||||
const handleEnd = () => {
|
const handleEnd = () => {
|
||||||
setStarted(false);
|
utils.user.getTourStatus.setData(undefined, {
|
||||||
|
completedManageTour: tourStatus?.completedManageTour ?? false,
|
||||||
|
completedBoardTour: true,
|
||||||
|
});
|
||||||
completeTour({ tour: "board" });
|
completeTour({ tour: "board" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,10 +101,8 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
|||||||
}, [hasChangeAccess, t]);
|
}, [hasChangeAccess, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TourTargetsProvider enabled={started}>
|
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
||||||
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
{children}
|
||||||
{children}
|
</TourShell>
|
||||||
</TourShell>
|
|
||||||
</TourTargetsProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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>;
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||||
import { useMediaQuery } from "@mantine/hooks";
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
@@ -17,7 +17,6 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/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";
|
||||||
|
|
||||||
@@ -47,7 +46,13 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
|
|||||||
|
|
||||||
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
|
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
|
||||||
const t = useScopedI18n("onboardingTour.manage");
|
const t = useScopedI18n("onboardingTour.manage");
|
||||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
|
const utils = clientApi.useUtils();
|
||||||
|
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||||
|
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
void utils.user.getTourStatus.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -57,13 +62,14 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
|||||||
|
|
||||||
const [tourActive, setTourActive] = useState(false);
|
const [tourActive, setTourActive] = useState(false);
|
||||||
const [tourDismissed, setTourDismissed] = useState(false);
|
const [tourDismissed, setTourDismissed] = useState(false);
|
||||||
|
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tourDismissed) return;
|
if (tourDismissed) return;
|
||||||
if (isManageHome && !isMobile) {
|
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
|
||||||
setTourActive(true);
|
setTourActive(true);
|
||||||
}
|
}
|
||||||
}, [isManageHome, isMobile, tourDismissed]);
|
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isManageSection) {
|
if (!isManageSection) {
|
||||||
@@ -71,12 +77,25 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
|||||||
}
|
}
|
||||||
}, [isManageSection]);
|
}, [isManageSection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasCompleted = wasManageTourCompletedRef.current;
|
||||||
|
const isCompleted = tourStatus?.completedManageTour;
|
||||||
|
if (wasCompleted === true && isCompleted === false) {
|
||||||
|
setTourDismissed(false);
|
||||||
|
}
|
||||||
|
wasManageTourCompletedRef.current = isCompleted;
|
||||||
|
}, [tourStatus?.completedManageTour]);
|
||||||
|
|
||||||
const handleEnd = useCallback(() => {
|
const handleEnd = useCallback(() => {
|
||||||
setTourDismissed(true);
|
setTourDismissed(true);
|
||||||
setTourActive(false);
|
setTourActive(false);
|
||||||
|
utils.user.getTourStatus.setData(undefined, {
|
||||||
|
completedManageTour: true,
|
||||||
|
completedBoardTour: tourStatus?.completedBoardTour ?? false,
|
||||||
|
});
|
||||||
completeTour({ tour: "manage" });
|
completeTour({ tour: "manage" });
|
||||||
router.push("/manage");
|
router.push("/manage");
|
||||||
}, [completeTour, router]);
|
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
|
||||||
|
|
||||||
const steps = useMemo(() => {
|
const steps = useMemo(() => {
|
||||||
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
|
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
|
||||||
@@ -188,16 +207,14 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
|||||||
}, [isAdmin, t]);
|
}, [isAdmin, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TourTargetsProvider enabled={tourActive}>
|
<TourShell
|
||||||
<TourShell
|
steps={steps}
|
||||||
steps={steps}
|
started={tourActive}
|
||||||
started={tourActive}
|
onEnd={handleEnd}
|
||||||
onEnd={handleEnd}
|
stepRoutes={stepRoutes}
|
||||||
stepRoutes={stepRoutes}
|
position={{ base: "bottom", sm: "right" }}
|
||||||
position={{ base: "bottom", sm: "right" }}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
</TourShell>
|
||||||
</TourShell>
|
|
||||||
</TourTargetsProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Menu } from "@mantine/core";
|
import { useRouter } from "next/navigation";
|
||||||
import { IconBrandDocker, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||||
|
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||||
|
import {
|
||||||
|
IconBrandDocker,
|
||||||
|
IconCheck,
|
||||||
|
IconHome,
|
||||||
|
IconLogin,
|
||||||
|
IconLogout,
|
||||||
|
IconSettings,
|
||||||
|
IconTool,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { signOut, useSession } from "@homarr/auth/client";
|
import { signOut, useSession } from "@homarr/auth/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
@@ -19,38 +30,42 @@ import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
|||||||
|
|
||||||
interface UserAvatarMenuProps {
|
interface UserAvatarMenuProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||||
isDockerEnabled?: boolean;
|
isDockerEnabled?: boolean;
|
||||||
opened: boolean;
|
|
||||||
onOpenChange: (opened: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserAvatarMenu = ({
|
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||||
children,
|
|
||||||
availableUpdates,
|
|
||||||
isDockerEnabled,
|
|
||||||
opened,
|
|
||||||
onOpenChange,
|
|
||||||
}: UserAvatarMenuProps) => {
|
|
||||||
const t = useScopedI18n("common.userAvatar.menu");
|
const t = useScopedI18n("common.userAvatar.menu");
|
||||||
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
|
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||||
|
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const { logoutUrl } = useAuthContext();
|
const { logoutUrl } = useAuthContext();
|
||||||
|
const { openModal } = useModalAction(LogoutModal);
|
||||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||||
|
|
||||||
const handleSignout = useCallback(async () => {
|
const handleSignout = useCallback(async () => {
|
||||||
const redirectUrl = logoutUrl ?? "/auth/login";
|
|
||||||
await signOut({
|
await signOut({
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
window.location.assign(redirectUrl);
|
openModal({
|
||||||
}, [logoutUrl]);
|
onTimeout: () => {
|
||||||
|
if (logoutUrl) {
|
||||||
|
window.location.assign(logoutUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/auth/login");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [logoutUrl, openModal, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||||
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
|
<Menu width={300} withinPortal keepMounted>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||||
{t("homeBoard")}
|
{t("homeBoard")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -98,3 +113,35 @@ export const UserAvatarMenu = ({
|
|||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LogoutModal = createModal<{ onTimeout: () => void }>(({ actions, innerProps }) => {
|
||||||
|
const t = useScopedI18n("common.userAvatar.menu");
|
||||||
|
const { start } = useTimeout(() => {
|
||||||
|
actions.closeModal();
|
||||||
|
innerProps.onTimeout();
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
start();
|
||||||
|
}, [start]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center h={200 - 2 * 16}>
|
||||||
|
<Stack align="center" c="green">
|
||||||
|
<IconCheck size={50} />
|
||||||
|
<Text ta="center" fw="bold">
|
||||||
|
{t("loggedOut")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}).withOptions({
|
||||||
|
centered: true,
|
||||||
|
withCloseButton: false,
|
||||||
|
transitionProps: {
|
||||||
|
transition: "pop",
|
||||||
|
},
|
||||||
|
size: 200,
|
||||||
|
closeOnClickOutside: false,
|
||||||
|
closeOnEscape: false,
|
||||||
|
});
|
||||||
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user