Compare commits

..
Author SHA1 Message Date
Thomas Camlong f612e2f917 fix widget polling manifest 2026-08-03 20:14:35 +02:00
Thomas c7e4b30c2d Merge branch 'dev' into feat/build-performance 2026-08-03 17:39:27 +02:00
Thomas Camlong 366ec6bdbc fix custom widget e2e login race 2026-08-03 15:52:08 +02:00
Thomas Camlong 9f4b147f0e fix legacy media server option mapping 2026-08-03 15:29:27 +02:00
Thomas Camlong 08af704c8a Merge origin/dev into feat/build-performance 2026-08-03 15:21:23 +02:00
Thomas Camlong 3f9fb215c6 fix: avoid ambiguous benchmark regex 2026-08-03 14:27:21 +02:00
Thomas Camlong de801f3f00 perf: optimize dashboard loading and builds 2026-08-02 15:58:16 +02:00
ajnart d90e762e4b fix: align Navidrome media sessions 2026-07-31 13:52:39 +02:00
ajnart 81a13d3b52 Merge remote-tracking branch 'origin/dev' into feat/build-performance 2026-07-31 13:38:16 +02:00
ajnart 388a74086e fix: address Copilot cache review 2026-07-31 13:32:48 +02:00
ajnart cd6c83a7b0 test: guard LDAP user lookup 2026-07-30 00:50:02 +02:00
ajnart 9963ba0274 fix: harden runtime isolation and regression tests 2026-07-30 00:38:34 +02:00
ajnart 5d9f05e083 test(e2e): open spotlight with hotkey 2026-07-29 07:53:35 +02:00
ajnart d48f299585 test(e2e): wait for authenticated board 2026-07-29 07:42:57 +02:00
ajnart c80e9878de fix: handle lazy startup failures 2026-07-29 07:23:09 +02:00
ajnart 84f87ce24e fix(ci): bound affected task concurrency 2026-07-29 07:11:26 +02:00
ajnart fbb57c8f40 fix(docker): build runtime workspaces only 2026-07-29 06:57:06 +02:00
ajnart 0b9c4d6375 fix: address performance review feedback 2026-07-29 06:50:13 +02:00
ajnart 7266e15356 style: auto-fix lint and format 2026-07-29 06:47:07 +02:00
ajnart c22c733463 build(docker): simplify manifest copy layers 2026-07-29 06:47:07 +02:00
ajnart 440626ba00 test(performance): add authenticated local benchmarks 2026-07-29 06:47:07 +02:00
ajnart f8d8a09c56 perf(proxy): isolate settings database reads 2026-07-29 06:47:07 +02:00
ajnart add6ee56ba fix(updates): enforce a persisted daily check 2026-07-29 06:47:07 +02:00
ajnart 6b74078766 perf(widgets): lazy load the application graph 2026-07-29 06:47:07 +02:00
ajnart 4ca0b747bb chore(next): upgrade preview and isolate dev services 2026-07-29 06:47:07 +02:00
175 changed files with 7160 additions and 11756 deletions

No files matched your search

-2
View File
@@ -33,8 +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.75.0
- 1.74.0
- 1.73.0 - 1.73.0
- 1.72.0 - 1.72.0
- 1.71.0 - 1.71.0
+35
View File
@@ -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
-47
View File
@@ -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
-4
View File
@@ -1,7 +1,3 @@
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31) ## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24) ## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
-8
View File
@@ -206,14 +206,6 @@ packages/integrations/src/unraid @manuel-rw
apps/docs/docs/integrations/uptime-kuma @ajnart apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart packages/integrations/src/uptime-kuma @ajnart
# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn
apps/docs/docs/integrations/whats-up-docker @tiimonn
apps/docs/docs/widgets/whats-up-docker @tiimonn
packages/api/src/router/widgets/wud.ts @tiimonn
packages/integrations/src/wud @tiimonn
packages/request-handler/src/wud.ts @tiimonn
packages/widgets/src/wud @tiimonn
# Traefik - by @Nerdy-Technician # Traefik - by @Nerdy-Technician
apps/docs/docs/integrations/traefik @Nerdy-Technician apps/docs/docs/integrations/traefik @Nerdy-Technician
apps/docs/docs/widgets/traefik @Nerdy-Technician apps/docs/docs/widgets/traefik @Nerdy-Technician
+12 -6
View File
@@ -15,17 +15,24 @@ COPY patches ./patches
COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./ COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./
# @homarr/definitions generates documentation types during install. # @homarr/definitions generates documentation types during install.
COPY --parents ./packages/definitions/src ./ COPY --parents ./packages/definitions/src ./
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
# content-addressable store is populated). By temporarily switching to the
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
# which IS created by pnpm fetch. The original hoisted linker is restored
# before the install step so the final node_modules layout stays flat.
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 && \
pnpm fetch --ignore-scripts sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch --ignore-scripts && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
# Install only from the fetched, committed lockfile so local and Docker builds # Install only from the fetched, committed lockfile so local and Docker builds
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's # resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
# atomic binary replacement racing across the workspace (pnpm/pnpm#8200). # atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
# --force re-runs native build scripts (better-sqlite3 etc.) that the offline
# store otherwise considers "already built" without the actual artifacts.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1 --force npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
COPY . . COPY . .
@@ -88,6 +95,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"]
+2 -2
View File
@@ -55,8 +55,8 @@ pnpm typecheck
docs/ docs/
├── getting-started/ # Installation guides, glossary, prerequisites ├── getting-started/ # Installation guides, glossary, prerequisites
├── management/ # Boards, apps, users, integrations, settings, tasks, API ├── management/ # Boards, apps, users, integrations, settings, tasks, API
├── integrations/ # 56 integration guides (one folder each) ├── integrations/ # 55 integration guides (one folder each)
├── widgets/ # 43 widget guides (one folder each) ├── widgets/ # 42 widget guides (one folder each)
├── advanced/ # SSO, env vars, proxy, styling, CLI, development ├── advanced/ # SSO, env vars, proxy, styling, CLI, development
└── community/ # FAQ, donate, license, translations, get-in-touch └── community/ # FAQ, donate, license, translations, get-in-touch
``` ```
@@ -25,14 +25,13 @@ 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 Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | | `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
## Authentication ## Authentication
+9 -18
View File
@@ -9,19 +9,17 @@ tags:
--- ---
## Icon picker ## Icon picker
Icons in Homarr are automatically requested from multiple sources: Icons in Homarr are automatically requested from multiple sources:
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons - [selfh.st icons](https://github.com/selfhst/icons)
- [selfh.st icons](https://github.com/selfhst/icons) - [Simple icons](https://github.com/simple-icons/simple-icons)
- [Simple icons](https://github.com/simple-icons/simple-icons) - [Tabler icons](https://tabler-icons.io/)
- [Tabler icons](https://tabler-icons.io/) - [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme) - [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets) - **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips. Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats. The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
### Finding Missing Icons ### Finding Missing Icons
@@ -31,13 +29,6 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right. Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
### Using external icons ### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon. If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it. Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
### Uploading a local image
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
+1
View File
@@ -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">
![Version: 8.27.0](https://img.shields.io/badge/Version-8.27.0-informational?style=flat) ![Version: 8.25.0](https://img.shields.io/badge/Version-8.25.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.75.0](https://img.shields.io/badge/AppVersion-v1.75.0-informational?style=flat) ![AppVersion: v1.73.0](https://img.shields.io/badge/AppVersion-v1.73.0-informational?style=flat)
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.75.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 |
@@ -1,48 +0,0 @@
---
title: "What's Up Docker"
description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { wudIntegration } from ".";
import { wudWidget } from "@site/docs/widgets/whats-up-docker";
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
:::
### Secrets
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
],
},
]}
/>
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const wudIntegration: IntegrationDefinition = {
name: "What's Up Docker",
description:
"What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
path: "../../integrations/whats-up-docker",
};
+1 -4
View File
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL ### Icon URL
This is the URL to the icon that will be displayed on the app widget. This is the URL to the icon that will be displayed on the app widget.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories. You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
![Icon URL](./img/app-icon-url.png) ![Icon URL](./img/app-icon-url.png)
@@ -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
+30 -67
View File
@@ -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.
![](./img/create-board-modal.png) ![](./img/create-board-modal.png)
### 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:
![](./img/navigate-to-settings.png) ![](./img/navigate-to-settings.png)
@@ -55,12 +50,8 @@ Alternatively, you can also navigate from the board page itself using the cog ic
![](./img/navigate-to-settings-2.png) ![](./img/navigate-to-settings-2.png)
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:
![](./img/page-title.png) ![](./img/page-title.png)
@@ -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):
![](./img/background-repeat.png) ![](./img/background-repeat.png)
### 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
![](./img/duplicate-board-modal.png) ![](./img/duplicate-board-modal.png)
## 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:
![](./img/home-board-flag.png) ![](./img/home-board-flag.png)
### 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:
![](./img/set-as-home-board-menu.png) ![](./img/set-as-home-board-menu.png)
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`` \
![](./img/set-home-board-other-user.png) ![](./img/set-home-board-other-user.png)
### 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`` \
![](./img/set-home-board-user-group.png) ![](./img/set-home-board-user-group.png)
### 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`` \
![](./img/set-home-board-global.png) ![](./img/set-home-board-global.png)
:::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.
+53 -83
View File
@@ -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.
@@ -1,35 +0,0 @@
---
title: "What's Up Docker"
description: "Displays how many watched containers have image updates available."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { wudWidget } from ".";
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: wudIntegration,
note: "Displays monitored container and available-update counts",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={wudWidget.configuration} />
@@ -1,37 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconBrandDocker } from "@tabler/icons-react";
export const wudWidget: WidgetDefinition = {
icon: IconBrandDocker,
name: "What's Up Docker",
description: "Displays how many watched containers have image updates available.",
path: "../../widgets/whats-up-docker",
configuration: {
items: [
{
name: "Show title",
description: "Displays the icon and \"What's Up Docker\" label above the stats",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show update ring",
description: "Displays a ring showing the percentage of monitored containers with a pending update",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Layout",
description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)",
values: { type: "select", options: ["Horizontal", "Vertical"] },
defaultValue: "Horizontal",
},
{
name: "Show container update list",
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
-2
View File
@@ -30,7 +30,6 @@
"@mdx-js/react": "^3.1.1", "@mdx-js/react": "^3.1.1",
"@nivo/line": "^0.99.0", "@nivo/line": "^0.99.0",
"@scalar/api-reference-react": "catalog:", "@scalar/api-reference-react": "catalog:",
"@scalar/types": "catalog:",
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2", "@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
"@tabler/icons-react": "catalog:", "@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.2", "@tailwindcss/postcss": "^4.3.2",
@@ -53,7 +52,6 @@
"@docusaurus/types": "^3.10.1", "@docusaurus/types": "^3.10.1",
"@fec/remark-a11y-emoji": "^4.0.2", "@fec/remark-a11y-emoji": "^4.0.2",
"@tsconfig/docusaurus": "^2.0.9", "@tsconfig/docusaurus": "^2.0.9",
"@types/node": "catalog:",
"@types/react": "catalog:", "@types/react": "catalog:",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
-5
View File
@@ -50,10 +50,8 @@
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/websocket": "workspace:^0.1.0", "@homarr/websocket": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/charts": "catalog:",
"@mantine/colors-generator": "catalog:", "@mantine/colors-generator": "catalog:",
"@mantine/core": "catalog:", "@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@mantine/dropzone": "catalog:", "@mantine/dropzone": "catalog:",
"@mantine/hooks": "catalog:", "@mantine/hooks": "catalog:",
"@mantine/modals": "catalog:", "@mantine/modals": "catalog:",
@@ -90,9 +88,7 @@
"mantine-react-table": "catalog:", "mantine-react-table": "catalog:",
"mcp-handler": "catalog:", "mcp-handler": "catalog:",
"next": "catalog:", "next": "catalog:",
"next-intl": "catalog:",
"postcss-preset-mantine": "catalog:", "postcss-preset-mantine": "catalog:",
"postcss-simple-vars": "catalog:",
"posthog-js": "catalog:", "posthog-js": "catalog:",
"prismjs": "catalog:", "prismjs": "catalog:",
"react": "catalog:", "react": "catalog:",
@@ -103,7 +99,6 @@
"sql.js": "catalog:", "sql.js": "catalog:",
"superjson": "catalog:", "superjson": "catalog:",
"trpc-to-mcp": "catalog:", "trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:",
"use-deep-compare-effect": "catalog:", "use-deep-compare-effect": "catalog:",
"yaml": "catalog:", "yaml": "catalog:",
"zod": "catalog:" "zod": "catalog:"
@@ -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>
);
};
@@ -697,11 +697,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<Stack gap="sm"> <Stack gap="sm">
<TextInput label={t("field.name")} required {...form.getInputProps("name")} /> <TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} {...form.getInputProps("description")} /> <Textarea label={t("field.description")} {...form.getInputProps("description")} />
<IconPicker <IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</Stack> </Stack>
</Fieldset> </Fieldset>
@@ -57,10 +57,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<IconPicker <IconPicker {...form.getInputProps("iconUrl")} />
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Fieldset legend={t("search.engine.page.edit.configControl")}> <Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl <SegmentedControl
@@ -1,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>
</>
); );
}; };
@@ -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>
);
};
@@ -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>
</>
); );
}; };
@@ -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>
</> </>
); );
@@ -1,17 +1,38 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { useEffect, useState } from "react"; import { Suspense, use, useEffect, useState } from "react";
import { UnstyledButton, useMantineColorScheme } from "@mantine/core"; import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { ErrorBoundary } from "react-error-boundary";
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 { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update"; 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 { interface UserButtonClientProps {
avatar: ReactNode; avatar: ReactNode;
isAdmin: boolean; isAdmin: boolean;
@@ -20,6 +41,8 @@ interface UserButtonClientProps {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => { export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false); const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const [isMenuMounted, setIsMenuMounted] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const session = useSession(); const session = useSession();
const { toggleColorScheme } = useMantineColorScheme(); const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]); useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -43,14 +66,60 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
staleTime: 60 * 60 * 1_000, staleTime: 60 * 60 * 1_000,
}); });
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined; 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 ( return (
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}> <ErrorBoundary
<UnstyledButton> fallbackRender={({ resetErrorBoundary }) =>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}> renderButton(() => {
{avatar} resetErrorBoundary();
</UpdateIndicator> openMenu();
</UnstyledButton> })
</UserAvatarMenu> }
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
); );
}; };
@@ -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>
);
};
@@ -21,9 +21,17 @@ interface UserAvatarMenuProps {
children: ReactNode; children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"]; availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean; isDockerEnabled?: boolean;
opened: boolean;
onOpenChange: (opened: boolean) => void;
} }
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => { export const UserAvatarMenu = ({
children,
availableUpdates,
isDockerEnabled,
opened,
onOpenChange,
}: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu"); const t = useScopedI18n("common.userAvatar.menu");
const session = useSession(); const session = useSession();
@@ -40,7 +48,7 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
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> <Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
<Menu.Dropdown> <Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} /> <AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}> <Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
-2
View File
@@ -26,14 +26,12 @@
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@trpc/server": "catalog:",
"dotenv": "catalog:", "dotenv": "catalog:",
"tsx": "catalog:", "tsx": "catalog:",
"ws": "catalog:" "ws": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/ws": "catalog:", "@types/ws": "catalog:",
"esbuild": "catalog:", "esbuild": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
-7
View File
@@ -429,13 +429,6 @@
<br/> <br/>
<p align="center">Uptime<br/>Kuma</p> <p align="center">Uptime<br/>Kuma</p>
</a> </a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/whats-up-docker" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg" alt="What's Up Docker" width="90" height="90" />
<br/>
<p align="center">What's<br/>Up<br/>Docker</p>
</a>
</td></tr> </td></tr>
</tbody> </tbody>
</table> </table>
+1 -4
View File
@@ -45,10 +45,7 @@ describe("Custom JSX custom widgets", () => {
await page.goto(`${baseUrl}/auth/login`); await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username); await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password); await page.locator("#password").fill(adminCredentials.password);
const loginNavigation = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", { const loginNavigation = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
timeout: 60_000,
waitUntil: "commit",
});
await page.locator("css=button[type='submit']").click(); await page.locator("css=button[type='submit']").click();
await loginNavigation; await loginNavigation;
+1 -1
View File
@@ -5,7 +5,7 @@ events {
http { http {
server { server {
listen 7575; listen 7575;
${NGINX_LISTEN_IPV6} listen [::]:7575;
# Route websockets traffic to port 3001 # Route websockets traffic to port 3001
location /websockets { location /websockets {
+1 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.75.0", "version": "1.73.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
@@ -61,13 +61,11 @@
"@semantic-release/release-notes-generator": "catalog:", "@semantic-release/release-notes-generator": "catalog:",
"@testcontainers/redis": "catalog:", "@testcontainers/redis": "catalog:",
"@turbo/gen": "catalog:", "@turbo/gen": "catalog:",
"@types/node-unifi": "catalog:",
"@vitejs/plugin-react": "catalog:", "@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:", "@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:", "@vitest/ui": "catalog:",
"conventional-changelog-conventionalcommits": "catalog:", "conventional-changelog-conventionalcommits": "catalog:",
"cross-env": "catalog:", "cross-env": "catalog:",
"dotenv-cli": "catalog:",
"jsdom": "catalog:", "jsdom": "catalog:",
"json5": "catalog:", "json5": "catalog:",
"oxfmt": "catalog:", "oxfmt": "catalog:",
-1
View File
@@ -30,7 +30,6 @@
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
+1 -9
View File
@@ -47,31 +47,23 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "catalog:", "@kubernetes/client-node": "catalog:",
"@paralleldrive/cuid2": "catalog:",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tiptap/react": "catalog:",
"@trpc/client": "catalog:", "@trpc/client": "catalog:",
"@trpc/react-query": "catalog:", "@trpc/react-query": "catalog:",
"@trpc/server": "catalog:", "@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:", "@trpc/tanstack-react-query": "catalog:",
"drizzle-orm": "catalog:",
"jsonpath-plus": "catalog:", "jsonpath-plus": "catalog:",
"lodash.clonedeep": "catalog:", "lodash.clonedeep": "catalog:",
"next": "catalog:", "next": "catalog:",
"pg": "catalog:",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"superjson": "catalog:", "superjson": "catalog:",
"trpc-to-mcp": "catalog:", "trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:", "trpc-to-openapi": "catalog:",
"zod": "catalog:", "zod": "catalog:"
"zod-form-data": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/pg": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
+1 -1
View File
@@ -173,7 +173,7 @@ export const appRouter = createTRPCRouter({
mcp: { mcp: {
enabled: true, enabled: true,
description: description:
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL or custom URI scheme such as vscode://, or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)", "Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
}, },
}) })
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
+31 -34
View File
@@ -1,4 +1,4 @@
import { getImageMatchRank, normalizeImageName } from "@homarr/common"; import { and, like } from "@homarr/db";
import { icons } from "@homarr/db/schema"; import { icons } from "@homarr/db/schema";
import { iconsFindSchema } from "@homarr/validation/icons"; import { iconsFindSchema } from "@homarr/validation/icons";
@@ -15,41 +15,38 @@ export const iconsRouter = createTRPCRouter({
}) })
.input(iconsFindSchema) .input(iconsFindSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const term = normalizeImageName(input.searchText ?? ""); const term = input.searchText?.toLowerCase().trim() ?? "";
const repositories = await ctx.db.query.iconRepositories.findMany({ const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
orderBy: (table, { asc, sql }) => [sql`CASE WHEN ${table.slug} = 'local' THEN 0 ELSE 1 END`, asc(table.slug)],
with: { let whereCondition = undefined;
icons: { if (term.length > 0) {
columns: { id: true, name: true, url: true }, whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
orderBy: (table, { asc, sql }) => [ }
sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`,
asc(table.name),
],
limit: term.length === 0 ? input.limitPerGroup : undefined,
},
},
});
return { return {
icons: icons: await ctx.db.query.iconRepositories.findMany({
term.length === 0 with: {
? repositories icons: {
: repositories.map((repository) => ({ columns: { id: true, name: true, url: true },
...repository, where: whereCondition,
icons: repository.icons orderBy: (table, { asc, sql }) => {
.flatMap((icon) => { const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
const rank = getImageMatchRank(term, icon.name || icon.url); const nameAsc = asc(table.name);
return rank === null ? [] : [{ icon, rank }]; if (term.length === 0) {
}) return [svgFirst, nameAsc];
.toSorted((a, b) => }
a.rank !== b.rank const tier = sql`CASE
? a.rank - b.rank WHEN LOWER(${table.name}) = ${term} THEN 0
: Number(b.icon.name.toLowerCase().endsWith(".svg")) - WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
Number(a.icon.name.toLowerCase().endsWith(".svg")) || a.icon.name.localeCompare(b.icon.name), WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
) ELSE 3
.slice(0, input.limitPerGroup) END`;
.map(({ icon }) => icon), return [tier, svgFirst, nameAsc];
})), },
limit: input.limitPerGroup,
},
},
}),
countIcons: await ctx.db.$count(icons), countIcons: await ctx.db.$count(icons),
}; };
}), }),
@@ -1,44 +0,0 @@
import { describe, expect, test } from "vitest";
import { iconRepositories, icons } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { iconsRouter } from "../icons";
describe("findIcons", () => {
test.each(["homeassistant", "home_assistant", "home assistant.png"])(
"matches normalized search %s before applying the group limit",
async (searchText) => {
const db = createDb();
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
await db.insert(iconRepositories).values({ id: "repository", slug: "dashboard-icons" });
await db.insert(icons).values([
{
id: "irrelevant",
name: "000-unrelated.svg",
url: "https://example.com/000-unrelated.svg",
checksum: "irrelevant",
iconRepositoryId: "repository",
},
{
id: "home-assistant",
name: "home-assistant.svg",
url: "https://example.com/home-assistant.svg",
checksum: "home-assistant",
iconRepositoryId: "repository",
},
]);
const result = await caller.findIcons({ searchText, limitPerGroup: 1 });
expect(result.icons[0]?.icons).toEqual([
{
id: "home-assistant",
name: "home-assistant.svg",
url: "https://example.com/home-assistant.svg",
},
]);
},
);
});
-1
View File
@@ -44,5 +44,4 @@ export const widgetRouter = createTRPCRouter({
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)), traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)), customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)), secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
}); });
-25
View File
@@ -1,25 +0,0 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const wudRouter = createTRPCRouter({
getStats: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneIntegrationMiddleware("query", "wud"))
.query(async ({ ctx }) => {
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
const { data, timestamp } = await handler.getDataAsync();
return {
stats: data,
updatedAt: timestamp,
};
}),
});
-3
View File
@@ -32,7 +32,6 @@
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"bcrypt": "catalog:", "bcrypt": "catalog:",
"cookies": "catalog:", "cookies": "catalog:",
"dayjs": "catalog:",
"ldapts": "catalog:", "ldapts": "catalog:",
"next": "catalog:", "next": "catalog:",
"next-auth": "catalog:", "next-auth": "catalog:",
@@ -44,8 +43,6 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "catalog:", "@types/bcrypt": "catalog:",
"@types/cookies": "catalog:", "@types/cookies": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
-5
View File
@@ -24,16 +24,11 @@
}, },
"dependencies": { "dependencies": {
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@mantine/hooks": "catalog:",
"next": "catalog:",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:" "react-dom": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
-1
View File
@@ -33,7 +33,6 @@
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"esbuild": "catalog:", "esbuild": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
-10
View File
@@ -26,17 +26,11 @@
}, },
"dependencies": { "dependencies": {
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
"@mantine/core": "catalog:",
"@paralleldrive/cuid2": "catalog:", "@paralleldrive/cuid2": "catalog:",
"@tanstack/query-core": "catalog:",
"@types/node-fetch": "catalog:",
"axios": "catalog:",
"cookie": "catalog:", "cookie": "catalog:",
"dayjs": "catalog:", "dayjs": "catalog:",
"next": "catalog:", "next": "catalog:",
"node-fetch": "catalog:",
"octokit": "catalog:", "octokit": "catalog:",
"ofetch": "catalog:",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"undici": "catalog:", "undici": "catalog:",
@@ -45,10 +39,6 @@
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/node-fetch": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
-31
View File
@@ -28,34 +28,3 @@ export const bestMatch = <TItem extends Record<string, unknown>>(
return best; return best;
}, null); }, null);
}; };
export const normalizeImageName = (value: string) => {
const fileName = value.split(/[?#]/, 1)[0]?.split("/").pop() ?? value;
const withoutExtension = fileName.replace(/\.(?:avif|gif|jpe?g|png|svg|webp)$/i, "");
return withoutExtension
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/&/g, " and ")
.replace(/[^\p{L}\p{M}\p{N}]+/gu, " ")
.trim()
.toLowerCase();
};
export const getImageMatchRank = (normalizedSearch: string, candidateValue: string): number | null => {
const candidate = normalizeImageName(candidateValue);
if (!candidate) return null;
const compactSearch = normalizedSearch.replaceAll(" ", "");
const compactCandidate = candidate.replaceAll(" ", "");
if (candidate === normalizedSearch) return 0;
if (compactCandidate === compactSearch) return 1;
if (candidate.startsWith(`${normalizedSearch} `)) return 2;
const searchTokens = normalizedSearch.split(" ");
const candidateTokens = candidate.split(" ");
if (searchTokens.every((term) => candidateTokens.some((token) => token === term))) return 3;
if (searchTokens.every((term) => candidateTokens.some((token) => token.startsWith(term)))) return 4;
if (candidate.includes(normalizedSearch)) return 5;
return null;
};
+1 -25
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { bestMatch, capitalize, getImageMatchRank, normalizeImageName } from "../string"; import { bestMatch, capitalize } from "../string";
const capitalizeTestCases = [ const capitalizeTestCases = [
["hello", "Hello"], ["hello", "Hello"],
@@ -54,27 +54,3 @@ describe("bestMatch should find the best match in an array of options", () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("image name matching", () => {
test.each([
["home-assistant.svg", "home assistant"],
["https://example.com/Home_Assistant.PNG?v=1", "home assistant"],
["jelly.fin.webp", "jelly fin"],
["Caf\u00e9.svg", "cafe"],
["\u6771\u4eac.svg", "\u6771\u4eac"],
["\u0939\u093f\u0928\u094d\u0926\u0940.svg", "\u0939\u093f\u0928\u094d\u0926\u0940"],
])("normalizes %s", (value, expected) => {
expect(normalizeImageName(value)).toBe(expected);
});
test.each([
["homeassistant", "home-assistant.svg", 1],
["home assistant", "home_assistant.png", 0],
["home assistant", "Home Assistant.webp", 0],
["\u6771\u4eac", "\u6771\u4eac.svg", 0],
["\u0939\u093f\u0928\u094d\u0926\u0940", "\u0939\u093f\u0928\u094d\u0926\u0940.png", 0],
["home assistant", "unrelated.svg", null],
])("ranks %s against %s", (search, candidate, expected) => {
expect(getImageMatchRank(normalizeImageName(search), candidate)).toBe(expected);
});
});
-4
View File
@@ -37,7 +37,6 @@
}, },
"dependencies": { "dependencies": {
"@t3-oss/env-nextjs": "catalog:", "@t3-oss/env-nextjs": "catalog:",
"axios": "catalog:",
"better-sqlite3": "catalog:", "better-sqlite3": "catalog:",
"dns-caching": "catalog:", "dns-caching": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
@@ -45,15 +44,12 @@
"mysql2": "catalog:", "mysql2": "catalog:",
"pg": "catalog:", "pg": "catalog:",
"superjson": "catalog:", "superjson": "catalog:",
"undici": "catalog:",
"winston": "catalog:", "winston": "catalog:",
"winston-transport": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "catalog:", "@types/better-sqlite3": "catalog:",
"@types/node": "catalog:",
"@types/pg": "catalog:", "@types/pg": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
-1
View File
@@ -26,7 +26,6 @@
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
-1
View File
@@ -30,7 +30,6 @@
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
-5
View File
@@ -38,16 +38,11 @@
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"croner": "catalog:",
"drizzle-orm": "catalog:",
"pg": "catalog:",
"semver-parser": "catalog:", "semver-parser": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/pg": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
-2
View File
@@ -51,7 +51,6 @@
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "catalog:", "@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@paralleldrive/cuid2": "catalog:", "@paralleldrive/cuid2": "catalog:",
"@testcontainers/mysql": "catalog:", "@testcontainers/mysql": "catalog:",
"@testcontainers/postgresql": "catalog:", "@testcontainers/postgresql": "catalog:",
@@ -67,7 +66,6 @@
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "catalog:", "@types/better-sqlite3": "catalog:",
"@types/node": "catalog:",
"@types/pg": "catalog:", "@types/pg": "catalog:",
"dotenv-cli": "catalog:", "dotenv-cli": "catalog:",
"esbuild": "catalog:", "esbuild": "catalog:",
-1
View File
@@ -30,7 +30,6 @@
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"tsx": "catalog:", "tsx": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
@@ -210,7 +210,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/unifi-controller" | "/docs/integrations/unifi-controller"
| "/docs/integrations/unraid" | "/docs/integrations/unraid"
| "/docs/integrations/uptime-kuma" | "/docs/integrations/uptime-kuma"
| "/docs/integrations/whats-up-docker"
| "/docs/management/api" | "/docs/management/api"
| "/docs/management/apps" | "/docs/management/apps"
| "/docs/management/backup" | "/docs/management/backup"
@@ -277,7 +276,6 @@ export type HomarrDocumentationPath =
| "/docs/widgets/video" | "/docs/widgets/video"
| "/docs/widgets/vpn" | "/docs/widgets/vpn"
| "/docs/widgets/weather" | "/docs/widgets/weather"
| "/docs/widgets/whats-up-docker"
| "" | ""
| "/sitemap.xml" | "/sitemap.xml"
| "/docs/integrations/sabnzbd" | "/docs/integrations/sabnzbd"
@@ -332,7 +330,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/peanut" | "/docs/integrations/peanut"
| "/docs/integrations/beszel" | "/docs/integrations/beszel"
| "/docs/integrations/traefik" | "/docs/integrations/traefik"
| "/docs/integrations/whats-up-docker"
| "/docs/widgets/clock" | "/docs/widgets/clock"
| "/docs/widgets/weather" | "/docs/widgets/weather"
| "/docs/widgets/app" | "/docs/widgets/app"
@@ -385,5 +382,4 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-alerts" | "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats" | "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik" | "/docs/widgets/traefik"
| "/docs/widgets/custom-api" | "/docs/widgets/custom-api";
| "/docs/widgets/whats-up-docker";
@@ -54,6 +54,5 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
beszel: "beszel", beszel: "beszel",
traefik: "traefik", traefik: "traefik",
archiveTeamWarrior: null, archiveTeamWarrior: null,
wud: "whats-up-docker",
mock: null, mock: null,
}; };
@@ -55,5 +55,4 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats", beszelSystemStats: "beszel-system-stats",
traefik: "traefik", traefik: "traefik",
customApi: "custom-api", customApi: "custom-api",
wud: "whats-up-docker",
}; };
-8
View File
@@ -468,14 +468,6 @@ export const integrationDefs = {
category: ["archiving"], category: ["archiving"],
documentationUrl: null, documentationUrl: null,
}, },
wud: {
name: "What's Up Docker",
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"),
defaultPort: 3000,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: { mock: {
name: "Mock", name: "Mock",
@@ -24,9 +24,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
networkControllerSummary: getIntegrationKindsByCategory("networkController"), networkControllerSummary: getIntegrationKindsByCategory("networkController"),
networkControllerStatus: getIntegrationKindsByCategory("networkController"), networkControllerStatus: getIntegrationKindsByCategory("networkController"),
indexerManager: getIntegrationKindsByCategory("indexerManager"), indexerManager: getIntegrationKindsByCategory("indexerManager"),
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter( healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
(kind) => kind !== "patchmon" && kind !== "wud",
),
firewall: getIntegrationKindsByCategory("firewall"), firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"), notifications: getIntegrationKindsByCategory("notifications"),
mediaReleases: ["mock", "emby", "jellyfin", "plex"], mediaReleases: ["mock", "emby", "jellyfin", "plex"],
@@ -52,7 +50,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
archiveTeamWarrior: ["archiveTeamWarrior"], archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"], anchorNote: ["anchor"],
traefik: ["traefik"], traefik: ["traefik"],
wud: ["wud"],
}; };
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => { export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
-1
View File
@@ -53,7 +53,6 @@ export const widgetKinds = [
"beszelSystemStats", "beszelSystemStats",
"traefik", "traefik",
"customApi", "customApi",
"wud",
] as const; ] as const;
export type WidgetKind = (typeof widgetKinds)[number]; export type WidgetKind = (typeof widgetKinds)[number];
+1 -4
View File
@@ -25,14 +25,11 @@
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
"@mantine/core": "catalog:", "dockerode": "catalog:"
"dockerode": "catalog:",
"zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "catalog:", "@types/dockerode": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
-3
View File
@@ -26,13 +26,10 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "catalog:", "@mantine/form": "catalog:",
"react": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
-8
View File
@@ -30,19 +30,11 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "catalog:", "@mantine/core": "catalog:",
"@mantine/hooks": "catalog:",
"@tabler/icons-react": "catalog:",
"@trpc/server": "catalog:",
"next": "catalog:",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
@@ -1,89 +1,3 @@
.dropdown { [data-combobox-selected="true"] .iconCard {
min-width: min(32rem, calc(100vw - 2rem));
}
.options {
max-height: min(28rem, 60vh);
overflow-y: auto;
overscroll-behavior: contain;
}
.iconGrid {
grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr));
}
.iconOption {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
height: 3.25rem;
padding: var(--mantine-spacing-xs);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms ease;
}
.iconOption:hover,
.iconOption[data-combobox-selected="true"] {
border-color: var(--mantine-primary-color-6); border-color: var(--mantine-primary-color-6);
background: var(--mantine-primary-color-light);
}
.iconOption:active {
transform: scale(0.96);
}
.formatBadge,
.sectionBadge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--mantine-radius-xs);
background: var(--mantine-color-red-9);
color: var(--mantine-color-white);
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
}
.formatBadge {
position: absolute;
top: 0.2rem;
right: 0.2rem;
padding: 0.15rem 0.2rem;
}
.sectionBadge {
padding: 0.2rem 0.3rem;
}
.urlPreview {
background: var(--mantine-color-default-hover);
}
.previewFrame {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
flex: 0 0 3rem;
}
.previewLoader {
position: absolute;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.iconOption {
transition: none;
}
} }
@@ -4,84 +4,31 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vi
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import type { Root } from "react-dom/client"; import type { Root } from "react-dom/client";
import type { IconGroup } from "./icon-picker.utils";
const { findIconsQuery, refetch, uploadSelection, sessionPermissions } = vi.hoisted(() => ({ const { findIconsQuery } = vi.hoisted(() => ({
findIconsQuery: vi.fn(), findIconsQuery: vi.fn((_query: { searchText: string }) => ({
refetch: vi.fn(), data: { icons: [], countIcons: 0 },
uploadSelection: vi.fn(), isLoading: false,
sessionPermissions: { current: [] as string[] }, isError: false,
refetch: vi.fn(),
})),
})); }));
interface QueryData {
icons: IconGroup[];
countIcons: number;
}
const emptyResult: QueryData = { icons: [], countIcons: 11_000 };
let queryResult: {
data?: QueryData;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
};
vi.mock("@homarr/api/client", () => ({ vi.mock("@homarr/api/client", () => ({
clientApi: { clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } },
icon: {
findIcons: {
useQuery: (input: { searchText: string; limitPerGroup: number }, options: { enabled: boolean }) => {
findIconsQuery(input, options);
return { ...queryResult, refetch };
},
},
},
},
})); }));
vi.mock("@homarr/auth/client", () => ({ vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: { user: { permissions: sessionPermissions.current } } }), useSession: () => ({ data: { user: { permissions: [] } } }),
})); }));
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] })); vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
vi.mock("@homarr/translation/client", () => ({ vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, params?: Record<string, string>) => useScopedI18n: () => (key: string) => key,
params ? `${key}:${Object.values(params).join(",")}` : key,
}));
vi.mock("../upload-media/upload-media", () => ({
UploadMedia: ({
children,
onSuccess,
}: {
children: (props: object) => unknown;
onSuccess: (medias: unknown) => void;
}) =>
children({
loading: false,
onClick: () => {
uploadSelection();
onSuccess([{ id: "uploaded", url: "/api/user-medias/uploaded" }]);
},
}),
})); }));
vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
import { IconPicker } from "./icon-picker"; import { IconPicker } from "./icon-picker";
const directImageUrl = "https://cdn.example.com/shopify.svg"; const directIconUrl = "https://cdn.example.com/shopify.svg";
const iconGroups = [
{
id: "remote",
slug: "dashboard-icons",
icons: [
{ id: "png", name: "shopify.png", url: "https://cdn.example.com/shopify.png" },
{ id: "svg", name: "shopify.svg", url: "https://cdn.example.com/shopify.svg" },
],
},
{
id: "local",
slug: "local",
icons: [{ id: "local-icon", name: "my-shop.png", url: "/api/user-medias/local-icon" }],
},
];
const setInputValue = (input: HTMLInputElement, value: string) => { const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
@@ -90,18 +37,6 @@ const setInputValue = (input: HTMLInputElement, value: string) => {
input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("input", { bubbles: true }));
}; };
const pressKey = (input: HTMLInputElement, key: string, init?: KeyboardEventInit) => {
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }));
};
const focusInput = async (input: HTMLInputElement) => {
await act(async () => input.focus());
};
const advanceDebounce = async () => {
await act(async () => vi.advanceTimersByTimeAsync(300));
};
describe("IconPicker", () => { describe("IconPicker", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
let root: Root; let root: Root;
@@ -110,9 +45,9 @@ describe("IconPicker", () => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true }); Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
writable: true, writable: true,
value: (media: string) => ({ value: (query: string) => ({
matches: false, matches: false,
media, media: query,
onchange: null, onchange: null,
addListener: () => undefined, addListener: () => undefined,
removeListener: () => undefined, removeListener: () => undefined,
@@ -121,186 +56,54 @@ describe("IconPicker", () => {
dispatchEvent: () => false, dispatchEvent: () => false,
}), }),
}); });
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { value: () => undefined, writable: true });
}); });
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers();
findIconsQuery.mockClear(); findIconsQuery.mockClear();
refetch.mockClear();
uploadSelection.mockClear();
sessionPermissions.current = [];
queryResult = { data: emptyResult, isLoading: false, isFetching: false, isError: false };
}); });
afterEach(() => { afterEach(() => {
act(() => root.unmount()); root.unmount();
container.remove(); container.remove();
document.querySelectorAll("[data-combobox-dropdown]").forEach((element) => element.remove());
vi.useRealTimers();
}); });
const renderPicker = async ( const renderPicker = async (onChange: (value: string) => void) => {
onChange: (value: string) => void,
props: Partial<React.ComponentProps<typeof IconPicker>> = {},
) => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
await act(async () => { await act(async () => {
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange, ...props }))); root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange })));
}); });
const input = container.querySelector("input"); const input = container.querySelector("input");
if (!input) throw new Error("Icon picker input was not rendered"); if (!input) throw new Error("Icon picker input was not rendered");
return input; return input;
}; };
test("clears an existing value without restoring it", async () => { test("keeps a direct URL visible without searching the icon repositories", async () => {
const onChange = vi.fn(); const onChange = vi.fn();
const input = await renderPicker(onChange, { value: directImageUrl }); const input = await renderPicker(onChange);
await act(async () => setInputValue(input, "")); await act(async () => {
setInputValue(input, directIconUrl);
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(onChange).toHaveBeenCalledWith(""); expect(onChange).toHaveBeenCalledWith(directIconUrl);
expect(input.value).toBe(""); expect(input.value).toBe(directIconUrl);
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
}); });
test.each([{ metaKey: true }, { ctrlKey: true }])( test("keeps icon names in repository search mode", async () => {
"clears the entire value with a line-delete shortcut", const onChange = vi.fn();
async (modifier) => { const input = await renderPicker(onChange);
const onChange = vi.fn();
const input = await renderPicker(onChange, { value: directImageUrl });
await act(async () => pressKey(input, "Backspace", modifier)); await act(async () => {
setInputValue(input, "shopify");
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(onChange).toHaveBeenCalledWith(""); expect(onChange).not.toHaveBeenCalled();
expect(input.value).toBe(""); expect(input.value).toBe("shopify");
},
);
test("debounces repository searches", async () => {
const input = await renderPicker(vi.fn());
findIconsQuery.mockClear();
await act(async () => setInputValue(input, "shopify"));
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(false);
await advanceDebounce();
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true); expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true);
}); });
test("pre-searches the suggested app name when opened", async () => {
const input = await renderPicker(vi.fn(), { suggestedSearch: "Jellyfin" });
await focusInput(input);
expect(input.value).toBe("Jellyfin");
await advanceDebounce();
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "Jellyfin")).toBe(true);
});
test("shows local images before SVG and raster library images", async () => {
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
const input = await renderPicker(vi.fn());
await focusInput(input);
const content = document.body.textContent ?? "";
expect(document.body.querySelector('[role="listbox"]')?.getAttribute("aria-label")).toBe("iconPicker.results");
expect(content.indexOf("iconPicker.localImages")).toBeLessThan(content.indexOf("iconPicker.svgIcons"));
expect(content.indexOf("iconPicker.svgIcons")).toBeLessThan(content.indexOf("iconPicker.otherImages"));
});
test("selects a library image with a pointer", async () => {
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
const onChange = vi.fn();
const input = await renderPicker(onChange);
await focusInput(input);
const option = document.body.querySelector<HTMLElement>(
'[data-combobox-option][value="/api/user-medias/local-icon"]',
);
if (!option) throw new Error("Local image option was not rendered");
await act(async () => option.click());
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
expect(input.value).toBe("/api/user-medias/local-icon");
});
test("selects a library image with the keyboard", async () => {
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
const onChange = vi.fn();
const input = await renderPicker(onChange);
await focusInput(input);
await act(async () => pressKey(input, "ArrowDown"));
await act(async () => pressKey(input, "Enter"));
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
});
test("keeps a direct URL visible and reports a successful preview", async () => {
const onChange = vi.fn();
const input = await renderPicker(onChange);
await act(async () => setInputValue(input, directImageUrl));
expect(onChange).toHaveBeenCalledWith(directImageUrl);
expect(input.value).toBe(directImageUrl);
expect(findIconsQuery.mock.calls.at(-1)?.[1]).toMatchObject({ enabled: false });
expect(document.body.textContent).toContain("iconPicker.urlLoading");
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
if (!preview) throw new Error("Direct URL preview was not rendered");
await act(async () => preview.dispatchEvent(new Event("load")));
expect(document.body.textContent).toContain("iconPicker.urlReady");
});
test("keeps a failed direct URL editable and shows an error", async () => {
const input = await renderPicker(vi.fn());
await act(async () => setInputValue(input, "https://invalid.example/image.png"));
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
if (!preview) throw new Error("Direct URL preview was not rendered");
await act(async () => preview.dispatchEvent(new Event("error")));
expect(input.value).toBe("https://invalid.example/image.png");
expect(document.body.textContent).toContain("iconPicker.urlError");
});
test("restores the committed value when an unfinished search is cancelled", async () => {
const input = await renderPicker(vi.fn(), { value: directImageUrl });
await act(async () => setInputValue(input, "unfinished search"));
await act(async () => pressKey(input, "Escape"));
expect(input.value).toBe(directImageUrl);
});
test("offers retry when the repository search fails", async () => {
queryResult = { data: undefined, isLoading: false, isFetching: false, isError: true };
const input = await renderPicker(vi.fn());
await focusInput(input);
const retry = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent === "action.tryAgain",
);
if (!retry) throw new Error("Retry button was not rendered");
await act(async () => retry.click());
expect(refetch).toHaveBeenCalledOnce();
});
test("selects an uploaded local image when the user has permission", async () => {
sessionPermissions.current = ["media-upload"];
const onChange = vi.fn();
await renderPicker(onChange);
const upload = container.querySelector<HTMLButtonElement>('[aria-label="iconPicker.uploadImage"]');
if (!upload) throw new Error("Upload button was not rendered");
await act(async () => upload.click());
expect(uploadSelection).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith("/api/user-medias/uploaded");
});
}); });
@@ -1,36 +1,27 @@
"use client"; "use client";
import type { FocusEventHandler, KeyboardEventHandler } from "react"; import type { FocusEventHandler } from "react";
import { startTransition, useEffect, useMemo, useState } from "react"; import { startTransition, useEffect, useState } from "react";
import { import {
ActionIcon, ActionIcon,
Box, Box,
Button, Button,
CloseButton, Card,
Combobox, Combobox,
Flex,
Group, Group,
Image, Image,
Indicator,
InputBase, InputBase,
Loader,
Paper, Paper,
SimpleGrid,
Skeleton, Skeleton,
Stack, Stack,
Text, Text,
ThemeIcon, UnstyledButton,
Tooltip,
useCombobox, useCombobox,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks"; import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
IconCheck,
IconCloud,
IconMoodEmpty,
IconPhoto,
IconPhotoOff,
IconSearchOff,
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";
@@ -38,7 +29,6 @@ import { supportedLanguages } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { UploadMedia } from "../upload-media/upload-media"; import { UploadMedia } from "../upload-media/upload-media";
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
import classes from "./icon-picker.module.css"; import classes from "./icon-picker.module.css";
interface IconPickerProps { interface IconPickerProps {
@@ -49,12 +39,9 @@ interface IconPickerProps {
onBlur?: FocusEventHandler; onBlur?: FocusEventHandler;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
suggestedSearch?: string | null;
withAsterisk?: boolean; withAsterisk?: boolean;
} }
type PreviewState = "idle" | "loading" | "ready" | "error";
export const IconPicker = ({ export const IconPicker = ({
value: propsValue, value: propsValue,
onChange, onChange,
@@ -64,403 +51,188 @@ export const IconPicker = ({
withAsterisk = true, withAsterisk = true,
label, label,
placeholder, placeholder,
suggestedSearch,
}: IconPickerProps) => { }: IconPickerProps) => {
const [value, setValue] = useUncontrolled({ value: propsValue, onChange, defaultValue: "" }); const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
const [draft, setDraft] = useState(propsValue ?? ""); const [query, setQuery] = useState("");
const [hasEdited, setHasEdited] = useState(false);
const [keyboardIndex, setKeyboardIndex] = useState(-1); useEffect(() => {
const [previewState, setPreviewState] = useState<PreviewState>("idle"); setQuery("");
}, [value]);
const { data: session } = useSession(); const { data: session } = useSession();
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const [debouncedDraft] = useDebouncedValue(draft, 250); const [debouncedQuery] = useDebouncedValue(query, 100);
const directUrl = isDirectImageUrl(draft) ? draft.trim() : null; const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : "";
const isCommittedValueDraft = debouncedDraft === (value ?? "");
const searchText = directUrl !== null || isCommittedValueDraft ? "" : debouncedDraft.trim();
const canUpload = session?.user.permissions.includes("media-upload") ?? false;
const query = clientApi.icon.findIcons.useQuery( const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
{ searchText, limitPerGroup: 24 }, { searchText },
{ enabled: directUrl === null, placeholderData: (previous) => previous }, { placeholderData: (prev) => prev },
); );
const combobox = useCombobox({ const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(), onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
}); });
useEffect(() => { const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
setDraft(value ?? ""); const groups = data?.icons.map((group) => {
setHasEdited(false); const options = group.icons.map((item) => (
}, [value]); <Combobox.Option
key={item.id}
useEffect(() => { value={item.url}
setPreviewState(directUrl ? "loading" : "idle"); p={0}
}, [directUrl]); h="calc(2*var(--mantine-spacing-sm)+25px)"
w="calc(2*var(--mantine-spacing-sm)+25px)"
const sections = useMemo( >
() => arrangeIconPickerSections(query.data?.icons ?? [], searchText), <UnstyledButton
[query.data?.icons, searchText], onClick={() => {
); startTransition(() => {
const orderedOptions = useMemo( setValue(item.url);
() => [...sections.local, ...sections.svg, ...sections.other], setQuery("");
[sections.local, sections.svg, sections.other], combobox.closeDropdown();
); });
const totalOptions = sections.local.length + sections.svg.length + sections.other.length; }}
>
const commitValue = (nextValue: string) => { <Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
startTransition(() => { <Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
setValue(nextValue); <Box w={25} h={25}>
setDraft(nextValue); <Image src={item.url} w={25} h={25} />
setHasEdited(false); </Box>
setKeyboardIndex(-1); </Card>
}); </Indicator>
}; </UnstyledButton>
</Combobox.Option>
const clearValue = () => { ));
commitValue("");
combobox.openDropdown("keyboard");
};
const openWithSuggestedSearch = () => {
if (!draft && !value && !hasEdited && suggestedSearch?.trim()) {
setDraft(suggestedSearch.trim());
}
combobox.openDropdown();
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Backspace") {
event.preventDefault();
clearValue();
return;
}
if (event.key === "Escape") {
setDraft(value ?? "");
setHasEdited(false);
combobox.closeDropdown("keyboard");
return;
}
if (!directUrl && orderedOptions.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
combobox.openDropdown("keyboard");
setKeyboardIndex((current) => {
const next =
event.key === "ArrowDown"
? current >= orderedOptions.length - 1
? 0
: current + 1
: current <= 0
? orderedOptions.length - 1
: current - 1;
combobox.selectOption(next);
return next;
});
return;
}
if (event.key === "Enter") {
if (keyboardIndex >= 0 && orderedOptions[keyboardIndex]) {
event.preventDefault();
commitValue(orderedOptions[keyboardIndex].url);
combobox.closeDropdown("keyboard");
} else if (isImageSource(draft)) {
event.preventDefault();
commitValue(draft.trim());
combobox.closeDropdown("keyboard");
}
}
};
const renderIconOptions = (icons: typeof sections.local, offset: number) => (
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6}>
{icons.map((icon, index) => (
<Tooltip key={icon.id} label={`${icon.name} · ${icon.repositorySlug}`} openDelay={500}>
<Combobox.Option
value={icon.url}
aria-label={icon.name}
active={icon.url === value || keyboardIndex === offset + index}
className={classes.iconOption}
>
<Image src={icon.url} alt="" w={30} h={30} fit="contain" fallbackSrc="/logo/logo.png" />
{isSvgImage(icon.url) && <span className={classes.formatBadge}>SVG</span>}
</Combobox.Option>
</Tooltip>
))}
</SimpleGrid>
);
const renderSearchResults = () => {
if (query.isError) {
return (
<PickerMessage
icon={<IconSearchOff size={22} />}
title={tCommon("iconPicker.error")}
action={
<Button size="xs" variant="default" onClick={() => query.refetch()}>
{tCommon("action.tryAgain")}
</Button>
}
/>
);
}
if (query.isLoading) {
return (
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6} p="xs">
{Array.from({ length: 12 }, (_, index) => (
<Skeleton key={index} height={52} radius="md" />
))}
</SimpleGrid>
);
}
if (totalOptions === 0) {
return (
<PickerMessage
icon={<IconMoodEmpty size={22} />}
title={tCommon("iconPicker.noResults", { search: debouncedDraft.trim() })}
/>
);
}
return ( return (
<Stack gap="sm" p="xs"> <Paper p="xs" key={group.slug} pt={2}>
{sections.local.length > 0 && ( <Text mb={8} size="sm" fw="bold">
<PickerSection icon={<IconPhoto size={15} />} label={tCommon("iconPicker.localImages")}> {group.slug}
{renderIconOptions(sections.local, 0)} </Text>
</PickerSection> <Flex gap={8} wrap={"wrap"}>
)} {options}
{sections.svg.length > 0 && ( </Flex>
<PickerSection label={tCommon("iconPicker.svgIcons")} badge="SVG"> </Paper>
{renderIconOptions(sections.svg, sections.local.length)} );
</PickerSection> });
)}
{sections.other.length > 0 && ( const inputValue = query || value || "";
<PickerSection icon={<IconCloud size={15} />} label={tCommon("iconPicker.otherImages")}> const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
{renderIconOptions(sections.other, sections.local.length + sections.svg.length)} const placeholderText =
</PickerSection> placeholder ||
)} (data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
tCommon("iconPicker.headerLoading");
const renderDropdown = () => {
if (isError) {
return (
<Stack align="center" gap="xs" p="md">
<IconSearchOff size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.error")}</Text>
<Button size="xs" variant="default" onClick={() => refetch()}>
{tCommon("action.tryAgain")}
</Button>
</Stack>
);
}
if (isLoading) {
return Array(15)
.fill(0)
.map((_, index) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
));
}
if (totalOptions > 0) {
return <Stack gap={4}>{groups}</Stack>;
}
return (
<Stack align="center" gap="xs" p="md">
<IconMoodEmpty size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
</Stack> </Stack>
); );
}; };
const leftSection = (() => {
if (directUrl && previewState === "loading") return <Loader size={15} />;
if (directUrl && previewState === "error") return <IconPhotoOff size={17} color="var(--mantine-color-red-6)" />;
if (shouldShowPreview(value)) {
return (
<Image
src={value}
alt=""
w={22}
h={22}
fit="contain"
onLoad={() => directUrl && setPreviewState("ready")}
onError={() => directUrl && setPreviewState("error")}
/>
);
}
return <IconPhoto size={17} stroke={1.5} />;
})();
const placeholderText =
placeholder ??
(query.data
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
: tCommon("iconPicker.headerLoading"));
return ( return (
<Combobox <Combobox store={combobox} withinPortal>
store={combobox} <Combobox.Target>
withinPortal <Group wrap="nowrap" gap="xs" w="100%" align="start">
resetSelectionOnOptionHover <InputBase
onOptionSubmit={(url) => { flex={1}
commitValue(url); rightSection={<Combobox.Chevron />}
combobox.closeDropdown(); leftSection={leftSection}
}} value={inputValue}
> onChange={(event) => {
<Combobox.Target withExpandedAttribute> const nextValue = event.currentTarget.value;
<InputBase const trimmedValue = nextValue.trim();
flex={1} setQuery(nextValue);
leftSection={leftSection} if (isDirectIconUrl(trimmedValue)) {
rightSection={ setValue(trimmedValue);
<Group gap={2} wrap="nowrap"> combobox.closeDropdown();
{canUpload && ( } else {
<UploadMedia combobox.openDropdown();
onSuccess={(medias) => { }
const first = medias.at(0); }}
if (!first) return; onClick={() => combobox.openDropdown()}
commitValue(first.url); onFocus={(event) => {
combobox.closeDropdown(); onFocus?.(event);
}} combobox.openDropdown();
> }}
{({ onClick, loading }) => ( onBlur={(event) => {
<Tooltip label={tCommon("iconPicker.uploadImage")}> onBlur?.(event);
<ActionIcon combobox.closeDropdown();
onClick={(event) => { setQuery("");
event.stopPropagation(); }}
onClick(); rightSectionPointerEvents="none"
}} withAsterisk={withAsterisk}
loading={loading} error={error}
size="sm" label={label ?? tCommon("iconPicker.label")}
variant="subtle" placeholder={placeholderText}
aria-label={tCommon("iconPicker.uploadImage")} />
> {session?.user.permissions.includes("media-upload") && (
<IconUpload size={16} stroke={1.5} /> <UploadMedia
</ActionIcon> onSuccess={(medias) => {
</Tooltip> const first = medias.at(0);
)} if (!first) return;
</UploadMedia> startTransition(() => {
setValue(first.url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)} )}
{(draft || value) && ( </UploadMedia>
<CloseButton )}
size="sm" </Group>
aria-label={tCommon("iconPicker.clear")}
onMouseDown={(event) => event.preventDefault()}
onClick={clearValue}
/>
)}
{query.isFetching && !directUrl ? <Loader size={14} /> : <Combobox.Chevron />}
</Group>
}
rightSectionWidth={canUpload ? 86 : 56}
rightSectionPointerEvents="all"
value={draft}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setDraft(nextValue);
setHasEdited(true);
setKeyboardIndex(-1);
combobox.openDropdown("keyboard");
combobox.updateSelectedOptionIndex();
if (!nextValue) {
setValue("");
} else if (isImageSource(nextValue)) {
setValue(nextValue.trim());
}
}}
onKeyDown={handleKeyDown}
onClick={openWithSuggestedSearch}
onFocus={(event) => {
onFocus?.(event);
openWithSuggestedSearch();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setDraft(value ?? "");
setHasEdited(false);
}}
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
description={tCommon("iconPicker.description")}
styles={{
description: { color: "var(--mantine-color-text)" },
input: { color: "var(--mantine-color-text)" },
error: { color: "light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2))" },
}}
placeholder={placeholderText}
autoComplete="off"
spellCheck={false}
/>
</Combobox.Target> </Combobox.Target>
<Combobox.Dropdown className={classes.dropdown}> <Combobox.Dropdown>
<Combobox.Options className={classes.options} aria-label={tCommon("iconPicker.results")}> <Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{directUrl ? ( {renderDropdown()}
<Paper p="sm" m="xs" className={classes.urlPreview}>
<Group wrap="nowrap" align="center">
<Box className={classes.previewFrame}>
{previewState === "loading" && <Loader size={18} className={classes.previewLoader} />}
{previewState === "error" ? (
<ThemeIcon variant="light" color="red" size={40}>
<IconPhotoOff size={21} />
</ThemeIcon>
) : (
<Image
src={directUrl}
alt=""
w={40}
h={40}
fit="contain"
data-testid="direct-url-preview"
onLoad={() => setPreviewState("ready")}
onError={() => setPreviewState("error")}
/>
)}
</Box>
<Box flex={1} miw={0}>
<Text size="sm" fw={600}>
{previewState === "error"
? tCommon("iconPicker.urlError")
: previewState === "ready"
? tCommon("iconPicker.urlReady")
: tCommon("iconPicker.urlLoading")}
</Text>
<Text size="xs" truncate>
{directUrl}
</Text>
</Box>
{previewState === "ready" && (
<ThemeIcon variant="light" color="green" size="sm" aria-hidden>
<IconCheck size={14} />
</ThemeIcon>
)}
</Group>
</Paper>
) : (
renderSearchResults()
)}
</Combobox.Options> </Combobox.Options>
</Combobox.Dropdown> </Combobox.Dropdown>
</Combobox> </Combobox>
); );
}; };
interface PickerSectionProps { // This regex is used to prevent loading a preview like en or /en which would trigger a language change
label: string; // See https://github.com/homarr-labs/homarr/issues/3070
icon?: React.ReactNode;
badge?: string;
children: React.ReactNode;
}
const PickerSection = ({ label, icon, badge, children }: PickerSectionProps) => (
<Stack gap={6}>
<Group gap={6} px={2}>
{icon}
<Text size="xs" fw={600}>
{label}
</Text>
{badge && <span className={classes.sectionBadge}>{badge}</span>}
</Group>
{children}
</Stack>
);
interface PickerMessageProps {
icon: React.ReactNode;
title: string;
action?: React.ReactNode;
}
const PickerMessage = ({ icon, title, action }: PickerMessageProps) => (
<Stack align="center" gap="xs" p="lg" ta="center">
<ThemeIcon variant="light" color="gray" size="lg">
{icon}
</ThemeIcon>
<Text size="sm">{title}</Text>
{action}
</Stack>
);
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i"); const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
const shouldShowPreview = (value: string | null | undefined): value is string => { const shouldShowPreview = (value: string | null | undefined): value is string => {
if (!value) return false; if (!value) return false;
return !localizationPathRegex.test(value); return !localizationPathRegex.test(value);
}; };
const isDirectIconUrl = (value: string) => {
try {
const url = new URL(value.trim());
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
} catch {
return false;
}
};
@@ -1,50 +0,0 @@
import { describe, expect, test } from "vitest";
import type { IconGroup } from "./icon-picker.utils";
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
const groups = [
{
id: "remote",
slug: "dashboard-icons",
icons: [
{ id: "long", name: "home-assistant-alt.svg", url: "https://cdn.example.com/home-assistant-alt.svg" },
{ id: "raster", name: "Home Assistant.png", url: "https://cdn.example.com/home-assistant.png" },
{ id: "svg", name: "home-assistant.svg", url: "https://cdn.example.com/home-assistant.svg?v=1" },
],
},
{
id: "local",
slug: "local",
icons: [{ id: "local", name: "Home Assistant.png", url: "/api/user-medias/home-assistant" }],
},
] as IconGroup[];
describe("icon picker utilities", () => {
test("arranges local images before remote SVG and raster images", () => {
const result = arrangeIconPickerSections(groups, "home assistant");
expect(result.local.map((icon) => icon.id)).toEqual(["local"]);
expect(result.svg.map((icon) => icon.id)).toEqual(["svg", "long"]);
expect(result.other.map((icon) => icon.id)).toEqual(["raster"]);
});
test.each([
["https://example.com/icon.png", true],
["http://localhost:3000/icon", true],
["ftp://example.com/icon.png", false],
["javascript:alert(1)", false],
["example.com/icon.png", false],
])("validates direct image URLs", (value, expected) => {
expect(isDirectImageUrl(value)).toBe(expected);
});
test("accepts internal image paths without treating search text as a source", () => {
expect(isImageSource("/api/user-medias/123")).toBe(true);
expect(isImageSource("shopify")).toBe(false);
});
test("recognizes SVG URLs with query strings case-insensitively", () => {
expect(isSvgImage("https://example.com/ICON.SVG?raw=1")).toBe(true);
});
});
@@ -1,67 +0,0 @@
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@homarr/api";
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
type RouterOutput = inferRouterOutputs<AppRouter>;
export type IconGroup = RouterOutput["icon"]["findIcons"]["icons"][number];
export type PickerIcon = IconGroup["icons"][number] & { repositorySlug: string };
export interface IconPickerSections {
local: PickerIcon[];
svg: PickerIcon[];
other: PickerIcon[];
}
export const arrangeIconPickerSections = (iconGroups: IconGroup[], searchTerm = ""): IconPickerSections => {
const sections: IconPickerSections = { local: [], svg: [], other: [] };
for (const group of iconGroups) {
for (const icon of group.icons) {
const pickerIcon = { ...icon, repositorySlug: group.slug };
if (group.slug === "local") {
sections.local.push(pickerIcon);
} else if (isSvgImage(icon.url)) {
sections.svg.push(pickerIcon);
} else {
sections.other.push(pickerIcon);
}
}
}
const normalizedSearch = normalizeImageName(searchTerm);
if (normalizedSearch) {
const compareMatches = (a: PickerIcon, b: PickerIcon) => {
const aRank = getImageMatchRank(normalizedSearch, a.name || a.url) ?? Number.MAX_SAFE_INTEGER;
const bRank = getImageMatchRank(normalizedSearch, b.name || b.url) ?? Number.MAX_SAFE_INTEGER;
return aRank - bRank || a.name.localeCompare(b.name);
};
return {
local: sections.local.toSorted(compareMatches),
svg: sections.svg.toSorted(compareMatches),
other: sections.other.toSorted(compareMatches),
};
}
return sections;
};
export const isDirectImageUrl = (value: string) => {
try {
const url = new URL(value.trim());
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
} catch {
return false;
}
};
export const isImageSource = (value: string) => isDirectImageUrl(value) || value.trim().startsWith("/");
export const isSvgImage = (value: string) => {
try {
const url = new URL(value, "http://homarr.local");
return url.pathname.toLowerCase().endsWith(".svg");
} catch {
return value.toLowerCase().split(/[?#]/, 1)[0]?.endsWith(".svg") ?? false;
}
};
+1
View File
@@ -2,6 +2,7 @@ export * from "./new-app/_app-new-form";
export * from "./new-app/_form"; export * from "./new-app/_form";
export * from "./icon-picker/icon-picker"; export * from "./icon-picker/icon-picker";
export * from "./new-app/icon-matcher";
export * from "./upload-media/upload-media"; export * from "./upload-media/upload-media";
@@ -3,15 +3,17 @@
import type { ChangeEventHandler } from "react"; import type { ChangeEventHandler } from "react";
import { useEffect, useImperativeHandle, useRef } from "react"; import { useEffect, useImperativeHandle, useRef } from "react";
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { appManageSchema } from "@homarr/validation/app"; import { appManageSchema } from "@homarr/validation/app";
import { IconPicker } from "../icon-picker/icon-picker"; import { IconPicker } from "../icon-picker/icon-picker";
import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer<typeof appManageSchema>; type FormType = z.infer<typeof appManageSchema>;
@@ -79,6 +81,9 @@ export const AppForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValuesKey]); }, [initialValuesKey]);
// Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200);
const shouldCreateAnother = useRef(false); const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => { const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current; const redirect = !shouldCreateAnother.current;
@@ -125,13 +130,29 @@ export const AppForm = ({
} }
}; };
// Auto-select icon based on app name with debounced search
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
{
searchText: debouncedName,
},
{
enabled: debouncedName.length > 3,
},
);
useEffect(() => {
if (debouncedName && !form.values.iconUrl && iconsData?.icons) {
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
if (bestMatch) {
form.setFieldValue("iconUrl", bestMatch);
}
}
}, [debouncedName, iconsData]);
const formFields = ( const formFields = (
<Stack> <Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} /> <TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker <IconPicker {...form.getInputProps("iconUrl")} />
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Textarea <Textarea
{...form.getInputProps("description")} {...form.getInputProps("description")}
label={t("app.field.description.label")} label={t("app.field.description.label")}
@@ -0,0 +1,30 @@
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@homarr/api";
type RouterOutput = inferRouterOutputs<AppRouter>;
type IconGroupsOutput = RouterOutput["icon"]["findIcons"]["icons"];
export const findBestIconMatch = (searchTerm: string, iconGroups: IconGroupsOutput): string | null => {
const nameLower = searchTerm.toLowerCase();
const allIcons = iconGroups.flatMap((group) => group.icons);
const getIconPriority = (iconUrl: string) => {
const fileName = iconUrl.toLowerCase().split("/").pop()?.split(".")[0];
if (!fileName) return -1;
const isSvg = iconUrl.endsWith(".svg");
const isExactMatch = fileName === nameLower;
if (isExactMatch) return isSvg ? 0 : 1;
if (fileName.includes(nameLower)) return isSvg ? 2 : 3;
return -1;
};
for (let priority = 0; priority <= 3; priority++) {
const match = allIcons.find((icon) => getIconPriority(icon.url) === priority);
if (match) return match.url;
}
return null;
};
-1
View File
@@ -28,7 +28,6 @@
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
-1
View File
@@ -29,7 +29,6 @@
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "catalog:", "@types/bcrypt": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
-8
View File
@@ -38,16 +38,10 @@
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@immich/sdk": "catalog:", "@immich/sdk": "catalog:",
"@jellyfin/sdk": "catalog:", "@jellyfin/sdk": "catalog:",
"@mantine/core": "catalog:",
"axios": "catalog:",
"dayjs": "catalog:",
"http-cookie-agent": "catalog:",
"ical.js": "catalog:", "ical.js": "catalog:",
"maria2": "catalog:", "maria2": "catalog:",
"node-fetch": "catalog:",
"octokit": "catalog:", "octokit": "catalog:",
"proxmox-api": "catalog:", "proxmox-api": "catalog:",
"superjson": "catalog:",
"timezones-ical-library": "catalog:", "timezones-ical-library": "catalog:",
"tsdav": "catalog:", "tsdav": "catalog:",
"undici": "catalog:", "undici": "catalog:",
@@ -57,8 +51,6 @@
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/node-fetch": "catalog:",
"@types/node-unifi": "catalog:", "@types/node-unifi": "catalog:",
"@types/ws": "catalog:", "@types/ws": "catalog:",
"@types/xml2js": "catalog:", "@types/xml2js": "catalog:",
@@ -54,7 +54,6 @@ import { UmamiIntegration } from "../umami/umami-integration";
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration"; import { UnraidIntegration } from "../unraid/unraid-integration";
import { WudIntegration } from "../wud/wud-integration";
import type { Integration, IntegrationInput } from "./integration"; import type { Integration, IntegrationInput } from "./integration";
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>( export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
@@ -134,7 +133,6 @@ export const integrationCreators = {
beszel: BeszelIntegration, beszel: BeszelIntegration,
bazarr: BazarrIntegration, bazarr: BazarrIntegration,
traefik: TraefikIntegration, traefik: TraefikIntegration,
wud: WudIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>; } satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = { type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
-2
View File
@@ -1,6 +1,5 @@
// General integrations // General integrations
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration"; export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
export { WudIntegration } from "./wud/wud-integration";
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration"; export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
export { AnchorIntegration } from "./anchor/anchor-integration"; export { AnchorIntegration } from "./anchor/anchor-integration";
@@ -92,7 +91,6 @@ export type {
ArchiveTeamWarriorItem, ArchiveTeamWarriorItem,
ArchiveTeamWarriorStatus, ArchiveTeamWarriorStatus,
} from "./archive-team-warrior/archive-team-warrior-types"; } from "./archive-team-warrior/archive-team-warrior-types";
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
// Schemas // Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types"; export { anchorNotesListInputSchema } from "./anchor/anchor-types";
@@ -185,15 +185,9 @@ export class OverseerrIntegration
}), }),
); );
const fulfilled = settled return settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled") .filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value); .map((result) => result.value);
if (fulfilled.length === 0) {
throw new Error("Failed to resolve any media request information");
}
return fulfilled;
} }
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus { protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -85,11 +85,6 @@ export class SynologyClient {
if (volumesFromV2.length > 0) { if (volumesFromV2.length > 0) {
return volumesFromV2; return volumesFromV2;
} }
const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info);
if (legacyVolumesFromV2.length > 0) {
return legacyVolumesFromV2;
}
} catch (error) { } catch (error) {
logger.debug("storage_v2 unavailable, falling back to legacy storage info", { logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
integrationId: this.integrationId, integrationId: this.integrationId,
@@ -78,7 +78,6 @@ export const synologyStorageV2VolumeSchema = z
export const synologyStorageV2DataSchema = z.object({ export const synologyStorageV2DataSchema = z.object({
volumes: z.array(synologyStorageV2VolumeSchema).optional(), volumes: z.array(synologyStorageV2VolumeSchema).optional(),
vol_info: z.array(synologyVolumeInfoSchema).optional(),
}); });
export const synologyUtilizationCpuSchema = z.object({ export const synologyUtilizationCpuSchema = z.object({
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more