Compare commits

..
862 changed files with 14052 additions and 39881 deletions

No files matched your search

+8 -18
View File
@@ -374,31 +374,21 @@ jobs:
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }} AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }} ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
run: | run: |
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}") docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
fi
docker buildx imagetools create "${tags[@]}" \
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \ "${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
"${PREVIEW_IMAGE}@${ARM64_DIGEST}" "${PREVIEW_IMAGE}@${ARM64_DIGEST}"
- name: Verify preview manifest platforms - name: Verify preview manifest platforms
if: "!env.ACT" if: "!env.ACT"
run: | run: |
tags=("${PREVIEW_TAG}") image="${PREVIEW_IMAGE}:${PREVIEW_TAG}"
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then manifest="$(docker buildx imagetools inspect "$image" --raw)"
tags+=("pr-6545") for architecture in amd64 arm64; do
fi jq --exit-status --arg architecture "$architecture" \
for tag in "${tags[@]}"; do 'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
image="${PREVIEW_IMAGE}:${tag}" <<<"$manifest" >/dev/null
manifest="$(docker buildx imagetools inspect "$image" --raw)"
for architecture in amd64 arm64; do
jq --exit-status --arg architecture "$architecture" \
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
<<<"$manifest" >/dev/null
done
docker buildx imagetools inspect "$image"
done done
docker buildx imagetools inspect "$image"
cleanup: cleanup:
name: Clean up preview image name: Clean up preview image
+21 -87
View File
@@ -27,103 +27,37 @@ jobs:
- run: pnpm test:workshop - run: pnpm test:workshop
- run: pnpm test:workshop-image - run: pnpm test:workshop-image
publish-amd64:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
needs: validate
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
id: build
with:
context: .
file: apps/workshop/Dockerfile
target: production
platforms: linux/amd64
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=workshop-linux-amd64
cache-to: type=gha,scope=workshop-linux-amd64,mode=max
publish-arm64:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
needs: validate
runs-on: ubuntu-24.04-arm
timeout-minutes: 20
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
id: build
with:
context: .
file: apps/workshop/Dockerfile
target: production
platforms: linux/arm64
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=workshop-linux-arm64
cache-to: type=gha,scope=workshop-linux-arm64,mode=max
publish: publish:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main') if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
needs: [publish-amd64, publish-arm64] needs: validate
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 5 timeout-minutes: 30
permissions: permissions:
contents: read contents: read
packages: write packages: write
env:
WORKSHOP_IMAGE: ghcr.io/homarr-labs/workshop
steps: steps:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4 - uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish multi-platform image - uses: docker/metadata-action@v6
env: id: metadata
AMD64_DIGEST: ${{ needs.publish-amd64.outputs.digest }} with:
ARM64_DIGEST: ${{ needs.publish-arm64.outputs.digest }} images: ghcr.io/homarr-labs/workshop
run: | tags: |
tags=(-t "${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}") type=sha,prefix=sha-,format=long
if [[ "${GITHUB_REF_NAME}" == "v2" || "${GITHUB_REF_NAME}" == "release/v2" ]]; then type=raw,value=v2,enable=${{ github.ref_name == 'v2' || github.ref_name == 'release/v2' }}
tags+=(-t "${WORKSHOP_IMAGE}:v2") type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
fi - uses: docker/build-push-action@v7
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then with:
tags+=(-t "${WORKSHOP_IMAGE}:latest") context: .
fi file: apps/workshop/Dockerfile
docker buildx imagetools create "${tags[@]}" \ target: production
"${WORKSHOP_IMAGE}@${AMD64_DIGEST}" \ platforms: linux/amd64,linux/arm64
"${WORKSHOP_IMAGE}@${ARM64_DIGEST}" push: true
- name: Verify multi-platform image tags: ${{ steps.metadata.outputs.tags }}
run: | labels: ${{ steps.metadata.outputs.labels }}
image="${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}"
manifest="$(docker buildx imagetools inspect "$image" --raw)"
for architecture in amd64 arm64; do
jq --exit-status --arg architecture "$architecture" \
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
<<<"$manifest" >/dev/null
done
docker buildx imagetools inspect "$image"
-2
View File
@@ -25,8 +25,6 @@ packages/db/migrations/*/migrate.cjs
# production # production
build build
tools/homarr-dev/homarr
tools/homarr-dev/homarr-dev
# misc # misc
.DS_Store .DS_Store
-20
View File
@@ -30,26 +30,6 @@
"options": { "options": {
"reportUnusedDisableDirectives": "off" "reportUnusedDisableDirectives": "off"
}, },
"overrides": [
{
"files": ["apps/nextjs/src/**/*.ts", "apps/nextjs/src/**/*.tsx"],
"excludeFiles": ["**/*.spec.ts", "**/*.spec.tsx"],
"rules": {
"eslint/no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@homarr/widgets",
"allowTypeImports": true,
"message": "Import a narrow @homarr/widgets export to preserve widget lazy loading."
}
]
}
]
}
}
],
"ignorePatterns": [ "ignorePatterns": [
"**/*.config.js", "**/*.config.js",
"**/*.config.mjs", "**/*.config.mjs",
@@ -1,175 +1,43 @@
# Developer Docker CLI # Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images. The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features. To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
## Requirements ## Browse development images
- Docker or OrbStack
- Go 1.25+ (when building the CLI from source)
- GitHub CLI authenticated with `gh auth login` (for pull-request and CI features)
- A [Nerd Font](https://www.nerdfonts.com/) enabled in your terminal (recommended for icons, status indicators, and TUI glyphs)
## Interactive Development Browser
```bash ```bash
pnpm dev:cli -- pnpm dev:cli --
# or with global binary:
homarr
``` ```
The main browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` (or `1`/`2`) to toggle between the image browser and the instance dashboard. The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
A locally built PR image is folded into its remote PR row only when its recorded revision matches the current GitHub head SHA (marked `◆ local`). When the matching GHCR image is also available, pressing `Enter` opens a local/remote selector with local preselected. Outdated local builds remain separate rows. A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
### Multi-Source Sidebar ## Build images
Press `Tab` to cycle between sidebar tabs:
- **`[logs]`**: Live container output streams (press `f` to follow/pause, `L` to pin to the selected container, `Page Up`/`Page Down` to scroll).
- **`[build]`**: Real-time BuildKit step streaming for active local builds.
- **`[ci]`**: GitHub Actions CI check statuses, run duration, and workflow links for pull requests.
### Background Task Overlay
Pulls and builds run asynchronously in the background. Press `t` to open the tasks overlay, showing:
- Layer-by-layer download progress and transfer speeds (via Docker Engine API).
- Active build stages and logs.
- Task cancellation and status.
### Browser Shortcuts
| Key | Action |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| `/` | Filter rows by PR number, title, author, branch, CI, image, state, or port |
| `↑` / `↓` or `j` / `k` | Navigate rows |
| `Enter` or `Space` | Start the available image; choose local or remote when both exist; stop if running |
| `R` | Build locally in background (or rebuild local image) |
| `p` | Pull remote PR image in background and start container |
| `m` | Manage mode (`b` rebuild, `i` delete image, `d` delete data, `c` remove container, `p` prune) |
| `t` | Toggle background tasks overlay |
| `Tab` | Cycle sidebar tabs (`[logs]`, `[build]`, `[ci]`) |
| `l` / `L` | Toggle sidebar visibility / Pin logs to selected instance |
| `f` | Follow or pause logs |
| `M` | Toggle demo mode for next launch |
| `b` | Toggle bot pull requests |
| `o` / `a` | Open PR on GitHub / Open running instance in browser |
| `c` | Copy instance URL to clipboard |
| `r` | Refresh data |
| `1` / `2` / `d` | Switch screen (development browser / instance dashboard) |
| `?` / `q` | Help overlay / Quit (cancels active background tasks) |
## Instance Dashboard
```bash ```bash
homarr dash pnpm dev:cli -- build <name>
# or pnpm dev:cli -- build --pr <number> [name]
pnpm dev:cli -- dash
``` ```
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes. The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
| Key | Action | ## Rebuild images
| ------------------ | ---------------------------------------- |
| `Enter` or `Space` | Start or stop selected instance |
| `s` / `S` | Stop / Restart instance |
| `x` | Remove container (preserves data volume) |
| `m` | Manage mode for selected instance |
| `R` | Rebuild from recorded build provenance |
## CLI Commands
The commands below use the installed `homarr` binary. Without global installation, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- list`).
### Launch Instances
```bash ```bash
homarr run dev pnpm dev:cli -- rebuild <name>
homarr run --detach dev
homarr run --pr 6441
homarr run --pr 6441 --demo
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
``` ```
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`). Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
- `--demo` (`-m`): Start with mock integrations and sample data.
- `--detach`: Start container in background.
- `--env` (`-e`): Pass additional environment variables (repeatable: `--env KEY=VALUE`).
### Build & Rebuild ## Run images
```bash ```bash
# Build current checkout as homarr:<name> homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
homarr build feature homarr run <name>
homarr run --pr <number>
# Build a pull request locally in a temporary checkout
homarr build --pr 6441
# Rebuild an existing image from recorded provenance
homarr rebuild feature
``` ```
Images built with the CLI record their source checkout, revision SHA, and optional PR number. `homarr rebuild` reads this provenance to reconstruct the build without manual configuration. The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
### GitHub CI Checks
```bash
# View CI checks for a specific PR
homarr ci 6441
# View CI checks for the current branch
homarr ci
# Watch CI checks until completion
homarr ci 6441 --watch
```
### Interactive Shell
```bash
# Open interactive shell in running container
homarr shell homarr-dev
# Run specific command in container
homarr shell homarr-dev pnpm db:migration:sqlite:run
```
### Image & Volume Management
```bash
# List local images with build provenance (source, revision, PR)
homarr images
# Delete local image
homarr images rm feature
# List data volumes with owner PR/tag and in-use status
homarr data
# Delete data volume (safely stops and removes owning container first)
homarr data rm homarr_feature_data
# Remove stopped containers (prompts without --force)
homarr prune --force
```
### Instance Operations & Diagnostics
```bash
# List instances in a script-friendly table
homarr list
# Follow container logs
homarr logs homarr-dev
# Stop, restart, remove, or open container in browser
homarr stop homarr-dev
homarr restart homarr-dev
homarr remove homarr-dev
homarr open homarr-dev
# Verify Docker daemon, Engine API, GHCR access, and gh CLI authentication
homarr doctor
```
@@ -25,29 +25,3 @@ You should also create a backup of the database, in case you break something.
4. Click "Execute" in the top right of the Window. ![](img/334116411-df1482f9-3aa9-4686-b77f-e0dca09e60b2.png) 4. Click "Execute" in the top right of the Window. ![](img/334116411-df1482f9-3aa9-4686-b77f-e0dca09e60b2.png)
5. Select /bin/bash and click Execute ![](img/334116518-e1ddb515-99c7-4159-81bf-76e889d1e808.png) 5. Select /bin/bash and click Execute ![](img/334116518-e1ddb515-99c7-4159-81bf-76e889d1e808.png)
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) ![](img/334116642-08c4f204-5cd0-4e4e-a785-0142e1b279ca.png) 6. Run the Homarr command and press enter (eg. homarr reset-owner-password) ![](img/334116642-08c4f204-5cd0-4e4e-a785-0142e1b279ca.png)
## Build a local branch harness
The host-side operations CLI can build the current checkout and run it as a named, browser-ready Docker harness. This is separate from the recovery CLI described above and does not open the interactive developer TUI.
```sh
pnpm cli harness setup feature-board
```
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
The demo board includes every widget kind. Integration-backed widgets use the bundled mock integration; media widgets use public stock assets.
```sh
pnpm cli harness port feature-board
```
Pass environment overrides by repeating `--env`:
```sh
pnpm cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
Other commands are `harness build <name>`, `harness run <name>`, and `harness stop <name>`. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`.
@@ -6,34 +6,19 @@ tags:
Homarr has the following keyboard shortcuts: Homarr has the following keyboard shortcuts:
| Shortcut | Description | | Shortcut | Description |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens search, including local apps, boards, integrations, pages, commands, and preferences | | `MOD` + `K` | Opens the search bar |
| `MOD` + `P` | Opens app search | | `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `/` | Opens Homarr Assistant inside search | | `MOD` + `E` | Toggles Edit Mode |
| `MOD` + `Shift` + `/` (`MOD` + `?`) | Opens preference search | | Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| `Shift` + `A` | Opens the full Homarr Assistant | | `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
| `MOD` + `J` | Toggles Dark Mode | | `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
| `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
## Search and commands
Search is designed as the fastest route to Homarr. Start typing after `MOD` + `K` to find local apps, boards,
integrations, pages, commands, and preferences without entering a separate mode first. Local Homarr results appear
before configured search-engine actions and Assistant fallbacks. Commands include opening the board switcher without
leaving the keyboard. Type `!` to enter Web mode when you want to browse configured engine shortcuts or discover
DuckDuckGo bangs; bang discovery is excluded from ordinary Search results.
Use the dedicated shortcuts above to start in apps, Assistant, or preferences. Inside search, choose a mode to narrow
the same query when needed. Media request search is available only when a supported Seerr integration is configured.
## Widget advanced view ## Widget advanced view
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl` Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press
on Windows/Linux or `Cmd` on macOS over the widget to keep the advanced view open, or use its menu. `Shift` + `Enter`, or use its menu, to keep the advanced view open.
## Board editing ## Board editing
@@ -55,7 +55,7 @@ The studio keeps related choices together:
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup. 2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it. 3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets. 4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets.
5. **Extend** explains and optionally configures Assistant, the community Workshop, and the separate Homarr MCP endpoint. 5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct. 6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
@@ -132,8 +132,8 @@ Here you have the following options in the header:
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page) - **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board. - **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...). - **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
- **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open - **Board switcher**: Use this button to open another board that you can access.
it directly. The current board is left out of the results. - **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions.
When entering the edit mode, you'll get some additional buttons to work with: When entering the edit mode, you'll get some additional buttons to work with:
@@ -151,7 +151,7 @@ When entering the edit mode, you'll get some additional buttons to work with:
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions. Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
stays visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place. keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place.
To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main
@@ -198,13 +198,9 @@ In the modal you can edit the app that you want to display:
<figure> <figure>
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} /> <img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
<figcaption> <figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
</figcaption>
</figure> </figure>
When adding a widget, set its initial grid width and height directly in the preview. These size controls are only shown before the widget is added.
As soon as you save your changes, the tile on the board will refresh and display your app. As soon as you save your changes, the tile on the board will refresh and display your app.
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles. Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
Press and hold a handle, move the pointer, and release it when the tile has the size you want. Press and hold a handle, move the pointer, and release it when the tile has the size you want.
@@ -373,7 +369,7 @@ Each page highlights the list view and the create action so you know where to fi
### Board tour ### Board tour
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu. When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
### Tour controls ### Tour controls
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 68 KiB

+5 -8
View File
@@ -1,5 +1,5 @@
--- ---
description: Configure and use Assistant. description: Configure and use Homarr Assistant.
tags: tags:
- Assistant - Assistant
- AI - AI
@@ -8,10 +8,10 @@ tags:
# Assistant # Assistant
Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for Homarr Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
approval before making changes. approval before making changes.
![Assistant conversation with suggested prompts](/img/assistant/conversation.png) ![Homarr Assistant conversation with suggested prompts](/img/assistant/conversation.png)
## Set it up ## Set it up
@@ -38,21 +38,18 @@ host gateway address.
## Use it ## Use it
- Select **Assistant** in the user menu or press `Shift + A`. - Select **Assistant** in the user menu or press `Shift + A`.
- Press `Cmd/Ctrl + /` to ask from search, or use `Cmd/Ctrl + K` and choose the Assistant fallback. - Ask from `Cmd/Ctrl + K`.
- Add the [Assistant widget](../widgets/assistant) to a board. - Add the [Assistant widget](../widgets/assistant) to a board.
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
add Homarr items as context, `/` for shortcuts, and the paperclip for supported files. add Homarr items as context, `/` for shortcuts, and the paperclip for supported files.
Use **Conversations** to search, switch, rename, export, or delete threads.
Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small
summary of accessible Homarr resources. Live status is fetched only when needed. summary of accessible Homarr resources. Live status is fetched only when needed.
## Permissions and privacy ## Permissions and privacy
- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the - Read-only actions can run automatically. Changes require approval.
current conversation, turn off when you switch conversations, and never answer questions for you.
- Homarr permissions apply to every tool call and mentioned item. - Homarr permissions apply to every tool call and mentioned item.
- API keys and custom headers are encrypted and never returned to the browser. - API keys and custom headers are encrypted and never returned to the browser.
- Conversations are stored per user and can be exported or deleted. - Conversations are stored per user and can be exported or deleted.
+12 -18
View File
@@ -27,11 +27,6 @@ opening it. Containers include their visible widgets and app icons instead of ap
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.
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the
arrow keys to move through the results and press `Enter` to open the selected 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.
@@ -209,14 +204,10 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
style={{ maxWidth: "100%" }} style={{ maxWidth: "100%" }}
/> />
While edit mode is active, **Add board content** opens a menu for adding a widget, app, integration, or Container. The While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
available actions respect the current user's permissions. Widget setup offers the main canvas, enabled rails, and existing Spotlight. It lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
Containers as destinations before the widget is created, so the common flow does not require a second move step. current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
destinations before the widget is created, so the common flow does not require a second move step.
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
selection. A short tip appears after entering edit mode as a reminder of the modifier key. A move is applied only when every
selected item fits in the destination.
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test, The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
@@ -236,7 +227,11 @@ During a pointer or touch interaction, Escape cancels the move or resize and res
Invalid, outside, or otherwise forbidden drops are cancelled in the same way. Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
The **Move / resize item** action in the edit menu remains available for precise numeric input. The **Move / resize item** action in the edit menu remains available for precise numeric input.
<img src={keyboardTileEditingImage} alt="A focused board tile in keyboard editing mode" style={{ maxWidth: "100%" }} /> <img
src={keyboardTileEditingImage}
alt="A focused board tile in keyboard editing mode"
style={{ maxWidth: "100%" }}
/>
#### Preview #### Preview
@@ -317,7 +312,7 @@ Change the radius of your items and Containers.
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.
When Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background, When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
Appearance, Custom CSS, and app-status settings. The assistant reads the current stylesheet first and presents an Appearance, Custom CSS, and app-status settings. The assistant reads the current stylesheet first and presents an
editable review form before requesting approval to save the change. It does not edit responsive layouts, access control, editable review form before requesting approval to save the change. It does not edit responsive layouts, access control,
or Danger zone actions. or Danger zone actions.
@@ -335,9 +330,8 @@ This will disable the status check for apps.
Widgets expose their routine controls without requiring edit mode: Widgets expose their routine controls without requiring edit mode:
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets - Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
remain visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
keep the view open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
instead.
- When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect - When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect
its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do
not use this widget menu, and available controls follow your board and integration permissions. not use this widget menu, and available controls follow your board and integration permissions.
@@ -125,7 +125,7 @@ model judges a result, the candidate must pass Homarr's real clipboard parser, c
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
runtime components. runtime components.
To exercise the real Assistant lifecycle instead of accepting a returned JSON block, run: To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run:
```sh ```sh
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
@@ -41,10 +41,6 @@ needs attention.
4. Write responsive JSX using `data`, `status`, `options`, and temporary `inputs`. 4. Write responsive JSX using `data`, `status`, `options`, and temporary `inputs`.
5. Test and preview, inspect Data and the redacted request journal, then save. 5. Test and preview, inspect Data and the redacted request journal, then save.
While focus is inside a code editor, use `Ctrl+Z`/`Cmd+Z` to undo and `Ctrl+Shift+Z`/`Cmd+Shift+Z` to redo. The toolbar exposes the same actions. This history is local to that editor; builder-wide undo is not yet available.
Changing the definition, instance options, or credentials invalidates the previous preview and its live-action session. Run **Test and preview** again before treating the data or result as current. Automatic load queries are queued with up to four running at once so larger previews stay within the request limit.
<CanonicalCustomWidgetExample <CanonicalCustomWidgetExample
id="named-widget-requests" id="named-widget-requests"
label="Named queries and actions" label="Named queries and actions"
@@ -43,7 +43,7 @@ interface HomarrCustomWidgetV2 {
### Design with AI ### Design with AI
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and, The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and,
when [Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
widget schema and current non-secret configuration, but not the latest preview response. It proposes the matching create or widget schema and current non-secret configuration, but not the latest preview response. It proposes the matching create or
update tool for your approval. update tool for your approval.
-34
View File
@@ -1,34 +0,0 @@
---
tags:
- Administration
- Logs
- Troubleshooting
---
# Logs
The logs page shows recent Homarr application logs and continues updating as new events occur. Use it to investigate
widget, integration, and server errors without opening the container logs separately.
## Opening the logs page
Navigate to **Management** > **Tools** > **Logs**. The page and widget log links are only available to users with the
**View logs** permission.
When a widget reports an error, **Check logs for more details** opens the logs page in a new tab and marks the time when
the widget displayed the error. The related server error normally appears immediately above that marker. If those logs
have already been removed from the retained history, the marker explains that they are no longer available.
## Retained and live logs
Homarr retains the latest 5,000 log events in Redis. Opening the page first loads this history and then continues with
live events without a gap. The history is bounded, so older events are removed automatically.
The history survives normal application restarts when the configured Redis instance persists its data. For complete
platform-level history, startup failures, or events older than the retained limit, use the logs provided by Docker or
your deployment platform.
## Display controls
The severity selector applies to both retained and live events. Selecting a different level redraws the existing view
using that threshold. The font-size control changes the terminal text size and is remembered in the browser.
+1 -1
View File
@@ -17,7 +17,7 @@ remains available as a compatibility alias, but new clients should always use `/
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools. With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
The optional [Assistant](./assistant) uses these same tool definitions internally. The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
approval before running mutating procedures. approval before running mutating procedures.
@@ -7,13 +7,9 @@ tags:
# Search Engines # Search Engines
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `MOD + K`. Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`.
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine. The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
The default **Search** mode lists every configured search engine as a fallback action for the current query. To discover
engines by shortcut, switch to **Web** with `!`. DuckDuckGo bang discovery is intentionally limited to Web mode so an
ordinary search term never expands into similarly named bang providers.
You can add any search engine that has a search URL that accepts a search term as a parameter. You can add any search engine that has a search URL that accepts a search term as a parameter.
## Create search engine ## Create search engine
@@ -30,8 +26,6 @@ The name can be chosen freely. It will be displayed in the search box popover.
Prefix that is used to choose the search engine in the popover using the keyboard. Prefix that is used to choose the search engine in the popover using the keyboard.
It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia). It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia).
Type `!` in Spotlight to browse these shortcuts and, when DuckDuckGo bangs are enabled in preferences, discover
additional bang providers.
### Configuration ### Configuration
+1 -1
View File
@@ -13,7 +13,7 @@ tags:
The settings page applies options to the whole Homarr application. The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect. Some changes need a full page reload before they take effect.
All settings sections remain visible 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. 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
@@ -16,7 +16,7 @@ import beszelSystemGrid from "./img/beszel-system-grid.png";
<WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} /> <WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} />
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Resizing the widget adjusts its rows and columns to keep the cards balanced. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount. This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts. Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
+6 -7
View File
@@ -1,6 +1,6 @@
--- ---
title: "Bookmarks" title: "Bookmarks"
description: "Keeps useful links together" description: "Displays multiple app links"
hide_title: true hide_title: true
--- ---
@@ -13,12 +13,11 @@ import { bookmarksWidget } from ".";
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} /> <WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list. The bookmarks widget allows you to display multiple links in a single widget.
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App. Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one. For links that do not need an App, add their URLs under **Custom links** instead.
Bookmarks selected from Apps continue to use the App name, URL, icon, and description. Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
### Screenshots ### Screenshots
+12 -18
View File
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
export const bookmarksWidget: WidgetDefinition = { export const bookmarksWidget: WidgetDefinition = {
icon: IconBookmark, icon: IconBookmark,
name: "Bookmarks", name: "Bookmarks",
description: "Keeps useful links together", description: "Displays multiple app links",
path: "../../widgets/bookmarks", path: "../../widgets/bookmarks",
configuration: { configuration: {
items: [ items: [
@@ -16,21 +16,9 @@ export const bookmarksWidget: WidgetDefinition = {
}, },
{ {
name: "Layout", name: "Layout",
description: "How bookmarks use the available widget space.", description: "Layout in which the bookmarks are displayed.",
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] }, values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
defaultValue: "Adaptive", defaultValue: "Vertical",
},
{
name: "Appearance",
description: "Card surface style.",
values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] },
defaultValue: "Soft",
},
{
name: "Card spacing",
description: "Space between bookmark cards.",
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
defaultValue: "Extra small",
}, },
{ {
name: "Hide title", name: "Hide title",
@@ -58,10 +46,16 @@ export const bookmarksWidget: WidgetDefinition = {
}, },
{ {
name: "Bookmarks", name: "Bookmarks",
description: "URLs and existing Apps shown by the widget.", description: "List of bookmarks to display in the widget.",
values: "Paste one or many URLs, or find an existing App. Drag to reorder.", values: "Select one or more Apps directly, then order them as you like.",
defaultValue: "No bookmarks selected", defaultValue: "No bookmarks selected",
}, },
{
name: "Custom links",
description: "URLs to display without first creating an App.",
values: "List of URLs.",
defaultValue: "No custom links",
},
], ],
}, },
}; };
@@ -69,7 +69,6 @@ import advancedView from "./img/advanced-view.png";
### Interactions ### Interactions
- Colored dots directly below a date mark its events. They scale with the widget and are hidden when the calendar is too short.
- Hover over a marked date to preview its events in a popover. - Hover over a marked date to preview its events in a popover.
- The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months. - The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load. - If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
+34
View File
@@ -0,0 +1,34 @@
---
title: "Today"
description: "Displays the current date and progress through the calendar."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { todayWidget } from ".";
<WidgetHeader widget={todayWidget} categories={["Date", "Time"]} />
The Today widget presents the current local date without duplicating a live clock. It progressively adds the week
number, day of year, quarter, remaining days, and calendar progress as more space becomes available.
The advanced view shows week, month, and year progress together with a twelve-month overview. Week numbers can follow
your locale or the ISO 8601 convention. “Days after today” excludes the current day to keep the remaining-day count
unambiguous.
The widget runs entirely in your browser and does not send date or location data to an external service. It follows
the timezone and locale of the device viewing the board, so viewers in different regions can see their own local date.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={todayWidget.configuration} />
+43
View File
@@ -0,0 +1,43 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconCalendarWeek } from "@tabler/icons-react";
export const todayWidget: WidgetDefinition = {
icon: IconCalendarWeek,
name: "Today",
description: "Displays the current date and progress through the calendar.",
path: "../../widgets/today",
configuration: {
items: [
{
name: "Week numbering",
description: "Use locale-specific week numbering or ISO 8601 week numbers",
values: { type: "select", options: ["Use locale rules", "ISO 8601"] },
defaultValue: "Use locale rules",
},
{
name: "Show week number",
description: "Display the current week number when the widget has room",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show day of year",
description: "Display the current day number within the year",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show quarter",
description: "Display the current calendar quarter",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show year progress",
description: "Display progress through the current year",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+1 -351
View File
@@ -2106,6 +2106,7 @@
"enum": [ "enum": [
"clock", "clock",
"weather", "weather",
"today",
"airQuality", "airQuality",
"countdown", "countdown",
"timer", "timer",
@@ -3453,9 +3454,6 @@
"enableRightClickOnWidgets": { "enableRightClickOnWidgets": {
"type": "boolean" "type": "boolean"
}, },
"headerPreferences": {
"type": "string"
},
"defaultSearchEngineId": { "defaultSearchEngineId": {
"anyOf": [ "anyOf": [
{ {
@@ -3491,7 +3489,6 @@
"firstDayOfWeek", "firstDayOfWeek",
"pingIconsEnabled", "pingIconsEnabled",
"enableRightClickOnWidgets", "enableRightClickOnWidgets",
"headerPreferences",
"defaultSearchEngineId", "defaultSearchEngineId",
"openSearchInNewTab", "openSearchInNewTab",
"ddgBangs", "ddgBangs",
@@ -4350,353 +4347,6 @@
} }
} }
}, },
"/api/users/header-preferences": {
"patch": {
"operationId": "userRouter-changeHeaderPreferences",
"tags": ["users"],
"security": [
{
"apikey": []
}
],
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"headerPreferences": {
"oneOf": [
{
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 3
},
"visible": {
"type": "boolean"
},
"searchDisplay": {
"type": "string",
"enum": ["input", "icon"]
},
"logoDisplay": {
"default": "logoAndText",
"type": "string",
"enum": ["logo", "logoAndText"]
},
"zones": {
"type": "object",
"properties": {
"left": {
"maxItems": 100,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "builtin"
},
"id": {
"type": "string",
"enum": [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user"
]
}
},
"required": ["type", "id"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "board"
},
"boardId": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
},
"required": ["type", "boardId"],
"additionalProperties": false
}
],
"type": "object"
}
},
"center": {
"maxItems": 100,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "builtin"
},
"id": {
"type": "string",
"enum": [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user"
]
}
},
"required": ["type", "id"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "board"
},
"boardId": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
},
"required": ["type", "boardId"],
"additionalProperties": false
}
],
"type": "object"
}
},
"right": {
"maxItems": 100,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "builtin"
},
"id": {
"type": "string",
"enum": [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user"
]
}
},
"required": ["type", "id"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "board"
},
"boardId": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
},
"required": ["type", "boardId"],
"additionalProperties": false
}
],
"type": "object"
}
}
},
"required": ["left", "center", "right"],
"additionalProperties": false
}
},
"required": ["version", "visible", "searchDisplay", "zones"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 1
},
"visible": {
"type": "boolean"
},
"items": {
"minItems": 1,
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
}
},
"required": ["version", "visible", "items"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 2
},
"visible": {
"type": "boolean"
},
"zones": {
"type": "object",
"properties": {
"left": {
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
},
"center": {
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
},
"right": {
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
}
},
"required": ["left", "center", "right"],
"additionalProperties": false
}
},
"required": ["version", "visible", "zones"],
"additionalProperties": false
}
],
"type": "object"
}
},
"required": ["id", "headerPreferences"]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.BAD_REQUEST"
}
}
}
},
"401": {
"description": "Authorization not provided",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.UNAUTHORIZED"
}
}
}
},
"403": {
"description": "Insufficient access",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.FORBIDDEN"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.NOT_FOUND"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
}
}
}
}
}
}
},
"/api/users/ddg-bangs": { "/api/users/ddg-bangs": {
"patch": { "patch": {
"operationId": "userRouter-changeDdgBangs", "operationId": "userRouter-changeDdgBangs",
-1
View File
@@ -76,7 +76,6 @@
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:", "@tanstack/react-query-next-experimental": "catalog:",
"@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:", "@trpc/client": "catalog:",
"@trpc/next": "catalog:", "@trpc/next": "catalog:",
"@trpc/react-query": "catalog:", "@trpc/react-query": "catalog:",
@@ -1,53 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" role="img" aria-labelledby="title description">
<title id="title">Abstract homelab network</title>
<description id="description">A dark indigo field with subtle connected nodes and cyan signal paths.</description>
<defs>
<radialGradient id="base" cx="50%" cy="40%" r="80%">
<stop offset="0" stop-color="#172047"/>
<stop offset="0.48" stop-color="#0b1028"/>
<stop offset="1" stop-color="#060814"/>
</radialGradient>
<radialGradient id="indigoGlow">
<stop offset="0" stop-color="#748ffc" stop-opacity="0.32"/>
<stop offset="1" stop-color="#748ffc" stop-opacity="0"/>
</radialGradient>
<radialGradient id="cyanGlow">
<stop offset="0" stop-color="#3bc9db" stop-opacity="0.22"/>
<stop offset="1" stop-color="#3bc9db" stop-opacity="0"/>
</radialGradient>
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#748ffc" stop-opacity="0.05"/>
<stop offset="0.5" stop-color="#748ffc" stop-opacity="0.34"/>
<stop offset="1" stop-color="#3bc9db" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur">
<feGaussianBlur stdDeviation="55"/>
</filter>
</defs>
<rect width="1920" height="1080" fill="url(#base)"/>
<ellipse cx="330" cy="90" rx="560" ry="430" fill="url(#indigoGlow)" filter="url(#blur)"/>
<ellipse cx="1680" cy="940" rx="620" ry="400" fill="url(#cyanGlow)" filter="url(#blur)"/>
<g fill="none" stroke="url(#signal)" stroke-width="1.5">
<path d="M-80 825C240 570 475 760 720 510s470-165 650-330 330-85 630-250"/>
<path d="M-120 1010C220 740 470 920 780 660s520-105 700-270 300-125 560-180"/>
<path d="M110 40c180 190 350 220 545 130s390-55 520 80 320 155 620 60" opacity=".45"/>
</g>
<g fill="#9aa8ff" opacity=".48">
<circle cx="248" cy="660" r="3"/>
<circle cx="718" cy="511" r="4"/>
<circle cx="1118" cy="287" r="3"/>
<circle cx="1450" cy="110" r="4"/>
</g>
<g fill="#5ee0ee" opacity=".42">
<circle cx="435" cy="850" r="3"/>
<circle cx="908" cy="604" r="4"/>
<circle cx="1350" cy="510" r="3"/>
<circle cx="1715" cy="285" r="4"/>
</g>
<g fill="none" stroke="#aeb8ff" stroke-opacity=".055">
<circle cx="330" cy="90" r="260"/>
<circle cx="330" cy="90" r="345"/>
<circle cx="1680" cy="940" r="250"/>
<circle cx="1680" cy="940" r="335"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1,7 +1,6 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core"; import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core"; import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -11,23 +10,18 @@ import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common"; import { parseCookies, setClientCookie } from "@homarr/common";
import { colorSchemeCookieKey } from "@homarr/definitions"; import { colorSchemeCookieKey } from "@homarr/definitions";
import { theme } from "@homarr/ui"; import { theme } from "@homarr/ui";
import type { ServerSettings } from "@homarr/server-settings";
import { createBrandTheme } from "~/theme/branding";
export const CustomMantineProvider = ({ export const CustomMantineProvider = ({
children, children,
defaultColorScheme, defaultColorScheme,
branding, }: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => {
const manager = useColorSchemeManager(); const manager = useColorSchemeManager();
const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]);
return ( return (
<DirectionProvider> <DirectionProvider>
<MantineProvider <MantineProvider
defaultColorScheme={defaultColorScheme} defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager} colorSchemeManager={manager}
theme={configuredTheme} theme={theme}
cssVariablesResolver={v8CssVariablesResolver} cssVariablesResolver={v8CssVariablesResolver}
> >
{children} {children}
@@ -1,350 +0,0 @@
import { dehydrate, QueryClient } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { persistQueryClientRestore } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createSessionQueryPersister,
getQueryPersistenceStorageKey,
queryPersistenceBuster,
queryPersistenceMaxDataBytes,
shouldPersistDashboardQuery,
} from "./query-persistence";
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKey, data, { updatedAt });
const query = queryClient.getQueryCache().find({ queryKey });
if (!query) throw new Error("Expected query to be present");
return { query, queryClient };
};
const createMemoryStorage = (): Storage => {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => Array.from(values.keys())[index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
};
};
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
window.sessionStorage.clear();
});
describe("widget query persistence", () => {
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
});
const pendingClient = new QueryClient();
const pending = pendingClient.getQueryCache().build(pendingClient, {
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
queryFn: () => Promise.resolve([]),
});
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
expect(shouldPersistDashboardQuery(pending)).toBe(false);
});
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
const userAKey = getQueryPersistenceStorageKey("user-a");
window.sessionStorage.setItem(userAKey, "a");
createSessionQueryPersister("user-a").removeClient();
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
});
test("evicts every other scope so signing out cannot leave data behind", () => {
const userAKey = getQueryPersistenceStorageKey("user-a");
const anonymousKey = getQueryPersistenceStorageKey(null);
window.sessionStorage.setItem(userAKey, "a");
window.sessionStorage.setItem(anonymousKey, "anonymous");
window.sessionStorage.setItem("unrelated", "kept");
createSessionQueryPersister("user-b");
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
});
test("never restores another session scope", async () => {
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
const userAPersister = createSessionQueryPersister("user-a");
userAPersister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
userAPersister.flush();
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
});
test("stops persisting once the provider tears the cache down", async () => {
const storage = createMemoryStorage();
const persister = createSessionQueryPersister("user-a", storage);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
persister.stop();
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
});
test("removes the cache instead of storing an empty one", () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
window.sessionStorage.setItem(key, "stale");
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
vi.useFakeTimers();
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
expect(shouldPersistDashboardQuery(query)).toBe(true);
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
};
persister.persistClient(persistedClient);
persister.flush();
const restored = await persister.restoreClient();
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
window.sessionStorage.setItem(key, "not-valid-superjson");
expect(persister.restoreClient()).toBeUndefined();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const { queryClient } = createSuccessfulQuery(
[["widget", "calendar", "findAllEvents"], { type: "query" }],
[{ title: "safe" }],
);
const validQuery = dehydrate(queryClient).queries[0];
if (!validQuery) throw new Error("Expected a dehydrated query");
window.sessionStorage.setItem(
key,
stringify({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [{ state: { status: "pending" } }],
queries: [
{
...validQuery,
state: {
...validQuery.state,
error: new Error("private refresh error"),
fetchStatus: "fetching",
status: "error",
},
},
{
...validQuery,
queryHash: "unrelated",
queryKey: [["board", "getBoardByName"], { type: "query" }],
},
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
{
...validQuery,
queryHash: "missing-timestamp",
state: { ...validQuery.state, dataUpdatedAt: undefined },
},
],
},
}),
);
const restored = await createSessionQueryPersister(scope).restoreClient();
expect(restored?.clientState.mutations).toEqual([]);
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
});
test.each([
["expired", 1, queryPersistenceBuster],
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
const scope = "user-a";
const persister = createSessionQueryPersister(scope);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
persister.flush();
const restoredClient = new QueryClient();
await persistQueryClientRestore({
queryClient: restoredClient,
persister,
maxAge: 5 * 60 * 1_000,
buster: queryPersistenceBuster,
});
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
});
test("quota retry writes the cache after evicting the oldest query", async () => {
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
const storage = createMemoryStorage();
const setItem = storage.setItem.bind(storage);
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
const client = parse<PersistedClient>(value);
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
setItem(key, value);
});
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [],
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
},
};
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient(persistedClient);
persister.flush();
expect(setItemSpy).toHaveBeenCalledTimes(2);
const restored = await persister.restoreClient();
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
});
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
vi.useFakeTimers();
const storage = createMemoryStorage();
const setItem = vi.spyOn(storage, "setItem");
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(first.queryClient),
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(latest.queryClient),
});
expect(setItem).not.toHaveBeenCalled();
persister.flush();
expect(setItem).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(0);
expect(setItem).toHaveBeenCalledOnce();
const restored = await persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
});
test("treats blocked storage operations as an empty cache", async () => {
const blockedStorage = {
getItem: () => {
throw new DOMException("blocked", "SecurityError");
},
removeItem: () => {
throw new DOMException("blocked", "SecurityError");
},
setItem: () => {
throw new DOMException("blocked", "SecurityError");
},
} as unknown as Storage;
const persister = createSessionQueryPersister("blocked", blockedStorage);
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
expect(persister.restoreClient()).toBeUndefined();
expect(() => {
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
}).not.toThrow();
expect(() => persister.removeClient()).not.toThrow();
});
test("treats an unavailable session storage getter as an empty cache", async () => {
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
});
});
@@ -1,226 +0,0 @@
import type { QueryKey } from "@tanstack/react-query";
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
export const queryPersistenceMaxDataBytes = 256 * 1024;
export const queryPersistenceBuster = "v2-session-superjson";
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
export const getQueryPersistenceStorageKey = (scope: string | null) =>
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
interface PersistableQuery {
queryKey: QueryKey;
state: {
data: unknown;
dataUpdatedAt: number;
};
}
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
const textEncoder = new TextEncoder();
const budgetVerdictCache = new WeakMap<object, boolean>();
const measureFitsBudget = (data: unknown) => {
try {
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
} catch {
return false;
}
};
const fitsStorageBudget = (data: unknown) => {
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
const cached = budgetVerdictCache.get(data);
if (cached !== undefined) return cached;
const verdict = measureFitsBudget(data);
budgetVerdictCache.set(data, verdict);
return verdict;
};
const holdsDashboardData = (query: PersistableQuery) =>
query.state.data !== undefined &&
Number.isFinite(query.state.dataUpdatedAt) &&
query.state.dataUpdatedAt > 0 &&
isWidgetDataQueryKey(query.queryKey);
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchStatus: "idle",
isInvalidated: true,
status: "success",
},
});
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
...client,
clientState: {
mutations: [],
queries: client.clientState.queries.map(sanitizePersistedQuery),
},
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
return holdsDashboardData(value as unknown as PersistableQuery);
};
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
return {
timestamp: value.timestamp as number,
buster: value.buster,
clientState: {
mutations: [],
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
},
};
};
const getSessionStorage = (): Storage | undefined => {
if (typeof window === "undefined") return undefined;
try {
return window.sessionStorage;
} catch {
return undefined;
}
};
const removeStorageItem = (storage: Storage | undefined, key: string) => {
try {
storage?.removeItem(key);
} catch {
return undefined;
}
};
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
if (!storage) return;
try {
const foreignKeys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const storedKey = storage.key(index);
if (storedKey === null || storedKey === activeKey) continue;
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
}
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
} catch {
return undefined;
}
};
export const createSessionQueryPersister = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersister => {
const key = getQueryPersistenceStorageKey(scope);
removeForeignScopes(storage, key);
let pendingClient: PersistedClient | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
const persistNow = (client: PersistedClient) => {
if (!storage) return;
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
let errorCount = 0;
if (candidate.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return;
}
while (candidate) {
try {
storage.setItem(key, stringify(candidate));
return;
} catch (error) {
errorCount += 1;
candidate = removeOldestQuery({
persistedClient: candidate,
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
errorCount,
});
}
}
};
const clearPersistTimer = () => {
if (persistTimer === undefined) return;
clearTimeout(persistTimer);
persistTimer = undefined;
};
const flush = () => {
clearPersistTimer();
const client = pendingClient;
pendingClient = undefined;
if (client) persistNow(client);
};
return {
persistClient(client) {
if (!storage || stopped) return;
pendingClient = client;
if (persistTimer !== undefined) return;
persistTimer = setTimeout(flush, 0);
},
restoreClient() {
try {
const storedValue = storage?.getItem(key);
if (!storedValue) return undefined;
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return undefined;
}
return client;
} catch {
removeStorageItem(storage, key);
return undefined;
}
},
removeClient() {
clearPersistTimer();
pendingClient = undefined;
removeStorageItem(storage, key);
},
flush,
stop() {
stopped = true;
clearPersistTimer();
pendingClient = undefined;
},
};
};
@@ -1,11 +1,10 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { import {
createWSClient, createWSClient,
httpBatchStreamLink, httpBatchStreamLink,
@@ -36,13 +35,6 @@ import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import {
createSessionQueryPersister,
queryPersistenceBuster,
queryPersistenceMaxAgeMs,
shouldPersistDashboardQuery,
} from "./query-persistence";
import type { SessionQueryPersister } from "./query-persistence";
import { createQueryRetry } from "./query-retry"; import { createQueryRetry } from "./query-retry";
const DevelopmentTools = const DevelopmentTools =
@@ -75,29 +67,21 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
const { data: session } = useSession(); const { data: session } = useSession();
const sessionQueryScope = getSessionQueryScope(session); const sessionQueryScope = getSessionQueryScope(session);
const [initialSessionQueryScope] = useState(() => sessionQueryScope); const [initialSessionQueryScope] = useState(() => sessionQueryScope);
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
const handleScopeChange = useCallback(() => {
void queryPersister.removeClient();
reloadPage();
}, [queryPersister]);
return ( return (
<SessionQueryScopeGuard <SessionQueryScopeGuard
initialScope={initialSessionQueryScope} initialScope={initialSessionQueryScope}
currentScope={sessionQueryScope} currentScope={sessionQueryScope}
onScopeChange={handleScopeChange} onScopeChange={reloadPage}
> >
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider> <ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard> </SessionQueryScopeGuard>
); );
} }
const reloadPage = () => window.location.reload(); const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
children,
queryPersister,
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
const wsClient = useMemo( const wsClient = useMemo(
() => () =>
createWSClient({ createWSClient({
@@ -152,15 +136,6 @@ const ScopedTRPCReactProvider = ({
return client; return client;
}); });
useEffect(() => {
const flush = () => queryPersister.flush();
window.addEventListener("pagehide", flush);
return () => {
window.removeEventListener("pagehide", flush);
flush();
queryPersister.stop();
};
}, [queryPersister]);
useEffect(() => () => queryClient.clear(), [queryClient]); useEffect(() => () => queryClient.clear(), [queryClient]);
const trpcClient = useMemo(() => { const trpcClient = useMemo(() => {
@@ -211,18 +186,10 @@ const ScopedTRPCReactProvider = ({
return ( return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}> <clientApi.Provider client={trpcClient} queryClient={queryClient}>
<PersistQueryClientProvider <QueryClientProvider client={queryClient}>
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: queryPersistenceMaxAgeMs,
buster: queryPersistenceBuster,
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration> <ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />} {process.env.NODE_ENV === "development" && <DevelopmentTools />}
</PersistQueryClientProvider> </QueryClientProvider>
</clientApi.Provider> </clientApi.Provider>
); );
}; };
@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui"; import { CustomPasswordInput } from "@homarr/ui";
import { userRegistrationSchema } from "@homarr/validation/user"; import { userRegistrationSchema } from "@homarr/validation/user";
@@ -19,7 +19,7 @@ interface RegistrationFormProps {
} }
export const RegistrationForm = ({ invite }: RegistrationFormProps) => { export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
const t = useI18n("user"); const t = useScopedI18n("user");
const router = useRouter(); const router = useRouter();
const { mutate, isPending } = clientApi.user.register.useMutation(); const { mutate, isPending } = clientApi.user.register.useMutation();
const form = useZodForm(userRegistrationSchema, { const form = useZodForm(userRegistrationSchema, {
@@ -1,14 +1,12 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { IconUserPlus } from "@tabler/icons-react"; import { IconUserPlus } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db"; import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema"; import { invites } from "@homarr/db/schema";
import { OnboardingAuthShell } from "@homarr/onboarding"; import { OnboardingAuthShell } from "@homarr/onboarding";
import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings"; import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { RegistrationForm } from "./_registration-form"; import { RegistrationForm } from "./_registration-form";
@@ -22,9 +20,11 @@ interface InviteUsagePageProps {
} }
export default async function InviteUsagePage(props: InviteUsagePageProps) { export default async function InviteUsagePage(props: InviteUsagePageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
if (!isProviderEnabled("credentials")) notFound(); if (!isProviderEnabled("credentials")) notFound();
const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]); const session = await auth();
if (session) notFound(); if (session) notFound();
const invite = await db.query.invites.findFirst({ const invite = await db.query.invites.findFirst({
@@ -45,33 +45,13 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
if (!invite || invite.expirationDate < new Date()) notFound(); if (!invite || invite.expirationDate < new Date()) notFound();
const [t, serverSettings] = await Promise.all([getI18n("user.page.invite"), getRscServerSettingsAsync()]); const t = await getScopedI18n("user.page.invite");
const branding = serverSettings.branding;
const wordmarkColors = getBrandingColorOverrides(branding);
const showCustomAppName = branding.authBranding.showAppName && branding.appName !== defaultBrandingSettings.appName;
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
const title = showCustomAppName ? t("titleWithAppName", { appName: branding.appName }) : t("title");
const defaultDescription = showCustomAppName
? t("subtitleWithAppName", { appName: branding.appName })
: t("subtitle");
const description = showCustomGreeting ? branding.greeting : defaultDescription;
return ( return (
<OnboardingAuthShell <OnboardingAuthShell
title={title} title={t("title")}
description={description} description={t("subtitle")}
icon={<IconUserPlus size={24} />} icon={<IconUserPlus size={24} />}
appName={branding.appName}
showAppName={branding.authBranding.showAppName}
showAppLogo={branding.authBranding.showLogo}
primaryColor={branding.primaryColor}
secondaryColor={branding.secondaryColor}
wordmarkPrimaryColor={wordmarkColors.primaryColor}
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
logoImageUrl={branding.logoImageUrl ?? undefined}
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
backgroundOverlay={branding.signInBackgroundOverlay}
radius={branding.defaultRadius}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
footer={t("description", { username: invite.creator.name! })} footer={t("description", { username: invite.creator.name! })}
> >
@@ -12,7 +12,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { userSignInSchema } from "@homarr/validation/user"; import { userSignInSchema } from "@homarr/validation/user";
@@ -28,7 +28,7 @@ interface LoginFormProps {
const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) }); const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) });
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => { export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useI18n("user"); const t = useScopedI18n("user");
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const isError = searchParams.has("error"); const isError = searchParams.has("error");
const router = useRouter(); const router = useRouter();
@@ -196,7 +196,7 @@ interface PasswordForgottenCollapseProps {
} }
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => { const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
const [visible, { toggle }] = useDisclosure(false); const [visible, { toggle }] = useDisclosure(false);
const tForgotPassword = useI18n("user.action.login.forgotPassword"); const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>"; const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
@@ -2,12 +2,10 @@ import { redirect } from "next/navigation";
import { Alert, Code, Text } from "@mantine/core"; import { Alert, Code, Text } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react"; import { IconLogin } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { env } from "@homarr/auth/env"; import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { OnboardingAuthShell } from "@homarr/onboarding"; import { OnboardingAuthShell } from "@homarr/onboarding";
import { getBrandingColorOverrides } from "@homarr/server-settings"; import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env"; import { env as appEnv } from "~/env";
@@ -21,35 +19,17 @@ interface LoginProps {
} }
export default async function Login(props: LoginProps) { export default async function Login(props: LoginProps) {
const [searchParams, session] = await Promise.all([props.searchParams, auth()]); const searchParams = await props.searchParams;
const session = await auth();
if (session) { if (session) {
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl)); redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
} }
const [t, serverSettings] = await Promise.all([getI18n("user.page.login"), getRscServerSettingsAsync()]); const t = await getScopedI18n("user.page.login");
const branding = serverSettings.branding;
const wordmarkColors = getBrandingColorOverrides(branding);
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
const description = showCustomGreeting ? branding.greeting : t("subtitle");
return ( return (
<OnboardingAuthShell <OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
title={t("title")}
description={description}
icon={<IconLogin size={24} />}
appName={branding.appName}
showAppName={branding.authBranding.showAppName}
showAppLogo={branding.authBranding.showLogo}
primaryColor={branding.primaryColor}
secondaryColor={branding.secondaryColor}
wordmarkPrimaryColor={wordmarkColors.primaryColor}
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
logoImageUrl={branding.logoImageUrl ?? undefined}
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
backgroundOverlay={branding.signInBackgroundOverlay}
radius={branding.defaultRadius}
>
{appEnv.DEMO_MODE ? ( {appEnv.DEMO_MODE ? (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light"> <Alert icon={<IconLogin size={18} />} color="blue" variant="light">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
@@ -1,11 +1,11 @@
import { IconLayoutOff } from "@tabler/icons-react"; import { IconLayoutOff } from "@tabler/icons-react";
import { getI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { BoardNotFound } from "~/components/board/not-found"; import { BoardNotFound } from "~/components/board/not-found";
export default async function BoardNotFoundPage() { export default async function BoardNotFoundPage() {
const tNotFound = await getI18n("board.error.notFound"); const tNotFound = await getScopedI18n("board.error.notFound");
return ( return (
<BoardNotFound <BoardNotFound
icon={IconLayoutOff} icon={IconLayoutOff}
@@ -19,11 +19,35 @@
align-self: start; align-self: start;
box-sizing: border-box; box-sizing: border-box;
height: var(--board-gutter-viewport-height); height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow: clip; overflow: clip;
} }
.gutter[data-board-editing="true"] { .gutter[data-board-editing="true"] {
outline: 0.0625rem solid var(--mantine-color-default-border); outline: 1px dashed var(--mantine-color-default-border);
outline-offset: -0.0625rem; outline-offset: -1px;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
} }
.gutter[data-board-editing="true"]::before {
position: sticky;
top: 0;
inset-inline-start: 0;
z-index: 2;
display: block;
width: fit-content;
height: 24px;
padding-inline: var(--mantine-spacing-xs);
margin-top: -24px;
overflow: hidden;
font-size: var(--mantine-font-size-xs);
line-height: 24px;
color: var(--mantine-color-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
content: attr(aria-label);
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
border-radius: var(--mantine-radius-sm);
}
@@ -1,50 +1,36 @@
"use client"; "use client";
import type { PropsWithChildren } from "react";
import { Box, Paper, Stack, Text } from "@mantine/core"; import { Box, Paper, Stack, Text } from "@mantine/core";
import { IconBulb } from "@tabler/icons-react";
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context"; import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview"; import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
import { BoardEmptyState } from "~/components/board/board-empty-state"; import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist"; import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import { import {
getBoardLaneColumnCount, getBoardLaneColumnCount,
getInitialBoardLogicalHeight, getInitialBoardLogicalHeight,
getLogicalGridSize, getLogicalTrackSize,
LOGICAL_GRID_GAP, LOGICAL_GRID_GAP,
getRootSectionForLane, getRootSectionForLane,
} from "~/components/board/layout"; } from "~/components/board/layout";
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas"; import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary"; import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry"; import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse"; import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background"; import { BoardBackgroundVideo } from "~/components/layout/background";
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
import classes from "./_client.module.css"; import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32; const APP_SHELL_INLINE_PADDING = 32;
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
<GridEditorRegistryProvider>
<BoardSelectionProvider>{children}</BoardSelectionProvider>
</GridEditorRegistryProvider>
);
export const ClientBoard = () => { export const ClientBoard = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useI18n("board.landmark"); const t = useScopedI18n("board.landmark");
const tPreview = useI18n("board.setting.section.layout.preview"); const tPreview = useScopedI18n("board.setting.section.layout.preview");
const tTips = useI18n("tips");
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth(); const initialViewportWidth = useInitialViewportWidth();
@@ -62,99 +48,86 @@ export const ClientBoard = () => {
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right"); const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount] const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0) .filter((columnCount) => columnCount > 0)
.map(getLogicalGridSize); .map(getLogicalTrackSize);
const logicalWidth = const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP; laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId); const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null; const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING); const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
const gridTemplateColumns = [ const gridTemplateColumns = [
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null, leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
`${getLogicalGridSize(mainColumnCount)}px`, `${getLogicalTrackSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null, rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
] ]
.filter((value) => value !== null) .filter((value) => value !== null)
.join(" "); .join(" ");
const content = ( const content = (
<BoardSelectionGridProvider> <BoardAdvancedFocusProvider>
<BoardAdvancedFocusProvider> <Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board> <BoardBackgroundVideo />
<BoardAppsSpotlightRegistrar /> <BoardEmptyState />
<BoardBackgroundVideo /> <BoardSetupChecklist />
<BoardEmptyState /> <BoardSectionCollapseProvider>
<BoardSetupChecklist /> <BoardGridPortalHost>
<BoardSectionCollapseProvider> <ScaledBoardCanvas
<BoardGridPortalHost> logicalWidth={logicalWidth}
<ScaledBoardCanvas initialLogicalHeight={initialLogicalHeight}
logicalWidth={logicalWidth} initialAvailableWidth={initialAvailableWidth}
initialLogicalHeight={initialLogicalHeight} label={board.name}
initialAvailableWidth={initialAvailableWidth} >
label={board.name} <BoardGridEditorBoundary key={currentLayoutId}>
> <BoardGridPortalRenderer />
<BoardGridEditorBoundary key={currentLayoutId}> <div className={classes.columns} style={{ gridTemplateColumns }}>
<div className={classes.columns} style={{ gridTemplateColumns }}> {leftColumnCount > 0 && leftSection && (
{leftColumnCount > 0 && leftSection && ( <aside
<aside className={`${classes.lane} ${classes.gutter}`}
className={`${classes.lane} ${classes.gutter}`} aria-label={t("leftRail")}
aria-label={t("leftRail")} data-board-gutter="left"
data-board-gutter="left" data-board-editing={isEditMode ? "true" : undefined}
data-board-editing={isEditMode ? "true" : undefined} >
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection <BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`} key={`${currentLayoutId}-${leftSection.id}`}
section={mainSection} section={leftSection}
columnCount={mainColumnCount} columnCount={leftColumnCount}
requestedRowCount={0} requestedRowCount={0}
railPlacement="left"
/> />
</section> </aside>
)}
{rightColumnCount > 0 && rightSection && ( <section className={classes.lane} aria-label={t("canvas")}>
<aside <BoardEmptySection
className={`${classes.lane} ${classes.gutter}`} key={`${currentLayoutId}-${mainSection.id}`}
aria-label={t("rightRail")} section={mainSection}
data-board-gutter="right" columnCount={mainColumnCount}
data-board-editing={isEditMode ? "true" : undefined} requestedRowCount={0}
> />
<BoardEmptySection </section>
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection} {rightColumnCount > 0 && rightSection && (
columnCount={rightColumnCount} <aside
requestedRowCount={0} className={`${classes.lane} ${classes.gutter}`}
railPlacement="right" aria-label={t("rightRail")}
/> data-board-gutter="right"
</aside> data-board-editing={isEditMode ? "true" : undefined}
)} >
</div> <BoardEmptySection
</BoardGridEditorBoundary> key={`${currentLayoutId}-${rightSection.id}`}
</ScaledBoardCanvas> section={rightSection}
</BoardGridPortalHost> columnCount={rightColumnCount}
</BoardSectionCollapseProvider> requestedRowCount={0}
<BoardSelectionToolbar /> railPlacement="right"
<FloatingTip />
opened={isEditMode} </aside>
showDelay={2_000} )}
dismissAfter={3_000} </div>
transitionDuration={200} </BoardGridEditorBoundary>
closable={false} </ScaledBoardCanvas>
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }} </BoardGridPortalHost>
> </BoardSectionCollapseProvider>
{tTips("multiSelectApps")} </Box>
</FloatingTip> </BoardAdvancedFocusProvider>
</Box>
</BoardAdvancedFocusProvider>
</BoardSelectionGridProvider>
); );
if (representativeWidth === null) return content; if (representativeWidth === null) return content;
@@ -4,7 +4,6 @@ import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { makeQueryClient } from "@homarr/api/shared"; import { makeQueryClient } from "@homarr/api/shared";
import { IntegrationProvider } from "@homarr/auth/client"; import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
@@ -16,11 +15,12 @@ import type { WidgetKind } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env"; import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator"; import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types"; import type { Board, Item } from "../_types";
import { ClientBoard } from "./_client"; import { ClientBoard } from "./_client";
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions"; import { BoardContentHeaderActions } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" }); const logger = createLogger({ module: "createBoardContentPage" });
const getQueryClient = cache(makeQueryClient); const getQueryClient = cache(makeQueryClient);
@@ -36,8 +36,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => { }: Props<TParams>) => {
return { return {
layout: createBoardLayout({ layout: createBoardLayout({
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />, headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
withTour: true, withTour: true,
}), }),
@@ -104,24 +103,18 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}, },
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => { generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try { try {
const [board, t, serverSettings] = await Promise.all([ const board = await getInitialBoard(await params);
getInitialBoard(await params), const t = await getI18n();
getI18n("board"),
getRscServerSettingsAsync(),
]);
const brandLogo = serverSettings.branding.logoImageUrl ?? "/logo/logo.png";
const brandFavicon = serverSettings.branding.faviconImageUrl ?? brandLogo;
const favicon = !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : brandFavicon;
return { return {
title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }), title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
icons: { icons: {
icon: favicon, icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
apple: favicon, apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
}, },
appleWebApp: { appleWebApp: {
startupImage: { startupImage: {
url: favicon, url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
}, },
}, },
}; };
@@ -2,19 +2,14 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Group, Menu } from "@mantine/core"; import { IconPlus } from "@tabler/icons-react";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions"; import { useBoardAddActions } from "~/components/board/use-board-add-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { UniversalCreateModal } from "~/components/create/universal-create-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() { export default function BoardEditActions() {
@@ -22,16 +17,10 @@ export default function BoardEditActions() {
} }
const AddMenu = () => { const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard(); const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); const { openModal } = useModalAction(UniversalCreateModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); const t = useI18n();
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const tBoard = useI18n("board");
const tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -40,69 +29,29 @@ const AddMenu = () => {
useEffect(() => { useEffect(() => {
if (handledInitialAdd.current || searchParams.get("add") !== "true") return; if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
handledInitialAdd.current = true; handledInitialAdd.current = true;
openItemSelectModal({ boardId: board.id }); addWidget();
const nextSearchParams = new URLSearchParams(searchParams.toString()); const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("add"); nextSearchParams.delete("add");
const query = nextSearchParams.toString(); const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [board.id, openItemSelectModal, pathname, router, searchParams]); }, [addWidget, board.id, pathname, router, searchParams]);
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
onSelectMany: (apps) => {
for (const app of apps) {
createItem({
kind: "app",
options: { appId: app.id },
});
}
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({ completionBoardId: board.id });
};
return ( return (
<Menu position="bottom-end"> <HeaderButton
<Menu.Target> aria-label={t("board.action.addContent")}
<HeaderButton w="auto" px={4} aria-label={tBoard("action.addContent")}> onClick={() =>
<Group gap={4} wrap="nowrap"> openModal({
<IconPlus stroke={1.5} /> boardActions: {
<IconChevronDown color="gray" size={16} /> widget: addWidget,
</Group> app: addApp,
</HeaderButton> integration: canConnectService ? connectService : undefined,
</Menu.Target> container: addContainer,
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}> },
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}> })
{tBoard("emptyState.addWidget")} }
</Menu.Item> >
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}> <IconPlus stroke={1.5} />
{tBoard("emptyState.addApp")} </HeaderButton>
</Menu.Item>
{session?.user.permissions.includes("integration-create") && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{tIntegration("action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{tContainer("action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
); );
}; };
@@ -3,9 +3,18 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react"; import {
IconDeviceFloppy,
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconReplace,
IconSettings,
IconX,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -15,7 +24,9 @@ import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader"; import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
@@ -39,37 +50,35 @@ const loadBoardEditorAsync = async () =>
]); ]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false }); const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => { export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useI18n();
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) return null; if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
return ( return (
<> <>
{isEditMode && <BoardEditActions />} {isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} /> <EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
)}
<SelectBoardsMenu />
</> </>
); );
}; };
export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const board = useRequiredBoard();
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess || demoReadOnly) return null;
return (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled"); const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
@@ -79,9 +88,8 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common"); const commonT = useI18n();
const tBoardSetting = useI18n("board.setting"); const t = useScopedI18n("board.action.edit");
const t = useI18n("board.action.edit");
const returnToLayoutSettings = const returnToLayoutSettings =
isEditMode && isEditMode &&
searchParams.get("edit") === "true" && searchParams.get("edit") === "true" &&
@@ -96,7 +104,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
}, },
onSuccess() { onSuccess() {
showSuccessNotification({ showSuccessNotification({
title: tCommon("notification.update.success"), title: t("notification.success.title"),
message: t("notification.success.message"), message: t("notification.success.message"),
}); });
void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getBoardByName.invalidate({ name: board.name });
@@ -107,7 +115,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({
title: tCommon("notification.update.error"), title: t("notification.error.title"),
message: t("notification.error.message"), message: t("notification.error.message"),
}); });
}, },
@@ -170,9 +178,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const cancelLayoutEdit = useCallback(() => { const cancelLayoutEdit = useCallback(() => {
openConfirmModal({ openConfirmModal({
title: tBoardSetting("section.layout.edit.cancel.title"), title: commonT("board.setting.section.layout.edit.cancel.title"),
children: tBoardSetting("section.layout.edit.cancel.message"), children: commonT("board.setting.section.layout.edit.cancel.message"),
confirmProps: { children: tCommon("action.discard"), color: "red" }, confirmProps: { children: commonT("common.action.discard"), color: "red" },
onConfirm() { onConfirm() {
void (async () => { void (async () => {
await utils.board.getBoardByName.invalidate({ name: board.name }); await utils.board.getBoardByName.invalidate({ name: board.name });
@@ -181,7 +189,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
})(); })();
}, },
}); });
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]); }, [board.name, close, commonT, openConfirmModal, router, utils.board.getBoardByName]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]); useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
@@ -192,14 +200,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
<HeaderButton <HeaderButton
onClick={() => saveBoard(board)} onClick={() => saveBoard(board)}
loading={isPending} loading={isPending}
aria-label={tBoardSetting("section.layout.edit.save")} aria-label={commonT("board.setting.section.layout.edit.save")}
> >
<IconDeviceFloppy stroke={1.5} /> <IconDeviceFloppy stroke={1.5} />
</HeaderButton> </HeaderButton>
<HeaderButton <HeaderButton
onClick={cancelLayoutEdit} onClick={cancelLayoutEdit}
disabled={isPending} disabled={isPending}
aria-label={tBoardSetting("section.layout.edit.cancel.action")} aria-label={commonT("board.setting.section.layout.edit.cancel.action")}
> >
<IconX stroke={1.5} /> <IconX stroke={1.5} />
</HeaderButton> </HeaderButton>
@@ -226,10 +234,50 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
); );
}; };
const SelectBoardsMenu = () => {
const t = useI18n();
const [isOpen, setIsOpen] = useState(false);
const utils = clientApi.useUtils();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
return (
<TourTarget id="board-switcher">
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{isPending && (
<Center p="xs">
<Loader size="xs" />
</Center>
)}
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
</Box>
</TourTarget>
);
};
const anchorSelector = "a[href]:not([target='_blank'])"; const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => { const usePreventLeaveWithDirty = (isDirty: boolean) => {
const tBoard = useI18n("board"); const t = useI18n();
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const router = useRouter(); const router = useRouter();
@@ -245,13 +293,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.stopPropagation(); event.stopPropagation();
openConfirmModal({ openConfirmModal({
title: tBoard("action.edit.confirmLeave.title"), title: t("board.action.edit.confirmLeave.title"),
children: tBoard("action.edit.confirmLeave.message"), children: t("board.action.edit.confirmLeave.message"),
onConfirm() { onConfirm() {
router.push(target.href); router.push(target.href);
}, },
confirmProps: { confirmProps: {
children: tCommon("action.discard"), children: t("common.action.discard"),
}, },
}); });
}; };
@@ -277,5 +325,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
window.removeEventListener("popstate", handlePopState); window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
}; };
}, [isDirty, openConfirmModal, router, tBoard, tCommon]); }, [isDirty, openConfirmModal, router, t]);
}; };
@@ -1,33 +1,30 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core"; import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useColorSchemeManager } from "../../_client-providers/mantine"; import { useColorSchemeManager } from "../../_client-providers/mantine";
import { generateColorScale } from "~/theme/branding";
export const BoardMantineProvider = ({ export const BoardMantineProvider = ({
children, children,
defaultColorScheme, defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => { }: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const { branding } = useSettings();
const colorSchemeManager = useColorSchemeManager(); const colorSchemeManager = useColorSchemeManager();
const primaryColor = branding.lockPrimaryColor ? branding.primaryColor : board.primaryColor;
const theme = createTheme({ const theme = createTheme({
colors: { colors: {
primaryColor: generateColorScale(primaryColor), primaryColor: generateColors(board.primaryColor),
secondaryColor: generateColorScale(board.secondaryColor), secondaryColor: generateColors(board.secondaryColor),
iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"), iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
}, },
primaryColor: "primaryColor", primaryColor: "primaryColor",
autoContrast: true, autoContrast: true,
defaultRadius: branding.defaultRadius, defaultRadius: "sm",
fontSizes: { fontSizes: {
"2xl": rem(24), "2xl": rem(24),
"3xl": rem(28), "3xl": rem(28),
@@ -46,3 +43,29 @@ export const BoardMantineProvider = ({
</MantineProvider> </MantineProvider>
); );
}; };
export const generateColors = (hex: string) => {
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
const rgbaColors = lightnessForColors.map((lightness) => {
if (lightness < 0) {
return lighten(hex, -lightness);
}
return darken(hex, lightness);
});
return rgbaColors.map((color) => {
return (
"#" +
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
color
.split("(")[1]!
.replaceAll(" ", "")
.replace(")", "")
.split(",")
.map((color) => parseInt(color, 10))
.slice(0, 3)
.map((color) => color.toString(16).padStart(2, "0"))
.join("")
);
}) as unknown as MantineColorsTuple;
};
@@ -1,6 +1,5 @@
import { IconHomeOff } from "@tabler/icons-react"; import { IconHomeOff } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import { boards } from "@homarr/db/schema"; import { boards } from "@homarr/db/schema";
@@ -8,7 +7,6 @@ import { getI18n } from "@homarr/translation/server";
import type { BoardNotFoundProps } from "~/components/board/not-found"; import type { BoardNotFoundProps } from "~/components/board/not-found";
import { BoardNotFound } from "~/components/board/not-found"; import { BoardNotFound } from "~/components/board/not-found";
import { homarrLogoPath } from "~/components/layout/logo/constants";
export default async function NotFoundBoardHomePage() { export default async function NotFoundBoardHomePage() {
const boardNotFoundProps = await getPropsAsync(); const boardNotFoundProps = await getPropsAsync();
@@ -18,16 +16,15 @@ export default async function NotFoundBoardHomePage() {
const getPropsAsync = async (): Promise<BoardNotFoundProps> => { const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const boardCount = await db.$count(boards); const boardCount = await db.$count(boards);
const t = await getI18n("board"); const t = await getI18n();
if (boardCount === 0) { if (boardCount === 0) {
const { branding } = await getRscServerSettingsAsync();
return { return {
icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` }, icon: { src: "/favicon.ico", alt: "Homarr logo" },
title: t("error.noBoard.title"), title: t("board.error.noBoard.title"),
description: t("error.noBoard.description"), description: t("board.error.noBoard.description"),
link: { label: t("error.noBoard.link"), href: "/manage/boards" }, link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
notice: t("error.noBoard.notice"), notice: t("board.error.noBoard.notice"),
}; };
} }
@@ -42,9 +39,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
return { return {
icon: IconHomeOff, icon: IconHomeOff,
title: t("error.homeBoard.title"), title: t(`board.error.homeBoard.title`),
description: t(`error.homeBoard.${type}.description` as never), description: t(`board.error.homeBoard.${type}.description`),
link: { label: t(`error.homeBoard.${type}.link` as never), href }, link: { label: t(`board.error.homeBoard.${type}.link`), href },
notice: t(`error.homeBoard.${type}.notice` as never), notice: t(`board.error.homeBoard.${type}.notice`),
}; };
}; };
@@ -22,10 +22,9 @@ import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { BoardColorInput } from "@homarr/ui"; import { BoardColorInput } from "@homarr/ui";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card"; import { SectionCard } from "~/components/manage/section-card";
import { generateColorScale } from "~/theme/branding"; import { generateColors } from "../../(content)/_theme";
import type { FormValues } from "./_settings-form"; import type { FormValues } from "./_settings-form";
interface Props { interface Props {
@@ -38,29 +37,22 @@ const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ form }: Props) => { export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false); const [showPreview, { toggle }] = useDisclosure(false);
const tBoard = useI18n("board"); const t = useI18n();
const tCommon = useI18n("common");
const theme = useMantineTheme(); const theme = useMantineTheme();
const { branding } = useSettings();
return ( return (
<SectionCard title={tBoard("setting.section.appearance.title")}> <SectionCard title={t("board.setting.section.appearance.title")}>
<Grid> <Grid>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs"> <Stack gap="xs">
<BoardColorInput <BoardColorInput label={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
label={tBoard("field.primaryColor.label")}
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
disabled={branding.lockPrimaryColor}
{...form.getInputProps("primaryColor")}
/>
</Stack> </Stack>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} /> <BoardColorInput label={t("board.field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor> <Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Collapse expanded={showPreview}> <Collapse expanded={showPreview}>
@@ -71,7 +63,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Collapse> </Collapse>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={tBoard("field.opacity.label")}> <InputWrapper label={t("board.field.opacity.label")}>
<Slider <Slider
my={6} my={6}
min={0} min={0}
@@ -85,7 +77,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end"> <Group align="end">
<ColorInput <ColorInput
label={tBoard("field.iconColor.label")} label={t("board.field.iconColor.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} flex={1}
@@ -98,20 +90,20 @@ export const ColorSettingsContent = ({ form }: Props) => {
onClick={() => form.setFieldValue("iconColor", "")} onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor} disabled={!form.values.iconColor}
> >
{tBoard("field.clearColor.label")} {t("board.field.clearColor.label")}
</Button> </Button>
</Group> </Group>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Select <Select
label={tBoard("field.itemRadius.label")} label={t("board.field.itemRadius.label")}
description={tBoard("field.itemRadius.description")} description={t("board.field.itemRadius.description")}
data={[ data={[
{ label: tBoard("field.itemRadius.option.xs"), value: "xs" }, { label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: tBoard("field.itemRadius.option.sm"), value: "sm" }, { label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: tBoard("field.itemRadius.option.md"), value: "md" }, { label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: tBoard("field.itemRadius.option.lg"), value: "lg" }, { label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: tBoard("field.itemRadius.option.xl"), value: "xl" }, { label: t("board.field.itemRadius.option.xl"), value: "xl" },
]} ]}
{...form.getInputProps("itemRadius")} {...form.getInputProps("itemRadius")}
/> />
@@ -128,8 +120,7 @@ interface ColorsPreviewProps {
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => { const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme(); const theme = useMantineTheme();
const colors = const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000");
return ( return (
<Group gap={0} wrap="nowrap"> <Group gap={0} wrap="nowrap">
@@ -23,7 +23,7 @@ interface Props {
} }
export const BackgroundSettingsContent = ({ form }: Props) => { export const BackgroundSettingsContent = ({ form }: Props) => {
const tBoard = useI18n("board"); const t = useI18n();
const { data: session } = useSession(); const { data: session } = useSession();
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200); const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
@@ -44,7 +44,7 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats); const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
return ( return (
<SectionCard title={tBoard("setting.section.background.title")}> <SectionCard title={t("board.setting.section.background.title")}>
<Grid> <Grid>
<Grid.Col span={12}> <Grid.Col span={12}>
<Group wrap="nowrap" gap="xs" w="100%" align="start"> <Group wrap="nowrap" gap="xs" w="100%" align="start">
@@ -67,8 +67,8 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
} }
// We filter it on the server // We filter it on the server
filter={({ options }) => options} filter={({ options }) => options}
label={tBoard("field.backgroundImageUrl.label")} label={t("board.field.backgroundImageUrl.label")}
placeholder={`${tBoard("field.backgroundImageUrl.placeholder")}...`} placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => { renderOption={({ option }) => {
const current = imageMap.get(option.value); const current = imageMap.get(option.value);
if (!current) return null; if (!current) return null;
@@ -87,13 +87,13 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}} }}
data={[ data={[
{ {
group: tBoard("field.backgroundImageUrl.group.your"), group: t("board.field.backgroundImageUrl.group.your"),
items: images items: images
.filter((media) => media.creatorId === session?.user.id) .filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`), .map((media) => `/api/user-medias/${media.id}`),
}, },
{ {
group: tBoard("field.backgroundImageUrl.group.other"), group: t("board.field.backgroundImageUrl.group.other"),
items: images items: images
.filter((media) => media.creatorId !== session?.user.id) .filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`), .map((media) => `/api/user-medias/${media.id}`),
@@ -123,21 +123,21 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<SelectWithDescriptionBadge <SelectWithDescriptionBadge
label={tBoard("field.backgroundImageAttachment.label")} label={t("board.field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData} data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")} {...form.getInputProps("backgroundImageAttachment")}
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<SelectWithDescriptionBadge <SelectWithDescriptionBadge
label={tBoard("field.backgroundImageSize.label")} label={t("board.field.backgroundImageSize.label")}
data={backgroundImageSizeData} data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")} {...form.getInputProps("backgroundImageSize")}
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<SelectWithDescriptionBadge <SelectWithDescriptionBadge
label={tBoard("field.backgroundImageRepeat.label")} label={t("board.field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData} data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")} {...form.getInputProps("backgroundImageRepeat")}
/> />
@@ -176,20 +176,19 @@ const useBackgroundOptionData = <
defaultValue: keyof TOptions; defaultValue: keyof TOptions;
}, },
) => { ) => {
const tBoard = useI18n("board"); const t = useI18n();
const tCommon = useI18n("common");
return data.values.map( return data.values.map(
(value) => (value) =>
({ ({
label: tBoard(`field.${key}.option.${value as string}.label` as never), label: t(`board.field.${key}.option.${value as string}.label` as never),
description: tBoard(`field.${key}.option.${value as string}.description` as never), description: t(`board.field.${key}.option.${value as string}.description` as never),
value: value as string, value: value as string,
badge: badge:
data.defaultValue === value data.defaultValue === value
? { ? {
color: "blue", color: "blue",
label: tCommon("select.badge.recommended"), label: t("common.select.badge.recommended"),
} }
: undefined, : undefined,
}) satisfies SelectItemWithDescriptionBadge, }) satisfies SelectItemWithDescriptionBadge,
@@ -13,13 +13,13 @@ interface Props {
} }
export const BehaviorSettingsContent = ({ form }: Props) => { export const BehaviorSettingsContent = ({ form }: Props) => {
const t = useI18n("board"); const t = useI18n();
return ( return (
<SectionCard title={t("setting.section.behavior.title")}> <SectionCard title={t("board.setting.section.behavior.title")}>
<Switch <Switch
label={t("field.disableStatus.label")} label={t("board.field.disableStatus.label")}
description={t("field.disableStatus.description")} description={t("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })} {...form.getInputProps("disableStatus", { type: "checkbox" })}
/> />
</SectionCard> </SectionCard>
@@ -19,7 +19,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation(); const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation(); const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const tBoard = useI18n("board"); const t = useI18n();
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery( const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{ {
@@ -52,7 +52,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
mutate: userMutation.mutate, mutate: userMutation.mutate,
isPending: userMutation.isPending, isPending: userMutation.isPending,
}} }}
translate={(key) => tBoard(`setting.section.access.permission.item.${key}.label` as never)} translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
permission={{ permission={{
items: boardPermissions, items: boardPermissions,
default: "view", default: "view",
@@ -10,7 +10,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card"; import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button"; import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
@@ -24,11 +24,11 @@ interface Props {
export const CustomCssSettingsContent = ({ form }: Props) => { export const CustomCssSettingsContent = ({ form }: Props) => {
const { data: session } = useSession(); const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false; const isAdmin = session?.user.permissions.includes("admin") ?? false;
const t = useI18n("board"); const t = useI18n();
const customCssT = useI18n("board.field.customCss"); const customCssT = useScopedI18n("board.field.customCss");
return ( return (
<SectionCard title={t("setting.section.customCss.title")}> <SectionCard title={t("board.setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} /> <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 />}>
@@ -46,7 +46,7 @@ interface CustomCssInputProps {
} }
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => { const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useI18n("board.field.customCss"); const customCssT = useScopedI18n("board.field.customCss");
return ( return (
<Input.Wrapper <Input.Wrapper
@@ -7,15 +7,14 @@ import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n } 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 { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => { export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useI18n("board.setting"); const t = useScopedI18n("board.setting");
const tCommonAction = useI18n("common.action");
const router = useRouter(); const router = useRouter();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal); const { openModal } = useModalAction(BoardRenameModal);
@@ -105,11 +104,11 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
/> />
)} )}
<DangerZoneItem <DangerZoneItem
label={tCommonAction("delete")} label={t("section.dangerZone.action.delete.label")}
description={t("section.dangerZone.action.delete.description")} description={t("section.dangerZone.action.delete.description")}
action={ action={
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}> <Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
{tCommonAction("delete")} {t("section.dangerZone.action.delete.button")}
</Button> </Button>
} }
/> />
@@ -8,9 +8,9 @@ import { useUpdateBoard } from "@homarr/boards/updater";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection"; import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card"; import { SectionCard } from "~/components/manage/section-card";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form"; import type { FormValues } from "./_settings-form";
@@ -20,27 +20,25 @@ interface Props {
} }
export const GeneralSettingsContent = ({ board, form }: Props) => { export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n("board"); const t = useI18n();
const { branding } = useSettings();
const defaultMetaTitle = t("content.metaTitle", { boardName: board.name });
useLogoPreview(form.values.logoImageUrl); useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle, defaultMetaTitle); const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
return ( return (
<SectionCard title={t("setting.section.general.title")}> <SectionCard title={t("board.setting.section.general.title")}>
<Grid> <Grid>
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("field.pageTitle.label")} label={t("board.field.pageTitle.label")}
placeholder={branding.appName} placeholder="Homarr"
{...form.getInputProps("pageTitle")} {...form.getInputProps("pageTitle")}
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("field.metaTitle.label")} label={t("board.field.metaTitle.label")}
placeholder={defaultMetaTitle} placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />} rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")} {...form.getInputProps("metaTitle")}
/> />
@@ -48,16 +46,16 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker <IconPicker
{...form.getInputProps("logoImageUrl")} {...form.getInputProps("logoImageUrl")}
label={t("field.logoImageUrl.label")} label={t("board.field.logoImageUrl.label")}
placeholder={branding.logoImageUrl ?? "/logo/logo.png"} placeholder="/logo/logo.png"
withAsterisk={false} withAsterisk={false}
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker <IconPicker
{...form.getInputProps("faviconImageUrl")} {...form.getInputProps("faviconImageUrl")}
label={t("field.faviconImageUrl.label")} label={t("board.field.faviconImageUrl.label")}
placeholder={branding.faviconImageUrl ?? branding.logoImageUrl ?? "/logo/logo.png"} placeholder="/logo/logo.png"
withAsterisk={false} withAsterisk={false}
/> />
</Grid.Col> </Grid.Col>
@@ -78,9 +76,9 @@ const useLogoPreview = (url: string | null) => {
}, [logoDebounced, updateBoard]); }, [logoDebounced, updateBoard]);
}; };
const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => { const useMetaTitlePreview = (title: string | null) => {
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200); const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle); useDocumentTitle(metaTitleDebounced);
return { return {
isPending: (title ?? "") !== metaTitleDebounced, isPending: (title ?? "") !== metaTitleDebounced,
@@ -6,7 +6,7 @@ import { IconLayoutGrid } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview"; import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview"; import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions"; import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui"; import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { widgetCatalogIcons } from "@homarr/widgets/catalog";
@@ -23,8 +23,6 @@ interface Props {
} }
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => { export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
const tBoard = useI18n("board");
const tSection = useI18n("section");
const t = useI18n(); const t = useI18n();
const elements = projectBoardLayout(board, sourceLayout, layout); const elements = projectBoardLayout(board, sourceLayout, layout);
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts); const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
@@ -44,13 +42,13 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
{representativeWidth}px {representativeWidth}px
</Badge> </Badge>
<Text size="xs"> <Text size="xs">
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")} {layout.columnCount} {t("board.setting.section.layout.preview.columns")}
</Text> </Text>
</Group> </Group>
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}> <Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
{elements.length === 0 ? ( {elements.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="xl"> <Text size="xs" c="dimmed" ta="center" py="xl">
{tBoard("setting.section.layout.preview.empty")} {t("board.setting.section.layout.preview.empty")}
</Text> </Text>
) : ( ) : (
<Stack gap="xs"> <Stack gap="xs">
@@ -78,8 +76,8 @@ export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Pr
const containerLabel = const containerLabel =
container?.kind === "container" && container.options.title container?.kind === "container" && container.options.title
? container.options.title ? container.options.title
: tSection("container.untitled"); : t("section.container.untitled");
const label = app?.name ?? (item ? getWidgetName(item.kind, t) : containerLabel); const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : containerLabel);
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid; const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
return ( return (
@@ -41,9 +41,7 @@ interface Props {
} }
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => { export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board"); const t = useI18n();
const tLayout = useI18n("layout");
const tCommon = useI18n("common");
const router = useRouter(); const router = useRouter();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
@@ -52,8 +50,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({ const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
onError() { onError() {
showErrorNotification({ showErrorNotification({
title: tCommon("notification.update.error"), title: t("common.notification.update.error"),
message: tCommon("notification.update.error"), message: t("common.notification.update.error"),
}); });
}, },
}); });
@@ -86,14 +84,15 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const confirmReset = (layout: FormValues["layouts"][number]) => { const confirmReset = (layout: FormValues["layouts"][number]) => {
openConfirmModal({ openConfirmModal({
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }), title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
children: tBoard("setting.section.layout.reset.confirm.message"), children: t("board.setting.section.layout.reset.confirm.message"),
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" }, confirmProps: { children: t("board.setting.section.layout.reset.action"), color: "red" },
onConfirm() { onConfirm() {
setResettingLayoutId(layout.id); setResettingLayoutId(layout.id);
void (async () => { void (async () => {
try { try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id); const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values;
const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
if (!canonicalLayout) return; if (!canonicalLayout) return;
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
@@ -111,14 +110,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base"); const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const sortedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
return ( return (
<SectionCard title={tBoard("setting.section.layout.title")}> <SectionCard title={t("board.setting.section.layout.title")}>
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap"> <Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2} maw="52rem"> <Stack gap={2} maw="52rem">
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text> <Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<Text size="sm">{tBoard("setting.section.layout.responsive.description")}</Text> <Text size="sm">{t("board.setting.section.layout.responsive.description")}</Text>
</Stack> </Stack>
<Button <Button
type="button" type="button"
@@ -126,25 +128,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
disabled={nextBreakpoint === null || !baseLayout} disabled={nextBreakpoint === null || !baseLayout}
onClick={() => { onClick={() => {
if (nextBreakpoint === null || !baseLayout) return; if (nextBreakpoint === null || !baseLayout) return;
const newLayout: FormValues["layouts"][number] = { form.setFieldValue("layouts", [
id: createId(), ...form.values.layouts,
name: tBoard("setting.section.layout.custom.defaultName"), {
columnCount: baseLayout.columnCount, id: createId(),
leftGutterColumnCount: baseLayout.leftGutterColumnCount, name: t("board.setting.section.layout.custom.defaultName"),
rightGutterColumnCount: baseLayout.rightGutterColumnCount, columnCount: baseLayout.columnCount,
breakpoint: nextBreakpoint, leftGutterColumnCount: baseLayout.leftGutterColumnCount,
role: "custom", rightGutterColumnCount: baseLayout.rightGutterColumnCount,
}; breakpoint: nextBreakpoint,
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint); role: "custom",
if (insertionIndex === -1) insertionIndex = form.values.layouts.length; },
form.insertListItem("layouts", newLayout, insertionIndex); ]);
}} }}
> >
{tBoard("setting.section.layout.responsive.action.add")} {t("board.setting.section.layout.responsive.action.add")}
</Button> </Button>
</Group> </Group>
{form.values.layouts.map((layout, index) => { {sortedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base"); const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
const customBreakpoints = form.values.layouts const customBreakpoints = form.values.layouts
@@ -157,9 +159,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
key={layout.id} key={layout.id}
legend={ legend={
<Group gap="xs"> <Group gap="xs">
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text> <Text>{layout.name || t(`board.setting.section.layout.role.${layout.role}`)}</Text>
<Badge size="sm" variant="default"> <Badge size="sm" variant="default">
{tBoard(`setting.section.layout.role.${layout.role}` as never)} {t(`board.setting.section.layout.role.${layout.role}`)}
</Badge> </Badge>
</Group> </Group>
} }
@@ -168,10 +170,10 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start"> <Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}> <Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
<Stack gap="md"> <Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} /> <TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
<Input.Wrapper label={tLayout("field.columnCount.label")}> <Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider <Slider
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`} thumbLabel={`${t("layout.field.columnCount.label")} — ${layout.name}`}
mt="xs" mt="xs"
min={1} min={1}
max={24} max={24}
@@ -192,11 +194,11 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
</Input.Wrapper> </Input.Wrapper>
<NumberInput <NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)} {...form.getInputProps(`layouts.${index}.breakpoint`)}
label={tLayout("field.breakpoint.label")} label={t("layout.field.breakpoint.label")}
description={ description={
layout.role === "mobile" layout.role === "mobile"
? tBoard("setting.section.layout.mobile.breakpointDescription") ? t("board.setting.section.layout.mobile.breakpointDescription")
: tLayout("field.breakpoint.description") : t("layout.field.breakpoint.description")
} }
disabled={layout.role === "mobile"} disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }} styles={{ description: { color: "var(--mantine-color-text)" } }}
@@ -244,7 +246,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
disabled={isSaving} disabled={isSaving}
onClick={() => confirmReset(layout)} onClick={() => confirmReset(layout)}
> >
{tBoard("setting.section.layout.reset.action")} {t("board.setting.section.layout.reset.action")}
</Button> </Button>
)} )}
{layout.role === "custom" && ( {layout.role === "custom" && (
@@ -260,7 +262,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
); );
}} }}
> >
{tCommon("action.remove")} {t("common.action.remove")}
</Button> </Button>
)} )}
</Group> </Group>
@@ -273,8 +275,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
onClick={() => void openLayoutEditorAsync(layout)} onClick={() => void openLayoutEditorAsync(layout)}
> >
{form.isDirty() || !persistedLayout {form.isDirty() || !persistedLayout
? tBoard("setting.section.layout.edit.saveAndEdit") ? t("board.setting.section.layout.edit.saveAndEdit")
: tBoard("setting.section.layout.edit.action")} : t("board.setting.section.layout.edit.action")}
</Button> </Button>
</Group> </Group>
</Fieldset> </Fieldset>
@@ -320,16 +322,16 @@ const gutterMarks = [
]; ];
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => { const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
const t = useI18n("layout"); const t = useI18n();
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1)); const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
return ( return (
<Paper className={classes.gutterSettings} p="md"> <Paper className={classes.gutterSettings} p="md">
<Stack gap="md"> <Stack gap="md">
<Box> <Box>
<Text fw={600}>{t("field.gutters.label")}</Text> <Text fw={600}>{t("layout.field.gutters.label")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("field.gutters.description")} {t("layout.field.gutters.description")}
</Text> </Text>
</Box> </Box>
@@ -369,7 +371,7 @@ const GutterControl = ({
max: number; max: number;
onChange: (value: number) => void; onChange: (value: number) => void;
}) => { }) => {
const t = useI18n("layout"); const t = useI18n();
const enabled = value > 0; const enabled = value > 0;
return ( return (
@@ -378,10 +380,10 @@ const GutterControl = ({
checked={enabled} checked={enabled}
disabled={max < 1} disabled={max < 1}
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)} onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
label={t(`field.gutters.${side}.label` as never)} label={t(`layout.field.gutters.${side}.label`)}
/> />
{enabled && ( {enabled && (
<Input.Wrapper label={t("field.gutters.width.label")}> <Input.Wrapper label={t("layout.field.gutters.width.label")}>
<Slider <Slider
mt="xs" mt="xs"
min={1} min={1}
@@ -389,7 +391,7 @@ const GutterControl = ({
value={Math.min(value, max)} value={Math.min(value, max)}
marks={gutterMarks.filter((mark) => mark.value <= max)} marks={gutterMarks.filter((mark) => mark.value <= max)}
restrictToMarks restrictToMarks
thumbLabel={t(`field.gutters.${side}.thumbLabel` as never)} thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
onChange={(nextValue) => onChange(Math.min(nextValue, max))} onChange={(nextValue) => onChange(Math.min(nextValue, max))}
/> />
</Input.Wrapper> </Input.Wrapper>
@@ -399,7 +401,7 @@ const GutterControl = ({
}; };
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => { const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
const t = useI18n("layout"); const t = useI18n();
const safeColumnCount = Math.max(1, columnCount); const safeColumnCount = Math.max(1, columnCount);
const main = Math.max(1, safeColumnCount - left - right); const main = Math.max(1, safeColumnCount - left - right);
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null] const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
@@ -409,7 +411,7 @@ const GutterPreview = ({ left, right, columnCount }: { left: number; right: numb
return ( return (
<Box <Box
className={classes.gutterPreview} className={classes.gutterPreview}
aria-label={t("field.gutters.preview.label")} aria-label={t("layout.field.gutters.preview.label")}
style={{ gridTemplateColumns: tracks }} style={{ gridTemplateColumns: tracks }}
> >
{left > 0 && <Box className={classes.previewGutter} data-side="left" />} {left > 0 && <Box className={classes.previewGutter} data-side="left" />}
@@ -10,11 +10,10 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board"; import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/constants"; import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { SectionCard } from "~/components/manage/section-card"; import { SectionCard } from "~/components/manage/section-card";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar"; import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
@@ -87,9 +86,8 @@ interface BoardSettingsFormProps {
} }
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => { export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n("common"); const t = useI18n();
const tSection = useI18n("board.setting.section"); const tSection = useScopedI18n("board.setting.section");
const { branding } = useSettings();
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board); const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board); const saveLayouts = useSaveLayoutsMutation(board);
@@ -144,9 +142,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const [, canonicalLayouts] = await Promise.all([ const [, canonicalLayouts] = await Promise.all([
partialSettingsChanged partialSettingsChanged
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => { ? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
updateFavicon( updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
values.faviconImageUrl ?? branding.faviconImageUrl ?? branding.logoImageUrl ?? homarrLogoPath,
);
}) })
: Promise.resolve(), : Promise.resolve(),
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null), layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
@@ -160,14 +156,14 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
form.resetDirty(canonicalValues); form.resetDirty(canonicalValues);
await revalidatePathActionAsync(`/boards/${board.name}/settings`); await revalidatePathActionAsync(`/boards/${board.name}/settings`);
showSuccessNotification({ showSuccessNotification({
title: t("notification.update.success"), title: t("common.notification.update.success"),
message: t("notification.update.success"), message: t("common.notification.update.success"),
}); });
return canonicalValues; return canonicalValues;
} catch { } catch {
showErrorNotification({ showErrorNotification({
title: t("notification.update.error"), title: t("common.notification.update.error"),
message: t("notification.update.error"), message: t("common.notification.update.error"),
}); });
return null; return null;
} }
@@ -204,10 +200,10 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
{form.isDirty() && ( {form.isDirty() && (
<UnsavedChangesBar> <UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}> <Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("action.discard")} {t("common.action.discard")}
</Button> </Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}> <Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("action.saveChanges")} {t("common.action.saveChanges")}
</Button> </Button>
</UnsavedChangesBar> </UnsavedChangesBar>
)} )}
@@ -6,7 +6,7 @@ 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 { getI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { BoardSettingsForm } from "./_settings-form"; import { BoardSettingsForm } from "./_settings-form";
@@ -46,7 +46,7 @@ export default async function BoardSettingsPage(props: Props) {
const { board, permissions } = await getBoardAndPermissionsAsync(params); const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board"); const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board); const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
const t = await getI18n("board.setting"); const t = await getScopedI18n("board.setting");
if (!hasChangeAccess) { if (!hasChangeAccess) {
notFound(); notFound();
@@ -12,8 +12,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { appShellLogoHeight } from "~/components/layout/constants"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
import { env } from "~/env"; import { env } from "~/env";
@@ -28,17 +27,13 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" }); const logger = createLogger({ module: "createBoardLayout" });
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions?: JSX.Element; headerActions: JSX.Element;
headerBoardEditAction?: JSX.Element;
headerBoardSettingsAction?: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean; withTour?: boolean;
} }
export const createBoardLayout = <TParams extends Params>({ export const createBoardLayout = <TParams extends Params>({
headerActions, headerActions,
headerBoardEditAction,
headerBoardSettingsAction,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
withTour = false, withTour = false,
}: CreateBoardLayoutProps<TParams>) => { }: CreateBoardLayoutProps<TParams>) => {
@@ -110,11 +105,8 @@ export const createBoardLayout = <TParams extends Params>({
<BoardTourGate enabled={shouldRunBoardTour}> <BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}> <ClientShell hasNavigation={false}>
<MainHeader <MainHeader
logo={<BoardLogo size={appShellLogoHeight} />} logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions} actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false} hasNavigation={false}
/> />
<AppShellMain data-advanced-focus-background>{children}</AppShellMain> <AppShellMain data-advanced-focus-background>{children}</AppShellMain>
@@ -16,7 +16,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react"; import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
interface RequestDetails { interface RequestDetails {
widgetName: string; widgetName: string;
@@ -31,8 +31,7 @@ interface RequestDetails {
} }
export function CustomWidgetConfigurationEntry({ token }: { token: string }) { export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const t = useI18n("customWidget.secretEntry"); const t = useScopedI18n("customWidget.secretEntry");
const tSecret = useI18n("customWidget.secret");
const [details, setDetails] = useState<RequestDetails | null>(null); const [details, setDetails] = useState<RequestDetails | null>(null);
const [values, setValues] = useState<Record<string, string>>({}); const [values, setValues] = useState<Record<string, string>>({});
const [baseUrl, setBaseUrl] = useState(""); const [baseUrl, setBaseUrl] = useState("");
@@ -121,7 +120,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
return ( return (
<Input <Input
key={kind} key={kind}
label={tSecret(kind)} label={t(`field.${kind}`)}
leftSection={<IconKey size={16} />} leftSection={<IconKey size={16} />}
value={values[kind] ?? ""} value={values[kind] ?? ""}
onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))} onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))}
+38 -52
View File
@@ -27,7 +27,6 @@ import { SettingsProvider } from "@homarr/settings";
import { SpotlightProvider } from "@homarr/spotlight"; import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation"; import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { Analytics } from "~/components/layout/analytics"; import { Analytics } from "~/components/layout/analytics";
@@ -54,45 +53,35 @@ const fontSans = Inter({
const logger = createLogger({ module: "rootLayout" }); const logger = createLogger({ module: "rootLayout" });
export const generateMetadata = async (): Promise<Metadata> => { // eslint-disable-next-line no-restricted-syntax
const [serverSettings, colorScheme, t] = await Promise.all([ export const generateMetadata = async (): Promise<Metadata> => ({
getRscServerSettingsAsync(), title: "Homarr",
getCurrentColorSchemeAsync(), description:
getI18n("metadata"), "A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
]); openGraph: {
const { appName, faviconImageUrl, logoImageUrl } = serverSettings.branding; title: "Homarr Dashboard",
const logo = logoImageUrl ?? "/logo/logo.png"; description:
const favicon = faviconImageUrl ?? logo; "A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
const description = t("description"); url: env.HOMARR_WEBSITE_URL,
siteName: "Homarr",
},
icons: {
icon: "/logo/logo.png",
apple: "/logo/logo.png",
},
appleWebApp: {
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
},
});
return { export const viewport: Viewport = {
title: { themeColor: [
default: appName, { media: "(prefers-color-scheme: light)", color: "white" },
template: `%s • ${appName}`, { media: "(prefers-color-scheme: dark)", color: "black" },
}, ],
description,
openGraph: {
title: t("dashboardTitle", { appName }),
description,
url: env.HOMARR_WEBSITE_URL,
siteName: appName,
},
icons: {
icon: favicon,
apple: logo,
},
appleWebApp: {
title: appName,
capable: true,
startupImage: { url: logo },
statusBarStyle: colorScheme === "dark" ? "black-translucent" : "default",
},
};
};
export const generateViewport = async (): Promise<Viewport> => {
const serverSettings = await getRscServerSettingsAsync();
return { themeColor: serverSettings.branding.primaryColor };
}; };
export default async function Layout(props: { export default async function Layout(props: {
@@ -105,7 +94,6 @@ export default async function Layout(props: {
} }
const sessionPromise = auth(); const sessionPromise = auth();
const serverSettingsPromise = getRscServerSettingsAsync();
const userPromise = sessionPromise.then((session) => const userPromise = sessionPromise.then((session) =>
session session
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
@@ -114,17 +102,18 @@ export default async function Layout(props: {
}) })
: null, : null,
); );
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => { const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
if (!session) return "unauthenticated"; !session
return api.assistant ? "unauthenticated"
.getAvailability() : api.assistant
.then((availability) => (availability.enabled ? "enabled" : "unconfigured")) .getAvailability()
.catch(() => "error"); .then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
}); .catch(() => "error"),
);
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([ const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise, sessionPromise,
userPromise, userPromise,
serverSettingsPromise, getRscServerSettingsAsync(),
getCurrentColorSchemeAsync(), getCurrentColorSchemeAsync(),
assistantAvailabilityPromise, assistantAvailabilityPromise,
]); ]);
@@ -159,7 +148,6 @@ export default async function Layout(props: {
}, },
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId }, search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
user: { enableGravatar: serverSettings.user.enableGravatar }, user: { enableGravatar: serverSettings.user.enableGravatar },
branding: serverSettings.branding,
}} }}
{...innerProps} {...innerProps}
/> />
@@ -168,9 +156,7 @@ export default async function Layout(props: {
(innerProps) => <TRPCReactProvider {...innerProps} />, (innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />, (innerProps) => <DayJsLoader {...innerProps} />,
(innerProps) => <NextIntlClientProvider {...innerProps} />, (innerProps) => <NextIntlClientProvider {...innerProps} />,
(innerProps) => ( (innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
<CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} branding={serverSettings.branding} />
),
(innerProps) => <ModalProvider {...innerProps} />, (innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />, (innerProps) => <SpotlightProvider {...innerProps} />,
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />, (innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
@@ -1,14 +1,12 @@
import { Box, Group, Image, Stack, Title } from "@mantine/core"; import { Box, Group, Image, Stack, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationMarquee } from "@homarr/ui"; import { IntegrationMarquee } from "@homarr/ui";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import classes from "./hero-banner.module.css"; import classes from "./hero-banner.module.css";
export const HeroBanner = async () => { export const HeroBanner = async () => {
const [t, serverSettings] = await Promise.all([getI18n("management.page.home"), getRscServerSettingsAsync()]); const t = await getScopedI18n("management.page.home");
const { appName, logoImageUrl } = serverSettings.branding;
return ( return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative"> <Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
@@ -16,17 +14,9 @@ export const HeroBanner = async () => {
<Title fz={{ base: "h4", md: "h2" }} c="dimmed"> <Title fz={{ base: "h4", md: "h2" }} c="dimmed">
{t("heroBanner.title")} {t("heroBanner.title")}
</Title> </Title>
<Group gap="sm" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<Image <Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
src={logoImageUrl ?? "/logo/logo.png"} <Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title>
alt={`${appName} logo`}
w="auto"
h={{ base: 48, md: 64 }}
maw={{ base: 128, md: 192 }}
fit="contain"
style={{ flexShrink: 0 }}
/>
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: appName })}</Title>
</Group> </Group>
</Stack> </Stack>
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute"> <Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
@@ -35,10 +35,11 @@ import {
import { capitalize, objectEntries } from "@homarr/common"; import { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/constants"; import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader"; import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
import githubContributors from "@static-data/contributors.json"; import githubContributors from "@static-data/contributors.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json"; import openCollectiveContributors from "@static-data/opencollective-contributors.json";
@@ -46,15 +47,15 @@ import crowdinContributors from "@static-data/translators.json";
import classes from "./about.module.css"; import classes from "./about.module.css";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getI18n("management"); const t = await getScopedI18n("management");
return { return {
title: t("metaTitle"), title: createMetaTitle(t("metaTitle")),
}; };
} }
export default async function AboutPage() { export default async function AboutPage() {
const t = await getI18n("management.page.about"); const t = await getScopedI18n("management.page.about");
const version = getPackageVersion(); const version = getPackageVersion();
const dependencies = await getDependenciesAsync(); const dependencies = await getDependenciesAsync();
@@ -9,15 +9,15 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
interface AppDeleteButtonProps { interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number]; app: RouterOutputs["app"]["all"][number];
} }
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => { export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useI18n("app.page.delete"); const t = useScopedI18n("app.page.delete");
const tCommon = useI18n("common"); const tList = useScopedI18n("app.page.list.action");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation(); const { mutate, isPending } = clientApi.app.delete.useMutation();
@@ -33,14 +33,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
{ {
onSuccess: () => { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
title: tCommon("notification.delete.success"), title: t("notification.success.title"),
message: t("notification.success.message"), message: t("notification.success.message"),
}); });
void revalidatePathActionAsync("/manage/apps"); void revalidatePathActionAsync("/manage/apps");
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
title: tCommon("notification.delete.error"), title: t("notification.error.title"),
message: t("notification.error.message"), message: t("notification.error.message"),
}); });
}, },
@@ -48,7 +48,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
); );
}, },
}); });
}, [app, mutate, t, tCommon, openConfirmModal]); }, [app, mutate, t, openConfirmModal]);
return ( return (
<ActionIcon <ActionIcon
@@ -57,7 +57,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
color="red" color="red"
size={44} size={44}
onClick={onClick} onClick={onClick}
aria-label={tCommon("action.deleteNamed", { name: app.name })} aria-label={tList("delete", { name: app.name })}
> >
<IconTrash color="red" size={16} stroke={1.5} /> <IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { AppForm } from "@homarr/forms-collection"; import { AppForm } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app"; import type { appManageSchema } from "@homarr/validation/app";
interface AppEditFormProps { interface AppEditFormProps {
@@ -17,14 +17,14 @@ interface AppEditFormProps {
} }
export const AppEditForm = ({ app }: AppEditFormProps) => { export const AppEditForm = ({ app }: AppEditFormProps) => {
const tScoped = useI18n("app.page.edit.notification"); const tScoped = useScopedI18n("app.page.edit.notification");
const tCommon = useI18n("common"); const t = useI18n();
const router = useRouter(); const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({ const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
title: tCommon("notification.update.success"), title: tScoped("success.title"),
message: tScoped("success.message"), message: tScoped("success.message"),
}); });
void revalidatePathActionAsync("/manage/apps").then(() => { void revalidatePathActionAsync("/manage/apps").then(() => {
@@ -33,7 +33,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
title: tCommon("notification.update.error"), title: tScoped("error.title"),
message: tScoped("error.message"), message: tScoped("error.message"),
}); });
}, },
@@ -52,7 +52,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
return ( return (
<AppForm <AppForm
buttonLabels={{ buttonLabels={{
submit: tCommon("action.save"), submit: t("common.action.save"),
}} }}
initialValues={app} initialValues={app}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
@@ -9,7 +9,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getSafeAppHref } from "@homarr/common"; import { getSafeAppHref } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui"; import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target"; import { TourTarget } from "~/components/layout/header/tour-target";
@@ -52,8 +52,7 @@ export default async function AppsPage(props: AppsPageProps) {
const { items: apps, totalCount } = canManageAll const { items: apps, totalCount } = canManageAll
? await api.app.getPaginated(searchParams) ? await api.app.getPaginated(searchParams)
: { items: [], totalCount: 0 }; : { items: [], totalCount: 0 };
const t = await getI18n("app"); const t = await getScopedI18n("app");
const tCommon = await getI18n("common");
const hasSearch = canManageAll && Boolean(searchParams.search?.trim()); const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
const emptyState = !canManageAll ? ( const emptyState = !canManageAll ? (
@@ -68,7 +67,7 @@ export default async function AppsPage(props: AppsPageProps) {
icon={IconBox} icon={IconBox}
title={t("page.list.noResults.filteredTitle")} title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })} description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: tCommon("action.clearSearch"), href: "/manage/apps" }} action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }}
/> />
) : ( ) : (
<NoResults <NoResults
@@ -81,7 +80,7 @@ export default async function AppsPage(props: AppsPageProps) {
const page = ( const page = (
<ManageCollectionPage <ManageCollectionPage
title={tCommon("entity.apps")} title={t("page.list.title")}
ariaLabel={t("page.list.ariaLabel")} ariaLabel={t("page.list.ariaLabel")}
itemCount={apps.length} itemCount={apps.length}
emptyState={emptyState} emptyState={emptyState}
@@ -116,7 +115,7 @@ export default async function AppsPage(props: AppsPageProps) {
key={app.id} key={app.id}
app={app} app={app}
canDelete={canDelete} canDelete={canDelete}
editLabel={tCommon("action.editNamed", { name: app.name })} editLabel={t("page.list.action.edit", { name: app.name })}
/> />
))} ))}
</ManageCollectionPage> </ManageCollectionPage>
@@ -51,7 +51,7 @@ import {
} from "@homarr/definitions"; } from "@homarr/definitions";
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions"; import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client"; import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
@@ -140,8 +140,7 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => (
); );
export const AssistantConfiguration = () => { export const AssistantConfiguration = () => {
const t = useI18n("management.page.settings.section.assistant"); const t = useScopedI18n("management.page.settings.section.assistant");
const tCommon = useI18n("common");
const router = useRouter(); const router = useRouter();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery(); const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
@@ -209,7 +208,7 @@ export const AssistantConfiguration = () => {
.filter((providerId) => assistantProviderPresets[providerId].category === category) .filter((providerId) => assistantProviderPresets[providerId].category === category)
.map((providerId) => ({ .map((providerId) => ({
value: providerId, value: providerId,
label: assistantProviderPresets[providerId].label ?? t("provider.options.custom.label"), label: t(`provider.options.${providerId}.label`),
})); }));
return { group: t(`provider.groups.${category}`), items }; return { group: t(`provider.groups.${category}`), items };
@@ -373,7 +372,7 @@ export const AssistantConfiguration = () => {
</ThemeIcon> </ThemeIcon>
<Box className={classes.summaryProviderText}> <Box className={classes.summaryProviderText}>
<Group gap="xs"> <Group gap="xs">
<Text fw={650}>{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}</Text> <Text fw={650}>{t(`provider.options.${provider}.label`)}</Text>
<Badge <Badge
size="sm" size="sm"
variant="light" variant="light"
@@ -419,7 +418,7 @@ export const AssistantConfiguration = () => {
description={t("connection.description")} description={t("connection.description")}
status={ status={
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}> <Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")} {t(`provider.options.${provider}.label`)}
</Badge> </Badge>
} }
> >
@@ -759,7 +758,7 @@ export const AssistantConfiguration = () => {
{t("serverTools.webSearch.label")} {t("serverTools.webSearch.label")}
</Text> </Text>
<Badge size="xs" variant="light"> <Badge size="xs" variant="light">
{tCommon("beta")} {t("serverTools.beta")}
</Badge> </Badge>
</Group> </Group>
<Text size="sm" c="dimmed" maw="66ch"> <Text size="sm" c="dimmed" maw="66ch">
@@ -2,15 +2,15 @@ import { notFound } from "next/navigation";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { AssistantConfiguration } from "./_components/assistant-configuration"; import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getI18n("management.page.settings.section.assistant"); const t = await getScopedI18n("management.page.settings.section.assistant");
return { return {
title: t("title"), title: `${t("title")} • Homarr`,
}; };
} }
@@ -21,7 +21,7 @@ export default async function AssistantPage() {
notFound(); notFound();
} }
const t = await getI18n("management.page.settings.section.assistant"); const t = await getScopedI18n("management.page.settings.section.assistant");
return ( return (
<ManagePageLayout title={t("title")}> <ManagePageLayout title={t("title")}>
<Text c="dimmed" maw="70ch"> <Text c="dimmed" maw="70ch">
@@ -10,7 +10,7 @@ import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection"; import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -28,9 +28,8 @@ interface BoardCardMenuDropdownProps {
} }
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => { export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useI18n("management.page.board.action"); const t = useScopedI18n("management.page.board.action");
const tRoot = useI18n(); const tCommon = useScopedI18n("common");
const tCommon = useI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board); const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession(); const { data: session } = useSession();
@@ -58,7 +57,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const handleDeletion = useCallback(() => { const handleDeletion = useCallback(() => {
openConfirmModal({ openConfirmModal({
title: tCommon("action.deleteNamed", { name: board.name }), title: t("delete.confirm.title"),
children: t("delete.confirm.description", { children: t("delete.confirm.description", {
name: board.name, name: board.name,
}), }),
@@ -69,7 +68,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
}); });
}, },
}); });
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]); }, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]);
const handleSetHomeBoard = useCallback(async () => { const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id }); await setHomeBoardMutation.mutateAsync({ id: board.id });
@@ -98,7 +97,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
</Menu.Item> </Menu.Item>
{session?.user.permissions.includes("board-create") && ( {session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}> <Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{tRoot("board.action.duplicate.title")} {t("duplicate.label")}
</Menu.Item> </Menu.Item>
)} )}
{hasChangeAccess && ( {hasChangeAccess && (
@@ -123,7 +122,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
onClick={handleDeletion} onClick={handleDeletion}
disabled={deleteBoardMutation.isPending} disabled={deleteBoardMutation.isPending}
> >
{tCommon("action.delete")} {t("delete.label")}
</Menu.Item> </Menu.Item>
</> </>
)} )}
@@ -10,13 +10,13 @@ import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => { export const CreateBoardButton = () => {
const t = useI18n("management.page.board.action"); const t = useI18n();
const { openModal } = useModalAction(AddBoardModal); const { openModal } = useModalAction(AddBoardModal);
return ( return (
<ManageMobilePrimaryAction> <ManageMobilePrimaryAction>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}> <Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
{t("new.label")} {t("management.page.board.action.new.label")}
</Button> </Button>
</ManageMobilePrimaryAction> </ManageMobilePrimaryAction>
); );
@@ -25,7 +25,7 @@ import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { constructBoardPermissions } from "@homarr/auth/shared"; import { constructBoardPermissions } from "@homarr/auth/shared";
import { getI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
@@ -36,7 +36,7 @@ import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { CreateBoardButton } from "./_components/create-board-button"; import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() { export default async function ManageBoardsPage() {
const t = await getI18n("management.page.board"); const t = await getScopedI18n("management.page.board");
const session = await auth(); const session = await auth();
const boards = await api.board.getManageOverview(); const boards = await api.board.getManageOverview();
const canCreateBoards = session?.user.permissions.includes("board-create"); const canCreateBoards = session?.user.permissions.includes("board-create");
@@ -3,12 +3,12 @@
import { Alert, Anchor, Text } from "@mantine/core"; import { Alert, Anchor, Text } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react"; import { IconFlask } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets"; const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
export function CustomWidgetBetaBanner() { export function CustomWidgetBetaBanner() {
const t = useI18n("customWidget.betaBanner"); const t = useScopedI18n("customWidget.betaBanner");
return ( return (
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}> <Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
@@ -1,50 +1,48 @@
"use client"; "use client";
import { useMemo } from "react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench"; import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import { invariantTechnicalLabels } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>; type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">; type CodeEditorProps = Omit<SharedEditorProps, "messages">;
export function CodeEditor(props: CodeEditorProps) { export function CodeEditor(props: CodeEditorProps) {
const t = useI18n("customWidget.editor"); const t = useScopedI18n("customWidget.editor");
const tCommon = useI18n("common"); return (
const messages = useMemo<SharedEditorProps["messages"]>( <CustomWidgetCodeEditor
() => ({ {...props}
languageJsx: invariantTechnicalLabels.jsx, messages={{
languageJson: invariantTechnicalLabels.json, languageJsx: t("language.jsx"),
undo: tCommon("action.undo"), languageJson: t("language.json"),
redo: t("action.redo"), undo: t("action.undo"),
components: t("action.components"), redo: t("action.redo"),
componentSearch: t("componentReference.search"), components: t("action.components"),
componentEmpty: t("componentReference.empty"), componentSearch: t("componentReference.search"),
componentCount: (count) => t("componentReference.count", { count }), componentEmpty: t("componentReference.empty"),
insertStarter: t("action.insertStarter"), componentCount: (count) => t("componentReference.count", { count }),
format: t("action.format"), insertStarter: t("action.insertStarter"),
copy: tCommon("action.copy"), format: t("action.format"),
copied: t("action.copied"), copy: t("action.copy"),
schema: t("action.schema"), copied: t("action.copied"),
schemaTab: invariantTechnicalLabels.jsonSchema, schema: t("action.schema"),
minimalTab: t("reference.minimal"), schemaTab: t("reference.schema"),
fullTab: t("reference.full"), minimalTab: t("reference.minimal"),
errors: (count) => t("status.errors", { count }), fullTab: t("reference.full"),
warnings: (count) => t("status.warnings", { count }), errors: (count) => t("status.errors", { count }),
ready: t("status.ready"), warnings: (count) => t("status.warnings", { count }),
position: (cursor) => t("status.position", cursor), ready: t("status.ready"),
characters: (count, limit) => position: (cursor) => t("status.position", cursor),
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }), characters: (count, limit) =>
diagnosticsTitle: t("diagnostics.title"), limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnostic: (diagnostic) => diagnosticsTitle: t("diagnostics.title"),
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t( diagnostic: (diagnostic) =>
`diagnostics.${diagnostic.code}` as never, `${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
{ value: diagnostic.value ?? "" } as never, `diagnostics.${diagnostic.code}` as never,
)}`, { value: diagnostic.value ?? "" } as never,
}), )}`,
[t, tCommon], }}
/>
); );
return <CustomWidgetCodeEditor {...props} messages={messages} />;
} }
@@ -3,70 +3,37 @@
import { ActionIcon, Group, Tooltip } from "@mantine/core"; import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { IconCopy, IconRobot } from "@tabler/icons-react"; import { IconCopy, IconRobot } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt, buildCustomWidgetAssistantPrompt } from "@homarr/custom-widgets/authoring-prompt"; import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
interface CopyAiPromptButtonBaseProps { interface CopyAiPromptButtonProps {
rawResponse?: string | null; rawResponse?: string | null;
currentConfig?: Record<string, unknown> | null;
request?: string | null; request?: string | null;
documentationUrl?: string | null; documentationUrl?: string | null;
} }
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
(
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
);
export const CopyAiPromptButton = ({ export const CopyAiPromptButton = ({
rawResponse, rawResponse,
draft, currentConfig,
getDraft,
diagnostics,
getDiagnostics,
request, request,
documentationUrl, documentationUrl,
}: CopyAiPromptButtonProps) => { }: CopyAiPromptButtonProps) => {
const t = useI18n("customWidget"); const t = useScopedI18n("customWidget");
const assistant = useOptionalHomarrAssistant(); const assistant = useOptionalHomarrAssistant();
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
const readDraft = () => {
if (getDraft) return getDraft();
return draft;
};
const readDiagnostics = () => {
if (getDiagnostics) return getDiagnostics();
return diagnostics;
};
const handleAssistant = () => { const handleAssistant = () => {
const prompt = buildCustomWidgetAssistantPrompt( if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return;
undefined,
rawResponse,
readDraft(),
request,
documentationUrl,
readDiagnostics(),
);
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") }); showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
}; };
const handleCopy = async () => { const handleCopy = async () => {
try { try {
const value = buildCustomWidgetAiPrompt( const value = prompt();
undefined,
rawResponse,
readDraft(),
request,
documentationUrl,
readDiagnostics(),
);
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
showSuccessNotification({ showSuccessNotification({
title: t("action.copyAiPrompt"), title: t("action.copyAiPrompt"),
@@ -22,7 +22,7 @@ import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } fr
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
@@ -39,7 +39,7 @@ interface WidgetRef {
} }
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useI18n("customWidget"); const t = useScopedI18n("customWidget");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation(); const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
@@ -1,38 +1,27 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Accordion } from "@mantine/core";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core"; import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
import { useDeferredCustomWidgetFormDocumentValues } from "./_custom-widget-form-state";
import { applyDefinition, parseJson } from "./_custom-widget-form-utils"; import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { LazyOnceAccordion } from "./_lazy-once-accordion";
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) { export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
const t = useI18n("customWidget.workbench.builder"); const t = useScopedI18n("customWidget.workbench.builder");
return (
<LazyOnceAccordion label={t("advancedManifest")}>
<CustomWidgetManifestEditor form={form} />
</LazyOnceAccordion>
);
}
function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm }) {
const t = useI18n("customWidget.workbench.builder");
const values = useDeferredCustomWidgetFormDocumentValues();
const serialized = JSON.stringify( const serialized = JSON.stringify(
{ {
$schema: "homarr-custom-widget-v2", $schema: "homarr-custom-widget-v2",
name: values.name, name: form.values.name,
...(values.description ? { description: values.description } : {}), ...(form.values.description ? { description: form.values.description } : {}),
...(values.iconUrl ? { iconUrl: values.iconUrl } : {}), ...(form.values.iconUrl ? { iconUrl: form.values.iconUrl } : {}),
sources: parseJson(values.sources), sources: parseJson(form.values.sources),
requests: parseJson(values.requests), requests: parseJson(form.values.requests),
options: parseJson(values.options), options: parseJson(form.values.options),
template: values.template, template: form.values.template,
}, },
null, null,
2, 2,
@@ -41,26 +30,33 @@ function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
useEffect(() => setDraft(serialized), [serialized]); useEffect(() => setDraft(serialized), [serialized]);
return ( return (
<CodeEditor <Accordion variant="contained">
id="raw-widget-manifest" <Accordion.Item value="manifest">
label={t("manifestFilename")} <Accordion.Control>{t("advancedManifest")}</Accordion.Control>
language="json" <Accordion.Panel>
value={draft} <CodeEditor
error={error} id="raw-widget-manifest"
onChange={(next) => { label={t("manifestFilename")}
setDraft(next); language="json"
try { value={draft}
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown); error={error}
if (!parsed.success) { onChange={(next) => {
setError(parsed.error.issues[0]?.message ?? t("invalidWidget")); setDraft(next);
return; try {
} const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
setError(undefined); if (!parsed.success) {
applyDefinition(form, parsed.data); setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
} catch (cause) { return;
setError(cause instanceof Error ? cause.message : t("invalidJson")); }
} setError(undefined);
}} applyDefinition(form, parsed.data);
/> } catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
}
}}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
); );
} }
@@ -3,14 +3,13 @@
import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core"; import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconClipboard, IconSparkles } from "@tabler/icons-react"; import { IconClipboard, IconSparkles } from "@tabler/icons-react";
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button"; import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
interface AiCardProps { interface AiCardProps {
getDraft(): CustomWidgetAiDraft; candidate: HomarrCustomWidgetV2 | null;
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
request: string; request: string;
onRequestChange(value: string): void; onRequestChange(value: string): void;
documentationUrl: string; documentationUrl: string;
@@ -19,7 +18,7 @@ interface AiCardProps {
} }
export function CustomWidgetAiCard(props: AiCardProps) { export function CustomWidgetAiCard(props: AiCardProps) {
const t = useI18n("customWidget.workbench.ai"); const t = useScopedI18n("customWidget.workbench.ai");
return ( return (
<Accordion variant="contained"> <Accordion variant="contained">
<Accordion.Item value="ai"> <Accordion.Item value="ai">
@@ -28,15 +27,14 @@ export function CustomWidgetAiCard(props: AiCardProps) {
{t("title")} {t("title")}
</Accordion.Control> </Accordion.Control>
<CopyAiPromptButton <CopyAiPromptButton
getDraft={props.getDraft} currentConfig={props.candidate}
getDiagnostics={props.getDiagnostics}
request={props.request} request={props.request}
documentationUrl={props.documentationUrl} documentationUrl={props.documentationUrl}
/> />
</Group> </Group>
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))"> <Text size="sm" c="dimmed">
{t("description")} {t("description")}
</Text> </Text>
<Textarea <Textarea
@@ -2,10 +2,7 @@ import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-wi
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils"; import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
export function createCustomWidgetCompletions( export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) {
values: Pick<CustomWidgetFormValues, "options" | "sources">,
requestIds: string[],
) {
const options = parseJson(values.options); const options = parseJson(values.options);
const optionKeys = Object.keys(isRecord(options) ? options : {}); const optionKeys = Object.keys(isRecord(options) ? options : {});
return [ return [
@@ -1,40 +1,14 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import type { ReactNode } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import type { Icon } from "@tabler/icons-react"; import type { Icon } from "@tabler/icons-react";
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
IconAlertCircle,
IconAlertTriangle,
IconApi,
IconBraces,
IconCheck,
IconCode,
IconDatabase,
IconEye,
IconPlayerPlay,
IconSettings,
} from "@tabler/icons-react";
import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core"; import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetFormDocumentDirty } from "./_custom-widget-form-state";
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
import classes from "./_custom-widget-form.module.css"; import classes from "./_custom-widget-form.module.css";
const sectionLinks = [
["general", "section.general", IconSettings],
["sources", "section.sources", IconApi],
["requests", "section.requests", IconDatabase],
["options", "section.options", IconBraces],
["jsx", "section.jsx", IconCode],
["preview", "section.preview", IconEye],
] as const;
export function EditorSection({ export function EditorSection({
id, id,
title, title,
@@ -78,8 +52,7 @@ export function SaveActions({
mode: "create" | "edit"; mode: "create" | "edit";
onPreview(): void; onPreview(): void;
}) { }) {
const t = useI18n("customWidget.workbench"); const t = useScopedI18n("customWidget.workbench");
const tCommon = useI18n("common");
return ( return (
<Group justify="space-between" wrap="wrap"> <Group justify="space-between" wrap="wrap">
<Group gap="xs"> <Group gap="xs">
@@ -104,89 +77,9 @@ export function SaveActions({
{previewPending ? t("action.previewLoading") : t("action.preview")} {previewPending ? t("action.previewLoading") : t("action.preview")}
</Button> </Button>
<Button type="submit" loading={savePending} disabled={invalid || previewPending}> <Button type="submit" loading={savePending} disabled={invalid || previewPending}>
{mode === "create" ? tCommon("action.create") : tCommon("action.save")} {mode === "create" ? t("action.create") : t("action.save")}
</Button> </Button>
</Group> </Group>
</Group> </Group>
); );
} }
export function CustomWidgetSectionNavigation({
onSelect,
}: {
onSelect(section: (typeof sectionLinks)[number][0]): void;
}) {
const t = useI18n("customWidget.workbench");
const invalidSections = useCustomWidgetFormAnalysisField("invalidSections");
return (
<nav className={classes.sectionNav} aria-label={t("sectionNavigation")}>
{sectionLinks.map(([id, key, SectionIcon]) => {
const invalid = invalidSections.has(id);
return (
<Button
key={id}
component="a"
href={`#${id}`}
onClick={() => onSelect(id)}
size="compact-sm"
variant="subtle"
color={invalid ? "red" : undefined}
leftSection={<SectionIcon size={14} />}
rightSection={invalid ? <IconAlertCircle size={13} aria-label={t("status.invalid")} /> : undefined}
>
{t(key)}
</Button>
);
})}
</nav>
);
}
export function CustomWidgetSaveActions({
mode,
savePending,
previewPending,
onPreview,
}: {
mode: "create" | "edit";
savePending: boolean;
previewPending: boolean;
onPreview(): void;
}) {
const dirty = useCustomWidgetFormDocumentDirty();
const candidateValid = useCustomWidgetFormAnalysisField("candidateValid");
const hasDiagnostics = useCustomWidgetFormAnalysisField("hasDiagnostics");
return (
<SaveActions
dirty={dirty}
savePending={savePending}
previewPending={previewPending}
invalid={hasDiagnostics || !candidateValid}
mode={mode}
onPreview={onPreview}
/>
);
}
export function CustomWidgetUnsavedChangesGuard() {
const dirty = useCustomWidgetFormDocumentDirty();
useUnsavedChangesGuard(dirty);
return null;
}
export function CustomWidgetOptionsSnapshotSync({
setOptionsSnapshot,
}: {
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
}) {
const parsedOptions = useCustomWidgetFormAnalysisField("parsedOptions");
const lastDefaults = useRef<Record<string, unknown> | null>(null);
useEffect(() => {
if (!parsedOptions.success) return;
const defaults = getCustomWidgetDefaultOptions(parsedOptions.data);
if (lastDefaults.current && areCustomWidgetValuesEqual(lastDefaults.current, defaults)) return;
lastDefaults.current = defaults;
setOptionsSnapshot(defaults);
}, [parsedOptions, setOptionsSnapshot]);
return null;
}
@@ -1,222 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { TextInput, Textarea } from "@mantine/core";
import { IconApi, IconBraces, IconCode, IconDatabase, IconSettings } from "@tabler/icons-react";
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
import { EditorSection } from "./_custom-widget-form-layout";
import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
import { useCustomWidgetFormDocumentField } from "./_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor";
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
import type { PreviewState } from "./_custom-widget-preview-panel";
import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { LazyOnceAccordion } from "./_lazy-once-accordion";
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
export function CustomWidgetAiSection({
getDraft,
getDiagnostics,
onPaste,
}: {
getDraft(): CustomWidgetAiDraft;
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
onPaste(): void;
}) {
const [request, setRequest] = useState("");
const [documentationUrl, setDocumentationUrl] = useState("");
return (
<CustomWidgetAiCard
getDraft={getDraft}
getDiagnostics={getDiagnostics}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={onPaste}
/>
);
}
export function CustomWidgetGeneralSection({
form,
formRevision,
mode,
}: {
form: CustomWidgetWorkbenchForm;
formRevision: string;
mode: "create" | "edit";
}) {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const w = useI18n("customWidget.workbench");
const [suggestedIconSearch, setSuggestedIconSearch] = useState(() => form.getValues().name);
const iconUrl = useCustomWidgetFormDocumentField("iconUrl");
const nameInputProps = form.getInputProps("name");
useEffect(() => setSuggestedIconSearch(form.getValues().name), [form, formRevision]);
return (
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput
key={form.key("name")}
label={tCommon("field.name")}
required
{...nameInputProps}
onBlur={(event) => {
nameInputProps.onBlur?.(event);
setSuggestedIconSearch(form.getValues().name);
}}
/>
<Textarea
key={form.key("description")}
label={t("field.description")}
autosize
minRows={2}
{...form.getInputProps("description")}
/>
<IconPicker
key={form.key("iconUrl")}
withAsterisk={false}
suggestedSearch={mode === "create" ? suggestedIconSearch : undefined}
{...form.getInputProps("iconUrl")}
value={iconUrl}
/>
</EditorSection>
);
}
export function CustomWidgetSourcesSection({
form,
definitionId,
}: {
form: CustomWidgetWorkbenchForm;
definitionId?: string;
}) {
const w = useI18n("customWidget.workbench");
useCustomWidgetFormDocumentField("sources");
useCustomWidgetFormDocumentField("requests");
useCustomWidgetFormDocumentField("secrets");
return (
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
</EditorSection>
);
}
export function CustomWidgetRequestsSection({
form,
onRename,
}: {
form: CustomWidgetWorkbenchForm;
onRename(currentId: string, nextId: string): void;
}) {
const w = useI18n("customWidget.workbench");
const requests = useCustomWidgetFormDocumentField("requests");
useCustomWidgetFormDocumentField("sources");
useCustomWidgetFormDocumentField("options");
const requestDiagnostics = useCustomWidgetFormAnalysisField("requestDiagnostics");
return (
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
<CustomWidgetRequestsEditor form={form} onRename={onRename} />
<LazyOnceAccordion label={w("builder.advancedJson")}>
<CodeEditor
id="requests-editor"
label={w("requests.label")}
description={w("requests.description")}
language="json"
value={requests}
onChange={(value) => form.setFieldValue("requests", value, { forceUpdate: false })}
diagnostics={requestDiagnostics}
error={form.errors.requests}
reference={customWidgetRequestReference}
required
/>
</LazyOnceAccordion>
</EditorSection>
);
}
export function CustomWidgetOptionsSection({
form,
onRename,
}: {
form: CustomWidgetWorkbenchForm;
onRename(currentName: string, nextName: string): void;
}) {
const w = useI18n("customWidget.workbench");
const options = useCustomWidgetFormDocumentField("options");
useCustomWidgetFormDocumentField("requests");
return (
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
<CustomWidgetOptionsEditor form={form} onRename={onRename} />
<LazyOnceAccordion label={w("builder.advancedJson")}>
<CodeEditor
id="options-editor"
label={w("options.title")}
language="json"
value={options}
onChange={(value) => form.setFieldValue("options", value, { forceUpdate: false })}
error={form.errors.options}
reference={customWidgetOptionsSchemaReference}
/>
</LazyOnceAccordion>
</EditorSection>
);
}
export function CustomWidgetTemplateSection({ form }: { form: CustomWidgetWorkbenchForm }) {
const w = useI18n("customWidget.workbench");
const template = useCustomWidgetFormDocumentField("template");
const jsxCompletions = useCustomWidgetFormAnalysisField("jsxCompletions");
const templateDiagnostics = useCustomWidgetFormAnalysisField("templateDiagnostics");
return (
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
<CodeEditor
id="jsx-editor"
label={w("jsx.label")}
language="jsx"
value={template}
onChange={(value) => form.setFieldValue("template", value, { forceUpdate: false })}
diagnostics={templateDiagnostics}
completions={jsxCompletions}
error={form.errors.template}
required
maxLength={50_000}
/>
</EditorSection>
);
}
interface CustomWidgetPreviewSectionProps {
preview: PreviewState;
size: string;
onSizeChange(size: string): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange: Dispatch<SetStateAction<Record<string, unknown>>>;
onLiveActionsChange(enabled: boolean): void;
}
export function CustomWidgetPreviewSection(props: CustomWidgetPreviewSectionProps) {
const previewCandidate = useCustomWidgetFormAnalysisField("previewCandidate");
const previewValidationIssues = useCustomWidgetFormAnalysisField("previewValidationIssues");
return (
<CustomWidgetPreviewPanel
candidate={previewCandidate}
validationIssues={previewValidationIssues}
preview={props.preview}
size={props.size}
onSizeChange={props.onSizeChange}
optionsSnapshot={props.optionsSnapshot}
onOptionsChange={props.onOptionsChange}
onLiveActionsChange={props.onLiveActionsChange}
/>
);
}
@@ -1,142 +0,0 @@
"use client";
import {
createContext,
startTransition,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { ReactNode } from "react";
import type { UseFormReturnType } from "@mantine/form";
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
export interface CustomWidgetFormDocumentStore {
getValues(): CustomWidgetFormValues;
setValues(values: CustomWidgetFormValues): void;
getDirty(): boolean;
subscribe(listener: () => void): () => void;
markSaved(values: CustomWidgetFormValues): void;
}
const documentValueKeys = customWidgetFormSchema.keyof().options;
function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) {
if (Object.keys(left).length !== documentValueKeys.length) return false;
if (Object.keys(right).length !== documentValueKeys.length) return false;
return documentValueKeys.every(
(key) => Object.hasOwn(left, key) && Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]),
);
}
export function createCustomWidgetFormDocumentStore(
initialValues: CustomWidgetFormValues,
): CustomWidgetFormDocumentStore {
let values = initialValues;
let persistedValues = initialValues;
let dirty = false;
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
return {
getValues: () => values,
setValues: (nextValues) => {
if (Object.is(values, nextValues)) return;
values = nextValues;
dirty = !areDocumentValuesEqual(nextValues, persistedValues);
notify();
},
getDirty: () => dirty,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
markSaved: (savedValues) => {
const wasDirty = dirty;
persistedValues = savedValues;
dirty = !areDocumentValuesEqual(values, persistedValues);
if (wasDirty !== dirty) notify();
},
};
}
const CustomWidgetFormDocumentContext = createContext<CustomWidgetFormDocumentStore | null>(null);
export function CustomWidgetFormDocumentProvider({
store,
children,
}: {
store: CustomWidgetFormDocumentStore;
children: ReactNode;
}) {
return <CustomWidgetFormDocumentContext.Provider value={store}>{children}</CustomWidgetFormDocumentContext.Provider>;
}
export function useCustomWidgetFormDocumentStore() {
const store = useContext(CustomWidgetFormDocumentContext);
if (!store) throw new Error("Custom widget form document provider is missing");
return store;
}
export function useDeferredCustomWidgetFormDocumentValues() {
const store = useCustomWidgetFormDocumentStore();
const [values, setValues] = useState(() => store.getValues());
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const scheduleUpdate = () => {
const nextValues = store.getValues();
clearTimeout(timeout);
timeout = setTimeout(() => {
startTransition(() => {
setValues((currentValues) => {
if (!Object.is(store.getValues(), nextValues)) return currentValues;
return nextValues;
});
});
}, 150);
};
const unsubscribe = store.subscribe(scheduleUpdate);
return () => {
clearTimeout(timeout);
unsubscribe();
};
}, [store]);
return values;
}
export function useCustomWidgetFormDocumentField<Key extends keyof CustomWidgetFormValues>(field: Key) {
const store = useCustomWidgetFormDocumentStore();
const getSnapshot = () => store.getValues()[field];
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
export function useCustomWidgetFormDocumentDirty() {
const store = useCustomWidgetFormDocumentStore();
return useSyncExternalStore(store.subscribe, store.getDirty, store.getDirty);
}
export function useCustomWidgetFormDocumentBridge(
form: UseFormReturnType<CustomWidgetFormValues>,
store: CustomWidgetFormDocumentStore,
) {
const formRef = useRef(form);
formRef.current = form;
return useMemo(
() =>
new Proxy({} as UseFormReturnType<CustomWidgetFormValues>, {
get: (_target, property) => {
if (property === "values") return store.getValues();
return Reflect.get(formRef.current, property);
},
}),
[store],
);
}
@@ -28,7 +28,7 @@ describe("Custom Widget workbench preview options", () => {
const setValues = vi.fn(); const setValues = vi.fn();
const result = applyCustomWidgetAiResponse( const result = applyCustomWidgetAiResponse(
{ {
getValues: () => ({ values: {
name: "", name: "",
description: "", description: "",
iconUrl: "", iconUrl: "",
@@ -37,7 +37,7 @@ describe("Custom Widget workbench preview options", () => {
options: "{}", options: "{}",
template: "", template: "",
secrets: [], secrets: [],
}), },
setValues, setValues,
} as never, } as never,
`\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``, `\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``,
@@ -84,6 +84,13 @@ describe("Custom Widget workbench preview options", () => {
it("normalizes only changed secrets", () => { it("normalizes only changed secrets", () => {
expect( expect(
getChangedSecrets({ getChangedSecrets({
name: "Widget",
description: "",
iconUrl: "",
sources: "{}",
requests: "{}",
options: "{}",
template: "<Text />",
secrets: [ secrets: [
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true }, { sourceId: "default", kind: "apiKey", value: "", hasValue: true },
{ sourceId: "other", kind: "password", value: "replacement" }, { sourceId: "other", kind: "password", value: "replacement" },
@@ -12,8 +12,6 @@ import type { UseFormReturnType } from "@mantine/form";
export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>; export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>;
const PREVIEW_QUERY_CONCURRENCY = 4;
export function buildDefinition(values: CustomWidgetFormValues) { export function buildDefinition(values: CustomWidgetFormValues) {
return customWidgetDefinitionSchema.safeParse({ return customWidgetDefinitionSchema.safeParse({
$schema: "homarr-custom-widget-v2", $schema: "homarr-custom-widget-v2",
@@ -27,7 +25,7 @@ export function buildDefinition(values: CustomWidgetFormValues) {
}); });
} }
export function getChangedSecrets(values: Pick<CustomWidgetFormValues, "secrets">) { export function getChangedSecrets(values: CustomWidgetFormValues) {
return values.secrets return values.secrets
.filter((secret) => secret.value.trim()) .filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({ .map(({ sourceId, kind, value }) => ({
@@ -48,7 +46,7 @@ export function filterSecretsForSourceAuthentication(
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) { export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({ form.setValues({
...form.getValues(), ...form.values,
name: widget.name, name: widget.name,
description: widget.description ?? "", description: widget.description ?? "",
iconUrl: widget.iconUrl ?? "", iconUrl: widget.iconUrl ?? "",
@@ -104,43 +102,25 @@ export function isRuntimeParams(
} }
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) { export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const requests = Object.entries(definition.requests).filter( const data: Record<string, unknown> = {};
const status: Record<string, unknown> = {};
for (const [requestId] of Object.entries(definition.requests).filter(
([, entry]) => entry.kind === "query" && entry.trigger === "load", ([, entry]) => entry.kind === "query" && entry.trigger === "load",
); )) {
const results: Array<{ try {
requestId: string; const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
data: unknown; data[requestId] = result.data;
status: Record<string, unknown>; status[requestId] = { loading: false, ...result };
}> = []; } catch (error) {
let nextIndex = 0; data[requestId] = null;
const worker = async () => { status[requestId] = {
while (true) { loading: false,
const index = nextIndex; ok: false,
nextIndex += 1; error: error instanceof Error ? error.message : "Request failed",
const request = requests[index]; };
if (!request) return;
const [requestId] = request;
try {
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
results[index] = { requestId, data: result.data, status: { loading: false, ...result } };
} catch (error) {
results[index] = {
requestId,
data: null,
status: {
loading: false,
ok: false,
error: error instanceof Error ? error.message : "Request failed",
},
};
}
} }
}; }
await Promise.all(Array.from({ length: Math.min(PREVIEW_QUERY_CONCURRENCY, requests.length) }, () => worker())); return { data, status };
return {
data: Object.fromEntries(results.map((result) => [result.requestId, result.data])),
status: Object.fromEntries(results.map((result) => [result.requestId, result.status])),
};
} }
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) { export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
@@ -1,45 +1,45 @@
"use client"; "use client";
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { Dispatch, FormEventHandler, SetStateAction } from "react"; import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import { Box, Paper, SegmentedControl, Stack } from "@mantine/core"; import {
IconAlertCircle,
IconApi,
IconBraces,
IconCode,
IconDatabase,
IconEye,
IconSettings,
} from "@tabler/icons-react";
import type { CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt"; import {
import { customWidgetFormSchema, DEFAULT_CUSTOM_WIDGET_FORM_VALUES } from "@homarr/custom-widgets/workbench"; analyzeJsxTemplate,
analyzeRequestManifest,
customWidgetFormSchema,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
} from "@homarr/custom-widgets/workbench";
import { customWidgetOptionsSchema, getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench"; import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest"; import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest";
import { import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
CustomWidgetOptionsSnapshotSync, import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
CustomWidgetSaveActions, import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
CustomWidgetSectionNavigation, import { buildDefinition, getDefinitionDefaults, isRecord, parseJson } from "./_custom-widget-form-utils";
CustomWidgetUnsavedChangesGuard, import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor";
} from "./_custom-widget-form-layout"; import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
import {
CustomWidgetAiSection,
CustomWidgetGeneralSection,
CustomWidgetOptionsSection,
CustomWidgetPreviewSection,
CustomWidgetRequestsSection,
CustomWidgetSourcesSection,
CustomWidgetTemplateSection,
} from "./_custom-widget-form-sections";
import {
createCustomWidgetFormDocumentStore,
CustomWidgetFormDocumentProvider,
useCustomWidgetFormDocumentBridge,
} from "./_custom-widget-form-state";
import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import { buildDefinition, getDefinitionDefaults } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import type { PreviewState } from "./_custom-widget-preview-panel"; import type { PreviewState } from "./_custom-widget-preview-panel";
import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor";
import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handlers"; import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handlers";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert"; import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions"; import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
import { analyzeCustomWidgetAiDiagnostics, CustomWidgetFormAnalysisProvider } from "./_use-custom-widget-form-analysis"; import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
import classes from "./_custom-widget-form.module.css"; import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps { interface CustomWidgetFormProps {
@@ -47,204 +47,240 @@ interface CustomWidgetFormProps {
initialValues?: Partial<CustomWidgetFormValues>; initialValues?: Partial<CustomWidgetFormValues>;
definitionId?: string; definitionId?: string;
} }
const sectionLinks = [
type MobilePane = "configure" | "preview"; ["general", "section.general", IconSettings],
["sources", "section.sources", IconApi],
interface CustomWidgetFormViewProps { ["requests", "section.requests", IconDatabase],
mode: "create" | "edit"; ["options", "section.options", IconBraces],
definitionId?: string; ["jsx", "section.jsx", IconCode],
form: CustomWidgetWorkbenchForm; ["preview", "section.preview", IconEye],
formErrors: CustomWidgetWorkbenchForm["errors"]; ] as const;
formRevision: string;
documentStore: CustomWidgetFormDocumentStore;
mobilePane: MobilePane;
setMobilePane: Dispatch<SetStateAction<MobilePane>>;
preview: PreviewState;
setPreview: Dispatch<SetStateAction<PreviewState>>;
previewSize: string;
setPreviewSize: Dispatch<SetStateAction<string>>;
optionsSnapshot: Record<string, unknown>;
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
saveIssues: CustomWidgetSaveIssue[];
savePending: boolean;
previewPending: boolean;
onSubmit: FormEventHandler<HTMLFormElement>;
onPreview(): void;
onPasteAiResponse(): void;
}
function getAiDraft(values: CustomWidgetFormValues): CustomWidgetAiDraft {
return {
name: values.name,
description: values.description,
iconUrl: values.iconUrl,
sources: values.sources,
requests: values.requests,
options: values.options,
template: values.template,
};
}
const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWidgetFormViewProps) {
const w = useI18n("customWidget.workbench");
const documentStore = props.documentStore;
const form = props.form;
const setMobilePane = props.setMobilePane;
const setPreview = props.setPreview;
const { renameRequest, renameOption } = useMemo(
() =>
createCustomWidgetRenameHandlers({
form,
invalidWidgetMessage: w("invalidWidget"),
}),
[form, w],
);
const readAiDraft = useCallback(() => getAiDraft(documentStore.getValues()), [documentStore]);
const readAiDiagnostics = useCallback(
() => analyzeCustomWidgetAiDiagnostics(documentStore.getValues()),
[documentStore],
);
const handleSectionSelect = useCallback(
(section: string) => {
setMobilePane(section === "preview" ? "preview" : "configure");
},
[setMobilePane],
);
const handleLiveActionsChange = useCallback(
(enabled: boolean) =>
setPreview((current) => ({
...current,
session: current.session ? { ...current.session, liveActions: enabled } : null,
})),
[setPreview],
);
return (
<CustomWidgetFormDocumentProvider store={documentStore}>
<CustomWidgetFormAnalysisProvider>
<form onSubmit={props.onSubmit} className={classes.form}>
<CustomWidgetUnsavedChangesGuard />
<CustomWidgetOptionsSnapshotSync setOptionsSnapshot={props.setOptionsSnapshot} />
<SegmentedControl
className={classes.paneSwitcher}
fullWidth
value={props.mobilePane}
onChange={(value) => props.setMobilePane(value as MobilePane)}
data={[
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
]}
/>
<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />
<CustomWidgetSectionNavigation onSelect={handleSectionSelect} />
<div className={classes.workbench} data-mobile-pane={props.mobilePane}>
<Stack gap="lg" className={classes.configuration}>
<CustomWidgetAiSection
getDraft={readAiDraft}
getDiagnostics={readAiDiagnostics}
onPaste={props.onPasteAiResponse}
/>
<CustomWidgetAdvancedManifest form={form} />
<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />
<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />
<CustomWidgetRequestsSection form={form} onRename={renameRequest} />
<CustomWidgetOptionsSection form={form} onRename={renameOption} />
<CustomWidgetTemplateSection form={form} />
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<CustomWidgetSaveActions
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
</Paper>
</Stack>
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
<Paper p="md" className={classes.actionBar} shadow="sm">
<CustomWidgetSaveActions
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
</Paper>
<CustomWidgetPreviewSection
preview={props.preview}
size={props.previewSize}
onSizeChange={props.setPreviewSize}
optionsSnapshot={props.optionsSnapshot}
onOptionsChange={props.setOptionsSnapshot}
onLiveActionsChange={handleLiveActionsChange}
/>
</Box>
</div>
</form>
</CustomWidgetFormAnalysisProvider>
</CustomWidgetFormDocumentProvider>
);
});
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) { export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const [formInitialValues] = useState<CustomWidgetFormValues>(() => ({ const t = useScopedI18n("customWidget");
...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, const w = useScopedI18n("customWidget.workbench");
...initialValues, const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
})); const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
const [documentStore] = useState(() => createCustomWidgetFormDocumentStore(formInitialValues)); const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
const mantineForm = useZodForm(customWidgetFormSchema, { const [request, setRequest] = useState("");
initialValues: formInitialValues, const [documentationUrl, setDocumentationUrl] = useState("");
mode: "uncontrolled",
validateDebounce: 300,
validateInputOnChange: false,
onValuesChange: documentStore.setValues,
});
const form = useCustomWidgetFormDocumentBridge(mantineForm, documentStore);
const [mobilePane, setMobilePane] = useState<MobilePane>("configure");
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" }); const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
const [previewSize, setPreviewSize] = useState("standard"); const [previewSize, setPreviewSize] = useState("standard");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => { const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
const initialDefinition = buildDefinition(formInitialValues); const initialDefinition = buildDefinition(formInitialValues);
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {}; return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
}); });
const actions = useCustomWidgetFormActions({ useUnsavedChangesGuard(form.isDirty());
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
const parsedOptions = useMemo(
() => customWidgetOptionsSchema.safeParse(parseJson(form.values.options)),
[form.values.options],
);
const requestIds = useMemo(
() =>
Object.keys(
isRecord(parseJson(form.values.requests)) ? (parseJson(form.values.requests) as Record<string, unknown>) : {},
),
[form.values.requests],
);
const jsxCompletions = useMemo(
() => createCustomWidgetCompletions(form.values, requestIds),
[form.values, requestIds],
);
const templateDiagnostics = useMemo(
() => analyzeJsxTemplate(form.values.template, { requestIds }),
[form.values.template, requestIds],
);
const requestDiagnostics = useMemo(() => analyzeRequestManifest(form.values.requests), [form.values.requests]);
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
const invalidSections = useMemo(
() =>
getInvalidCustomWidgetSections(
candidate.success ? [] : candidate.error.issues,
requestDiagnostics,
templateDiagnostics,
),
[candidate, requestDiagnostics, templateDiagnostics],
);
const { save, runPreview, pasteAiResponse, saveIssues, savePending, previewPending } = useCustomWidgetFormActions({
mode, mode,
definitionId, definitionId,
form, form,
documentStore, candidate,
preview,
setPreview, setPreview,
setMobilePane, setMobilePane,
optionsSnapshot, optionsSnapshot,
setOptionsSnapshot, setOptionsSnapshot,
}); });
const latestActions = useRef(actions); useEffect(() => {
useLayoutEffect(() => { if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
latestActions.current = actions; }, [parsedOptions]);
}, [actions]);
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>((event) => latestActions.current.save(event), []);
const handlePreview = useCallback(() => void latestActions.current.runPreview(), []);
const handlePasteAiResponse = useCallback(() => void latestActions.current.pasteAiResponse(), []);
const invalid = hasDiagnostics || !candidate.success;
const { renameRequest, renameOption } = createCustomWidgetRenameHandlers({
form,
candidate,
invalidWidgetMessage: w("invalidWidget"),
});
return ( return (
<CustomWidgetFormView <form onSubmit={save} className={classes.form}>
mode={mode} <SegmentedControl
definitionId={definitionId} className={classes.paneSwitcher}
form={form} fullWidth
formErrors={mantineForm.errors} value={mobilePane}
formRevision={mantineForm.key("name")} onChange={(value) => setMobilePane(value as typeof mobilePane)}
documentStore={documentStore} data={[
mobilePane={mobilePane} { value: "configure", label: w("configure") },
setMobilePane={setMobilePane} { value: "preview", label: w("section.preview") },
preview={preview} ]}
setPreview={setPreview} />
previewSize={previewSize} <CustomWidgetSaveIssuesAlert issues={saveIssues} />
setPreviewSize={setPreviewSize} <nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
optionsSnapshot={optionsSnapshot} {sectionLinks.map(([id, key, SectionIcon]) => (
setOptionsSnapshot={setOptionsSnapshot} <Button
saveIssues={actions.saveIssues} key={id}
savePending={actions.savePending} component="a"
previewPending={actions.previewPending} href={`#${id}`}
onSubmit={handleSubmit} onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
onPreview={handlePreview} size="compact-sm"
onPasteAiResponse={handlePasteAiResponse} variant="subtle"
/> color={invalidSections.has(id) ? "red" : undefined}
leftSection={<SectionIcon size={14} />}
rightSection={
invalidSections.has(id) ? <IconAlertCircle size={13} aria-label={w("status.invalid")} /> : undefined
}
>
{w(key)}
</Button>
))}
</nav>
<div className={classes.workbench} data-mobile-pane={mobilePane}>
<Stack gap="lg" className={classes.configuration}>
<CustomWidgetAiCard
candidate={candidate.success ? candidate.data : null}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
/>
<CustomWidgetAdvancedManifest form={form} />
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
<IconPicker
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</EditorSection>
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
</EditorSection>
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
<CustomWidgetRequestsEditor form={form} onRename={renameRequest} />
<Accordion variant="contained">
<Accordion.Item value="raw">
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="requests-editor"
label={w("requests.label")}
description={w("requests.description")}
language="json"
value={form.values.requests}
onChange={(value) => form.setFieldValue("requests", value)}
diagnostics={requestDiagnostics}
error={form.errors.requests}
reference={customWidgetRequestReference}
required
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</EditorSection>
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
<CustomWidgetOptionsEditor form={form} onRename={renameOption} />
<Accordion variant="contained">
<Accordion.Item value="raw">
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="options"
label={w("options.title")}
language="json"
value={form.values.options}
onChange={(value) => form.setFieldValue("options", value)}
error={form.errors.options}
reference={customWidgetOptionsSchemaReference}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</EditorSection>
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
<CodeEditor
id="jsx-editor"
label={w("jsx.label")}
language="jsx"
value={form.values.template}
onChange={(value) => form.setFieldValue("template", value)}
diagnostics={templateDiagnostics}
completions={jsxCompletions}
error={form.errors.template}
required
maxLength={50_000}
/>
</EditorSection>
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<SaveActions
dirty={form.isDirty()}
savePending={savePending}
previewPending={previewPending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
/>
</Paper>
</Stack>
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
<Paper p="md" className={classes.actionBar} shadow="sm">
<SaveActions
dirty={form.isDirty()}
savePending={savePending}
previewPending={previewPending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
/>
</Paper>
<CustomWidgetPreviewPanel
candidate={candidate.success ? candidate.data : null}
validationIssues={
candidate.success
? []
: candidate.error.issues.map((issue) => ({
path: issue.path.map(String).join(".") || undefined,
message: issue.message,
}))
}
preview={preview}
size={previewSize}
onSizeChange={setPreviewSize}
optionsSnapshot={optionsSnapshot}
onOptionsChange={setOptionsSnapshot}
onLiveActionsChange={(enabled) =>
setPreview((current) => ({
...current,
session: current.session ? { ...current.session, liveActions: enabled } : null,
}))
}
/>
</Box>
</div>
</form>
); );
} }
@@ -5,7 +5,7 @@ import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react"; import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog"; import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
@@ -18,22 +18,20 @@ interface CustomWidgetListProps {
} }
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => { export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
const t = useI18n("customWidget"); const t = useScopedI18n("customWidget");
const tCommon = useI18n("common");
const tEntities = useI18n("common.entity");
if (definitions.length === 0) { if (definitions.length === 0) {
return ( return (
<NoResults <NoResults
icon={IconApi} icon={IconApi}
title={t("page.list.noResults")} title={t("page.list.noResults")}
action={{ href: "/manage/custom-widgets/new", label: tCommon("action.create") }} action={{ href: "/manage/custom-widgets/new", label: t("action.create") }}
/> />
); );
} }
return ( return (
<ManageCollectionList ariaLabel={tEntities("customWidgets")}> <ManageCollectionList ariaLabel={t("page.list.title")}>
{definitions.map((definition) => ( {definitions.map((definition) => (
<CustomWidgetRow key={definition.id} widget={definition} /> <CustomWidgetRow key={definition.id} widget={definition} />
))} ))}
@@ -44,8 +42,7 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
type WidgetDef = RouterOutputs["customWidget"]["list"][number]; type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetRow({ widget }: { widget: WidgetDef }) { function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
const t = useI18n("customWidget"); const t = useScopedI18n("customWidget");
const tCommon = useI18n("common");
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false); const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
const origins = [...new Set(widget.sources.map((source) => source.origin))]; const origins = [...new Set(widget.sources.map((source) => source.origin))];
@@ -97,7 +94,7 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
size="sm" size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />} leftSection={<IconPencil size={16} stroke={1.5} />}
> >
{tCommon("action.edit")} {t("action.edit")}
</Button> </Button>
)} )}
<CustomWidgetRowActions <CustomWidgetRowActions
@@ -125,8 +122,14 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
} }
/** Everything that decides whether this widget can actually run right now. */ /** Everything that decides whether this widget can actually run right now. */
function CustomWidgetStatusBadges({ widget, onConfigureSources }: { widget: WidgetDef; onConfigureSources(): void }) { function CustomWidgetStatusBadges({
const t = useI18n("customWidget"); widget,
onConfigureSources,
}: {
widget: WidgetDef;
onConfigureSources(): void;
}) {
const t = useScopedI18n("customWidget");
if (widget.migrationRequired) { if (widget.migrationRequired) {
return ( return (
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core"; import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react"; import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
@@ -21,7 +21,7 @@ export function DefaultValueEditor({
choices?: Array<{ label: string; value: string | number }>; choices?: Array<{ label: string; value: string | number }>;
onChange: (value: unknown) => void; onChange: (value: unknown) => void;
}) { }) {
const t = useI18n("customWidget.workbench.builder"); const t = useScopedI18n("customWidget.workbench.builder");
if (control === "switch") if (control === "switch")
return ( return (
<Switch <Switch
@@ -84,7 +84,7 @@ export function OptionChoicesEditor({
value: Array<{ label: string; value: string | number }>; value: Array<{ label: string; value: string | number }>;
onChange(value: Array<{ label: string; value: string | number }>): void; onChange(value: Array<{ label: string; value: string | number }>): void;
}) { }) {
const t = useI18n("customWidget.workbench.builder"); const t = useScopedI18n("customWidget.workbench.builder");
return ( return (
<Stack gap="xs"> <Stack gap="xs">
{value.map((choice, index) => ( {value.map((choice, index) => (
@@ -150,7 +150,7 @@ export function JsonValueEditor({
value: unknown; value: unknown;
onChange(value: unknown): void; onChange(value: unknown): void;
}) { }) {
const t = useI18n("customWidget.workbench.builder"); const t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2); const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized); const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -16,7 +16,7 @@ import {
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react"; import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { customWidgetOptionControls } from "@homarr/custom-widgets/core"; import { customWidgetOptionControls } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils"; import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
@@ -30,7 +30,7 @@ export function CustomWidgetOptionsEditor({
form: CustomWidgetWorkbenchForm; form: CustomWidgetWorkbenchForm;
onRename(currentName: string, nextName: string): void; onRename(currentName: string, nextName: string): void;
}) { }) {
const t = useI18n("customWidget.workbench.builder"); const t = useScopedI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.options); const parsed = parseJson(form.values.options);
const entries = isRecord(parsed) ? Object.entries(parsed) : []; const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const requests = parseJson(form.values.requests); const requests = parseJson(form.values.requests);
@@ -69,7 +69,7 @@ export function CustomWidgetOptionsEditor({
) )
: []; : [];
return ( return (
<Fieldset key={id} legend={typeof option.label === "string" ? option.label : id}> <Fieldset key={index} legend={typeof option.label === "string" ? option.label : id}>
<Stack gap="sm"> <Stack gap="sm">
<Group grow align="start"> <Group grow align="start">
<CustomWidgetIdentifierInput <CustomWidgetIdentifierInput
@@ -99,7 +99,7 @@ export function CustomWidgetOptionsEditor({
} }
/> />
<DefaultValueEditor <DefaultValueEditor
id={`${id}-default`} id={`${index}-default`}
control={control} control={control}
value={option.default} value={option.default}
choices={choices} choices={choices}
@@ -8,7 +8,7 @@ import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } fro
import type { CustomJsxRequest } from "@homarr/custom-widgets/core"; import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils"; import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
@@ -19,7 +19,7 @@ export function PreviewActionControl({
request: CustomJsxRequest & { id: string }; request: CustomJsxRequest & { id: string };
sessionId?: string; sessionId?: string;
}) { }) {
const t = useI18n("customWidget.workbench.preview"); const t = useScopedI18n("customWidget.workbench.preview");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const actionMutation = clientApi.customWidget.previewAction.useMutation(); const actionMutation = clientApi.customWidget.previewAction.useMutation();
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted(); const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
@@ -6,8 +6,6 @@ interface PreviewSnapshot {
session: { id: string; liveActions: boolean } | null; session: { id: string; liveActions: boolean } | null;
} }
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template">;
export function createPreviewDisplayData({ export function createPreviewDisplayData({
candidate, candidate,
fixture, fixture,
@@ -15,7 +13,7 @@ export function createPreviewDisplayData({
options, options,
fixtureError, fixtureError,
}: { }: {
candidate: PreviewCandidate | null; candidate: HomarrCustomWidgetV2 | null;
fixture: "live" | "loading" | "empty" | "error"; fixture: "live" | "loading" | "empty" | "error";
preview: PreviewSnapshot; preview: PreviewSnapshot;
options: Record<string, unknown>; options: Record<string, unknown>;
@@ -19,7 +19,7 @@ vi.mock("@homarr/api/client", () => ({
}, },
}, },
})); }));
vi.mock("@homarr/translation/client", () => ({ useI18n: () => (key: string) => key })); vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() })); vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
const rendererAttempt = vi.hoisted(() => vi.fn()); const rendererAttempt = vi.hoisted(() => vi.fn());
const codeEditorProps = vi.hoisted(() => vi.fn()); const codeEditorProps = vi.hoisted(() => vi.fn());
@@ -1,13 +1,13 @@
"use client"; "use client";
import { memo, useMemo, useState } from "react"; import { useState } from "react";
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core"; import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench"; import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display"; import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
@@ -15,12 +15,9 @@ import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-wi
import type { PreviewOutcome } from "./_custom-widget-preview-status"; import type { PreviewOutcome } from "./_custom-widget-preview-status";
import { PreviewActionControl } from "./_custom-widget-preview-action"; import { PreviewActionControl } from "./_custom-widget-preview-action";
import { createPreviewDisplayData } from "./_custom-widget-preview-data"; import { createPreviewDisplayData } from "./_custom-widget-preview-data";
import { CustomWidgetPreviewSizeControl } from "./_custom-widget-preview-size-control";
import { JsonPreviewEditor } from "./_json-preview-editor"; import { JsonPreviewEditor } from "./_json-preview-editor";
import classes from "./_custom-widget-form.module.css"; import classes from "./_custom-widget-form.module.css";
const previewWidths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
export interface PreviewState { export interface PreviewState {
data: Record<string, unknown>; data: Record<string, unknown>;
status: Record<string, unknown>; status: Record<string, unknown>;
@@ -39,65 +36,35 @@ interface PreviewPanelProps {
onLiveActionsChange(enabled: boolean): void; onLiveActionsChange(enabled: boolean): void;
} }
function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) { export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
const t = useI18n("customWidget.workbench.preview"); const t = useScopedI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary"); const errorBoundaryMessages = useScopedI18n("customWidget.editor.errorBoundary");
const actionT = useI18n("common.action");
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live"); const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation(); const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const journalQuery = clientApi.customWidget.previewJournal.useQuery( const journalQuery = clientApi.customWidget.previewJournal.useQuery(
{ sessionId: props.preview.session?.id ?? "" }, { sessionId: props.preview.session?.id ?? "" },
{ enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false }, { enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false },
); );
const widths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
const candidate = props.candidate; const candidate = props.candidate;
const candidateTemplate = candidate?.template; const accessibilityIssues = candidate ? analyzeCustomWidgetAccessibility(candidate.template) : [];
const candidateRequests = candidate?.requests; const displayData = createPreviewDisplayData({
const previewCandidate = useMemo(() => { candidate,
if (candidateTemplate === undefined || candidateRequests === undefined) return null; fixture,
return { template: candidateTemplate, requests: candidateRequests }; preview: props.preview,
}, [candidateRequests, candidateTemplate]); options: props.optionsSnapshot,
const previewSnapshot = useMemo( fixtureError: t("fixtureError"),
() => ({ data: props.preview.data, status: props.preview.status, session: props.preview.session }), });
[props.preview.data, props.preview.session, props.preview.status], const previewSummary = getPreviewSummary(props.preview.status);
); const rendererResetKey = JSON.stringify({
const fixtureError = t("fixtureError"); template: candidate?.template,
const accessibilityIssues = useMemo(() => { requests: candidate?.requests,
if (candidateTemplate === undefined) return []; fixture,
return analyzeCustomWidgetAccessibility(candidateTemplate); data: props.preview.data,
}, [candidateTemplate]); status: props.preview.status,
const displayData = useMemo( sessionId: props.preview.session?.id,
() => options: props.optionsSnapshot,
createPreviewDisplayData({ });
candidate: previewCandidate,
fixture,
preview: previewSnapshot,
options: props.optionsSnapshot,
fixtureError,
}),
[fixture, fixtureError, previewCandidate, previewSnapshot, props.optionsSnapshot],
);
const previewSummary = useMemo(() => getPreviewSummary(props.preview.status), [props.preview.status]);
const rendererResetKey = useMemo(
() =>
JSON.stringify({
template: candidateTemplate,
requests: candidateRequests,
fixture,
data: props.preview.data,
status: props.preview.status,
sessionId: props.preview.session?.id,
options: props.optionsSnapshot,
}),
[
candidateRequests,
candidateTemplate,
fixture,
props.optionsSnapshot,
props.preview.data,
props.preview.session?.id,
props.preview.status,
],
);
const previewResult = const previewResult =
props.preview.outcome === "success" props.preview.outcome === "success"
? { ? {
@@ -117,7 +84,37 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
<Card withBorder p="md"> <Card withBorder p="md">
<Stack gap="md"> <Stack gap="md">
<Group gap="xs" justify="space-between"> <Group gap="xs" justify="space-between">
<CustomWidgetPreviewSizeControl value={props.size} onChange={props.onSizeChange} /> <SegmentedControl
size="xs"
value={props.size}
onChange={props.onSizeChange}
data={[
{
value: "compact",
label: (
<span aria-label={t("size.compact")} title={t("size.compact")}>
S
</span>
),
},
{
value: "standard",
label: (
<span aria-label={t("size.standard")} title={t("size.standard")}>
M
</span>
),
},
{
value: "wide",
label: (
<span aria-label={t("size.wide")} title={t("size.wide")}>
L
</span>
),
},
]}
/>
<SegmentedControl <SegmentedControl
size="xs" size="xs"
value={fixture} value={fixture}
@@ -153,19 +150,12 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="widget" pt="sm"> <Tabs.Panel value="widget" pt="sm">
<Box className={classes.previewCanvas}> <Box className={classes.previewCanvas}>
<Paper <Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
withBorder
p="sm"
h={360}
w={previewWidths[props.size] ?? 480}
maw="100%"
style={{ overflow: "auto" }}
>
{displayData ? ( {displayData ? (
<PreviewErrorBoundary <PreviewErrorBoundary
title={errorBoundaryMessages("title")} title={errorBoundaryMessages("title")}
description={t("containedFailures")} description={t("containedFailures")}
retryLabel={actionT("tryAgain")} retryLabel={errorBoundaryMessages("retry")}
resetKeys={[rendererResetKey]} resetKeys={[rendererResetKey]}
> >
<CustomJsxDisplay data={displayData} /> <CustomJsxDisplay data={displayData} />
@@ -273,5 +263,3 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
</Card> </Card>
); );
} }
export const CustomWidgetPreviewPanel = memo(CustomWidgetPreviewPanelContent);
@@ -1,56 +0,0 @@
"use client";
import { memo } from "react";
import { SegmentedControl, VisuallyHidden } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
interface CustomWidgetPreviewSizeControlProps {
value: string;
onChange(value: string): void;
}
const previewSizeShortLabels = { compact: "S", standard: "M", wide: "L" } as const;
function CustomWidgetPreviewSizeControlContent({ value, onChange }: CustomWidgetPreviewSizeControlProps) {
const t = useI18n("customWidget.workbench.preview");
return (
<SegmentedControl
size="xs"
value={value}
onChange={onChange}
data={[
{
value: "compact",
label: (
<span title={t("size.compact")}>
<span aria-hidden>{previewSizeShortLabels.compact}</span>
<VisuallyHidden>{t("size.compact")}</VisuallyHidden>
</span>
),
},
{
value: "standard",
label: (
<span title={t("size.standard")}>
<span aria-hidden>{previewSizeShortLabels.standard}</span>
<VisuallyHidden>{t("size.standard")}</VisuallyHidden>
</span>
),
},
{
value: "wide",
label: (
<span title={t("size.wide")}>
<span aria-hidden>{previewSizeShortLabels.wide}</span>
<VisuallyHidden>{t("size.wide")}</VisuallyHidden>
</span>
),
},
]}
/>
);
}
export const CustomWidgetPreviewSizeControl = memo(CustomWidgetPreviewSizeControlContent);
@@ -1,35 +1,34 @@
import { renameCustomWidgetOption, renameCustomWidgetRequest } from "@homarr/custom-widgets/workbench"; import { renameCustomWidgetOption, renameCustomWidgetRequest } from "@homarr/custom-widgets/workbench";
import { applyDefinition, buildDefinition } from "./_custom-widget-form-utils"; import { applyDefinition } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { buildDefinition, CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
interface RenameHandlersInput { interface RenameHandlersInput {
form: CustomWidgetWorkbenchForm; form: CustomWidgetWorkbenchForm;
candidate: ReturnType<typeof buildDefinition>;
invalidWidgetMessage: string; invalidWidgetMessage: string;
} }
export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) { export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
const renameRequest = (currentId: string, nextId: string) => { const renameRequest = (currentId: string, nextId: string) => {
const candidate = buildDefinition(input.form.getValues()); if (!input.candidate.success) {
if (!candidate.success) {
input.form.setFieldError("requests", input.invalidWidgetMessage); input.form.setFieldError("requests", input.invalidWidgetMessage);
return; return;
} }
try { try {
applyDefinition(input.form, renameCustomWidgetRequest(candidate.data, currentId, nextId)); applyDefinition(input.form, renameCustomWidgetRequest(input.candidate.data, currentId, nextId));
input.form.clearFieldError("requests"); input.form.clearFieldError("requests");
} catch (error) { } catch (error) {
input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage); input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage);
} }
}; };
const renameOption = (currentName: string, nextName: string) => { const renameOption = (currentName: string, nextName: string) => {
const candidate = buildDefinition(input.form.getValues()); if (!input.candidate.success) {
if (!candidate.success) {
input.form.setFieldError("options", input.invalidWidgetMessage); input.form.setFieldError("options", input.invalidWidgetMessage);
return; return;
} }
try { try {
applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName)); applyDefinition(input.form, renameCustomWidgetOption(input.candidate.data, currentName, nextName));
input.form.clearFieldError("options"); input.form.clearFieldError("options");
} catch (error) { } catch (error) {
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage); input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage);
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, Select, Stack, Text, TextInput } from "@mantine/core"; import { ActionIcon, Button, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react"; import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
import { isRecord } from "./_custom-widget-form-utils"; import { isRecord } from "./_custom-widget-form-utils";
@@ -21,9 +21,9 @@ export function QueryValuesEditor({
allowParams: boolean; allowParams: boolean;
onChange(value: Record<string, unknown>): void; onChange(value: Record<string, unknown>): void;
}) { }) {
const t = useI18n("customWidget.workbench.builder"); const t = useScopedI18n("customWidget.workbench.builder");
const entries = Object.entries(value); const entries = Object.entries(value);
const [nameErrors, setNameErrors] = useState<Record<string, string>>({}); const [nameErrors, setNameErrors] = useState<Record<number, string>>({});
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
@@ -65,23 +65,23 @@ export function QueryValuesEditor({
...(allowParams ? [{ value: "param", label: t("invocationParameter") }] : []), ...(allowParams ? [{ value: "param", label: t("invocationParameter") }] : []),
]; ];
return ( return (
<Group key={name} align="end" wrap="wrap"> <Group key={index} align="end" wrap="wrap">
<CustomWidgetIdentifierInput <CustomWidgetIdentifierInput
style={{ flex: "1 1 10rem" }} style={{ flex: "1 1 10rem" }}
label={t("queryKey")} label={t("queryKey")}
value={name} value={name}
error={nameErrors[name]} error={nameErrors[index]}
onCommit={(nextName) => { onCommit={(nextName) => {
if ( if (
!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(nextName) || !/^[A-Za-z][A-Za-z0-9_-]*$/u.test(nextName) ||
entries.some(([entryName], entryIndex) => entryIndex !== index && entryName === nextName) entries.some(([entryName], entryIndex) => entryIndex !== index && entryName === nextName)
) { ) {
setNameErrors((currentErrors) => ({ ...currentErrors, [name]: t("queryKeyInvalid") })); setNameErrors((currentErrors) => ({ ...currentErrors, [index]: t("queryKeyInvalid") }));
return; return;
} }
setNameErrors((currentErrors) => { setNameErrors((currentErrors) => {
const nextErrors = { ...currentErrors }; const nextErrors = { ...currentErrors };
delete nextErrors[name]; delete nextErrors[index];
return nextErrors; return nextErrors;
}); });
commit(nextName, kind, current); commit(nextName, kind, current);
@@ -150,7 +150,7 @@ export function RequestBodyEditor({
allowParams: boolean; allowParams: boolean;
onChange(value: unknown): void; onChange(value: unknown): void;
}) { }) {
const t = useI18n("customWidget.workbench.builder"); const t = useScopedI18n("customWidget.workbench.builder");
const serialized = value === undefined ? "" : JSON.stringify(value, null, 2); const serialized = value === undefined ? "" : JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized); const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -1,15 +1,14 @@
"use client"; "use client";
import { ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core"; import { Accordion, ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react"; import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils"; import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { QueryValuesEditor, RequestBodyEditor } from "./_custom-widget-request-value-editors"; import { QueryValuesEditor, RequestBodyEditor } from "./_custom-widget-request-value-editors";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input"; import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
import { LazyOnceAccordion } from "./_lazy-once-accordion";
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"]; const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
@@ -20,13 +19,11 @@ export function CustomWidgetRequestsEditor({
form: CustomWidgetWorkbenchForm; form: CustomWidgetWorkbenchForm;
onRename(currentId: string, nextId: string): void; onRename(currentId: string, nextId: string): void;
}) { }) {
const t = useI18n("customWidget.workbench.builder"); const t = useScopedI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.requests); const parsed = parseJson(form.values.requests);
const entries = isRecord(parsed) ? Object.entries(parsed) : []; const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const sources = parseJson(form.values.sources); const sources = parseJson(form.values.sources);
const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"]; const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"];
const options = parseJson(form.values.options);
const optionNames = isRecord(options) ? Object.keys(options) : [];
const commit = (next: Array<[string, unknown]>) => const commit = (next: Array<[string, unknown]>) =>
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2)); form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
@@ -53,7 +50,7 @@ export function CustomWidgetRequestsEditor({
const kind = request.kind === "action" ? "action" : "query"; const kind = request.kind === "action" ? "action" : "query";
const trigger = request.trigger === "manual" ? "manual" : "load"; const trigger = request.trigger === "manual" ? "manual" : "load";
return ( return (
<Fieldset key={id} legend={id || t("request", { count: index + 1 })}> <Fieldset key={index} legend={id || t("request", { count: index + 1 })}>
<Stack gap="sm"> <Stack gap="sm">
<Group grow align="start"> <Group grow align="start">
<CustomWidgetIdentifierInput <CustomWidgetIdentifierInput
@@ -122,61 +119,71 @@ export function CustomWidgetRequestsEditor({
<TextInput <TextInput
label={t("path")} label={t("path")}
description={t("pathDescription")} description={t("pathDescription")}
descriptionProps={{
style: { color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))" },
}}
value={typeof request.path === "string" ? request.path : ""} value={typeof request.path === "string" ? request.path : ""}
onChange={(event) => update(index, id, { ...request, path: event.currentTarget.value })} onChange={(event) => update(index, id, { ...request, path: event.currentTarget.value })}
/> />
<LazyOnceAccordion label={t("requestDetails")}> <Accordion variant="contained">
<Stack gap="sm"> <Accordion.Item value="values">
<QueryValuesEditor <Accordion.Control>{t("requestDetails")}</Accordion.Control>
value={isRecord(request.query) ? request.query : {}} <Accordion.Panel>
optionNames={optionNames} <Stack gap="sm">
allowParams={trigger === "manual" || kind === "action"} <QueryValuesEditor
onChange={(query) => update(index, id, { ...request, query })} value={isRecord(request.query) ? request.query : {}}
/> optionNames={Object.keys(
<RequestBodyEditor isRecord(parseJson(form.values.options))
id={id} ? (parseJson(form.values.options) as Record<string, unknown>)
value={request.body} : {},
optionNames={optionNames} )}
allowParams={trigger === "manual" || kind === "action"} allowParams={trigger === "manual" || kind === "action"}
onChange={(body) => update(index, id, { ...request, body })} onChange={(query) => update(index, id, { ...request, query })}
/> />
{kind === "action" && ( <RequestBodyEditor
<TextInput id={id}
label={t("confirmation")} value={request.body}
value={typeof request.confirmation === "string" ? request.confirmation : ""} optionNames={Object.keys(
onChange={(event) => isRecord(parseJson(form.values.options))
update(index, id, { ...request, confirmation: event.currentTarget.value || undefined }) ? (parseJson(form.values.options) as Record<string, unknown>)
} : {},
/> )}
)} allowParams={trigger === "manual" || kind === "action"}
{kind === "action" && ( onChange={(body) => update(index, id, { ...request, body })}
<TextInput />
label={t("invalidation")} {kind === "action" && (
description={t("invalidationDescription")} <TextInput
value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""} label={t("confirmation")}
onChange={(event) => value={typeof request.confirmation === "string" ? request.confirmation : ""}
update(index, id, { onChange={(event) =>
...request, update(index, id, { ...request, confirmation: event.currentTarget.value || undefined })
invalidates: event.currentTarget.value }
.split(",") />
.map((value) => value.trim()) )}
.filter(Boolean), {kind === "action" && (
}) <TextInput
} label={t("invalidation")}
/> description={t("invalidationDescription")}
)} value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""}
<Switch onChange={(event) =>
label={t("noAuth")} update(index, id, {
checked={request.auth === "none"} ...request,
onChange={(event) => invalidates: event.currentTarget.value
update(index, id, { ...request, auth: event.currentTarget.checked ? "none" : "inherit" }) .split(",")
} .map((value) => value.trim())
/> .filter(Boolean),
</Stack> })
</LazyOnceAccordion> }
/>
)}
<Switch
label={t("noAuth")}
checked={request.auth === "none"}
onChange={(event) =>
update(index, id, { ...request, auth: event.currentTarget.checked ? "none" : "inherit" })
}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between"> <Group justify="space-between">
<Group gap="xs"> <Group gap="xs">
<ActionIcon <ActionIcon
@@ -1,37 +0,0 @@
import type { Dispatch, SetStateAction } from "react";
import type { UseFormReturnType } from "@mantine/form";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
export function reportSaveIssues(
form: UseFormReturnType<CustomWidgetFormValues>,
issues: CustomWidgetSaveIssue[],
setIssues: Dispatch<SetStateAction<CustomWidgetSaveIssue[]>>,
title: string,
getRemainingLabel: (count: number) => string,
) {
setIssues(issues);
const messagesByPath = new Map<string, string[]>();
for (const issue of issues) {
if (!issue.path) continue;
const field = issue.path.split(".")[0] ?? issue.path;
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
}
for (const [path, messages] of messagesByPath) {
form.setFieldError(path, messages.join(" "));
}
const remaining = issues.length - 1;
showErrorNotification({
title,
message: `${issues[0]?.message ?? ""}${remaining > 0 ? ` ${getRemainingLabel(remaining)}` : ""}`,
});
}
export function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
form.clearFieldError(path);
}
}
@@ -3,12 +3,12 @@
import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core"; import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core";
import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react"; import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors"; import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) { export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) {
const t = useI18n("customWidget.workbench.saveError"); const t = useScopedI18n("customWidget.workbench.saveError");
if (issues.length === 0) return null; if (issues.length === 0) return null;
const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n"); const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
@@ -5,7 +5,7 @@ import { IconKey, IconTrash } from "@tabler/icons-react";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core"; import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core"; import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input"; import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
@@ -51,8 +51,7 @@ export function CustomWidgetSourceField({
onClearSecret, onClearSecret,
onRemove, onRemove,
}: CustomWidgetSourceFieldProps) { }: CustomWidgetSourceFieldProps) {
const t = useI18n("customWidget.workbench.sources"); const t = useScopedI18n("customWidget.workbench.sources");
const tSecret = useI18n("customWidget.secret");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl); const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type; const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
@@ -119,7 +118,7 @@ export function CustomWidgetSourceField({
<Group key={kind} align="end" wrap="nowrap"> <Group key={kind} align="end" wrap="nowrap">
<Input <Input
style={{ flex: 1 }} style={{ flex: 1 }}
label={tSecret(kind)} label={t(`secret.${kind}`)}
value={secret?.value ?? ""} value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined} placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />} leftSection={<IconKey size={15} />}
@@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core"; import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications"; import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils"; import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
@@ -21,8 +21,8 @@ export function CustomWidgetSourcesEditor({
form: CustomWidgetWorkbenchForm; form: CustomWidgetWorkbenchForm;
definitionId?: string; definitionId?: string;
}) { }) {
const t = useI18n("customWidget.workbench.sources"); const t = useScopedI18n("customWidget.workbench.sources");
const w = useI18n("customWidget.workbench"); const w = useScopedI18n("customWidget.workbench");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation(); const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
@@ -3,7 +3,7 @@
import { Tabs } from "@mantine/core"; import { Tabs } from "@mantine/core";
import { IconApi, IconBuildingStore } from "@tabler/icons-react"; import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
const tabs = { const tabs = {
@@ -13,7 +13,7 @@ const tabs = {
/** Switches between the widgets you have installed and the community Workshop. */ /** Switches between the widgets you have installed and the community Workshop. */
export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => { export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
const t = useI18n("customWidget.page.tabs"); const t = useScopedI18n("customWidget.page.tabs");
return ( return (
<Tabs value={tabs[active]}> <Tabs value={tabs[active]}>
@@ -1,18 +0,0 @@
export function areCustomWidgetValuesEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left)) {
if (!Array.isArray(right) || left.length !== right.length) return false;
return left.every((value, index) => areCustomWidgetValuesEqual(value, right[index]));
}
if (!isRecord(left) || !isRecord(right)) return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every((key) => Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -5,7 +5,7 @@ import { Component } from "react";
import { Alert, Button, Stack, Text } from "@mantine/core"; import { Alert, Button, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
interface FormErrorBoundaryState { interface FormErrorBoundaryState {
error: Error | null; error: Error | null;
@@ -35,8 +35,7 @@ export class FormErrorBoundary extends Component<FormErrorBoundaryProps, FormErr
} }
function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) { function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
const t = useI18n("customWidget"); const t = useScopedI18n("customWidget");
const tCommon = useI18n("common");
return ( return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md"> <Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md">
<Stack gap="xs"> <Stack gap="xs">
@@ -47,7 +46,7 @@ function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }
{error.message} {error.message}
</Text> </Text>
<Button size="xs" variant="light" color="red" onClick={reset}> <Button size="xs" variant="light" color="red" onClick={reset}>
{tCommon("action.tryAgain")} {t("editor.errorBoundary.retry")}
</Button> </Button>
</Stack> </Stack>
</Alert> </Alert>
@@ -11,14 +11,13 @@ import {
} from "@homarr/custom-widgets/core"; } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
export const ImportCustomWidgetButton = () => { export const ImportCustomWidgetButton = () => {
const t = useI18n("customWidget"); const t = useScopedI18n("customWidget");
const tCommon = useI18n("common");
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null); const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false); const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
@@ -39,7 +38,7 @@ export const ImportCustomWidgetButton = () => {
const result = parseCustomWidgetClipboardDetailed(text); const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) { if (!result.success) {
showErrorNotification({ showErrorNotification({
title: tCommon("action.import"), title: t("action.import"),
message: formatCustomWidgetImportIssues(result.issues), message: formatCustomWidgetImportIssues(result.issues),
}); });
return; return;
@@ -48,7 +47,7 @@ export const ImportCustomWidgetButton = () => {
}; };
window.addEventListener("paste", handlePaste); window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste); return () => window.removeEventListener("paste", handlePaste);
}, [queueImport, t, tCommon]); }, [queueImport, t]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => { const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -62,13 +61,13 @@ export const ImportCustomWidgetButton = () => {
queueImport(result.widget); queueImport(result.widget);
} catch (error) { } catch (error) {
showErrorNotification({ showErrorNotification({
title: tCommon("action.import"), title: t("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"), message: error instanceof Error ? error.message : t("notification.importError"),
}); });
} }
}); });
reader.addEventListener("error", () => { reader.addEventListener("error", () => {
showErrorNotification({ title: tCommon("action.import"), message: t("notification.importError") }); showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
}); });
reader.readAsText(file); reader.readAsText(file);
event.target.value = ""; event.target.value = "";
@@ -82,7 +81,7 @@ export const ImportCustomWidgetButton = () => {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
title={t("action.pasteImportHint")} title={t("action.pasteImportHint")}
> >
{tCommon("action.import")} {t("action.import")}
</MobileAffixButton> </MobileAffixButton>
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -9,9 +9,7 @@ import { JsonPreviewEditor } from "./_json-preview-editor";
const editorProps = vi.hoisted(() => vi.fn()); const editorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/translation/client", () => ({ vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
useI18n: (namespace: string) => (key: string) => `${namespace}.${key}`,
}));
vi.mock("./_code-editor", () => ({ vi.mock("./_code-editor", () => ({
CodeEditor: (props: { error?: string; onChange(value: string): void }) => { CodeEditor: (props: { error?: string; onChange(value: string): void }) => {
editorProps(props); editorProps(props);
@@ -43,8 +41,6 @@ describe("preview JSON editor", () => {
await act(async () => (host.querySelector("button") as HTMLButtonElement).click()); await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
expect(onChange).toHaveBeenCalledWith({}); expect(onChange).toHaveBeenCalledWith({});
expect(editorProps).toHaveBeenLastCalledWith( expect(editorProps).toHaveBeenLastCalledWith(expect.objectContaining({ error: "invalidJson" }));
expect.objectContaining({ error: "customWidget.workbench.builder.invalidJson" }),
);
}); });
}); });
Loaded 100 of 862 files, more files were not shown because too many files have changed in this diff. Show more