Compare commits

..
151 changed files with 4879 additions and 13267 deletions

No files matched your search

@@ -1,60 +0,0 @@
---
name: homarr-local-harness
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
---
# Homarr Local Harness
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
## Set up the harness
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
```sh
pnpm cli harness setup feature-board
```
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
- `DEMO_MODE=true`
- `DEMO_READ_ONLY=false`
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
Pass repeatable environment overrides when the feature needs them:
```sh
pnpm cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
Always use the URL printed by the command. If the output is no longer available, query it directly:
```sh
pnpm cli harness port feature-board
```
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
## Browser validation
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
```sh
agent-browser skills get core
```
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
## Cleanup
Stop the named container when finished:
```sh
pnpm cli harness stop feature-board
```
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
@@ -1,4 +0,0 @@
interface:
display_name: "Homarr Local Harness"
short_description: "Build and browse a writable local Homarr branch harness"
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
+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
@@ -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,27 +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:
```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`.
+1 -4
View File
@@ -44,15 +44,12 @@ host gateway address.
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.
+9 -8
View File
@@ -204,13 +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 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
@@ -230,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
-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
@@ -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
+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
View File
@@ -2106,6 +2106,7 @@
"enum": [ "enum": [
"clock", "clock",
"weather", "weather",
"today",
"airQuality", "airQuality",
"countdown", "countdown",
"timer", "timer",
@@ -23,8 +23,6 @@ import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host"; import { BoardGridPortalHost, BoardGridPortalRenderer } 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;
@@ -65,74 +63,71 @@ export const ClientBoard = () => {
.join(" "); .join(" ");
const content = ( const content = (
<BoardSelectionProvider> <BoardAdvancedFocusProvider>
<BoardAdvancedFocusProvider> <Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board> <BoardBackgroundVideo />
<BoardBackgroundVideo /> <BoardEmptyState />
<BoardEmptyState /> <BoardSetupChecklist />
<BoardSetupChecklist /> <BoardSectionCollapseProvider>
<BoardSectionCollapseProvider> <BoardGridPortalHost>
<BoardGridPortalHost> <ScaledBoardCanvas
<ScaledBoardCanvas logicalWidth={logicalWidth}
logicalWidth={logicalWidth} initialLogicalHeight={initialLogicalHeight}
initialLogicalHeight={initialLogicalHeight} initialAvailableWidth={initialAvailableWidth}
initialAvailableWidth={initialAvailableWidth} label={board.name}
label={board.name} >
> <BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridEditorBoundary key={currentLayoutId}> <BoardGridPortalRenderer />
<BoardGridPortalRenderer /> <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"
</Box> />
</BoardAdvancedFocusProvider> </aside>
</BoardSelectionProvider> )}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
</Box>
</BoardAdvancedFocusProvider>
); );
if (representativeWidth === null) return content; if (representativeWidth === null) return content;
@@ -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,13 +17,9 @@ 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 { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const t = useI18n(); const t = useI18n();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@@ -38,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={t("board.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}> })
{t("item.action.create")} }
</Menu.Item> >
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}> <IconPlus stroke={1.5} />
{t("app.action.add")} </HeaderButton>
</Menu.Item>
{session?.user.permissions.includes("integration-create") && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{t("section.container.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
); );
}; };
@@ -29,7 +29,6 @@ export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) =>
}))} }))}
{...form.getInputProps("defaultColorScheme")} {...form.getInputProps("defaultColorScheme")}
SelectOption={AppearanceCustomOption} SelectOption={AppearanceCustomOption}
withinPortal
/> />
</SectionCard> </SectionCard>
); );
@@ -2,7 +2,7 @@
import { Switch, Text } from "@mantine/core"; import { Switch, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -12,11 +12,11 @@ import type { FormValues } from "./settings-form";
interface BoardSettingsFormProps { interface BoardSettingsFormProps {
form: UseFormReturnType<FormValues>; form: UseFormReturnType<FormValues>;
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
} }
export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormProps) => { export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
const tBoard = useScopedI18n("management.page.settings.section.board"); const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
return ( return (
<SectionCard title={tBoard("title")}> <SectionCard title={tBoard("title")}>
@@ -26,7 +26,6 @@ export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormP
clearable clearable
boards={selectableBoards} boards={selectableBoards}
{...form.getInputProps("homeBoardId")} {...form.getInputProps("homeBoardId")}
withinPortal
/> />
<BoardSelect <BoardSelect
@@ -35,7 +34,6 @@ export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormP
clearable clearable
boards={selectableBoards} boards={selectableBoards}
{...form.getInputProps("mobileHomeBoardId")} {...form.getInputProps("mobileHomeBoardId")}
withinPortal
/> />
<Text fw={500}>{tBoard("status.title")}</Text> <Text fw={500}>{tBoard("status.title")}</Text>
@@ -22,7 +22,6 @@ export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
label={tCulture("defaultLocale.label")} label={tCulture("defaultLocale.label")}
{...localeInputProps} {...localeInputProps}
value={localeInputProps.value as SupportedLanguage} value={localeInputProps.value as SupportedLanguage}
withinPortal
/> />
</SectionCard> </SectionCard>
); );
@@ -2,7 +2,7 @@
import { Select } from "@mantine/core"; import { Select } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -11,11 +11,11 @@ import type { FormValues } from "./settings-form";
interface SearchSettingsFormProps { interface SearchSettingsFormProps {
form: UseFormReturnType<FormValues>; form: UseFormReturnType<FormValues>;
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
} }
export const SearchSettingsForm = ({ form, selectableSearchEngines }: SearchSettingsFormProps) => { export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
const tSearch = useScopedI18n("management.page.settings.section.search"); const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return ( return (
<SectionCard title={tSearch("title")}> <SectionCard title={tSearch("title")}>
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from "react";
import { Button, Stack } from "@mantine/core"; import { Button, Stack } from "@mantine/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
@@ -59,11 +58,9 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
interface SettingsFormProps { interface SettingsFormProps {
initialData: ServerSettings; initialData: ServerSettings;
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
} }
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => { export const SettingsForm = ({ initialData }: SettingsFormProps) => {
const t = useI18n(); const t = useI18n();
const tSettings = useScopedI18n("management.page.settings"); const tSettings = useScopedI18n("management.page.settings");
@@ -172,13 +169,13 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
return ( return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl"> <Stack gap="xl">
<BoardSettingsForm form={form} selectableBoards={selectableBoards} />
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
<AppearanceSettingsForm form={form} />
<CultureSettingsForm form={form} />
<AnalyticsSettings form={form} /> <AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} /> <CrawlingAndIndexingSettings form={form} />
<BoardSettingsForm form={form} />
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} />
<AppearanceSettingsForm form={form} />
<CultureSettingsForm form={form} />
{form.isDirty() && ( {form.isDirty() && (
<UnsavedChangesBar> <UnsavedChangesBar>
@@ -23,20 +23,12 @@ export default async function SettingsPage() {
notFound(); notFound();
} }
const [serverSettings, selectableBoards, selectableSearchEngines, tSettings] = await Promise.all([ const serverSettings = await api.serverSettings.getAll();
api.serverSettings.getAll(), const tSettings = await getScopedI18n("management.page.settings");
api.board.getPublicBoards(),
api.searchEngine.getSelectable({ withIntegrations: false }),
getScopedI18n("management.page.settings"),
]);
return ( return (
<ManagePageLayout title={tSettings("title")}> <ManagePageLayout title={tSettings("title")}>
<SettingsForm <SettingsForm initialData={serverSettings} />
initialData={serverSettings}
selectableBoards={selectableBoards}
selectableSearchEngines={selectableSearchEngines}
/>
</ManagePageLayout> </ManagePageLayout>
); );
} }
@@ -2,11 +2,7 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
interface ClientSideTerminalComponentProps { export const ClientSideTerminalComponent = dynamic(
focusTimestamp?: number;
}
export const ClientSideTerminalComponent = dynamic<ClientSideTerminalComponentProps>(
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent), () => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
{ {
ssr: false, ssr: false,
@@ -29,25 +29,12 @@ export async function generateMetadata() {
}; };
} }
interface LogsManagementPageProps { export default async function LogsManagementPage() {
searchParams: Promise<{ focus?: string | string[] }>;
}
export default async function LogsManagementPage({ searchParams }: LogsManagementPageProps) {
const session = await auth(); const session = await auth();
if (!session?.user.permissions.includes("other-view-logs")) { if (!session?.user.permissions.includes("other-view-logs")) {
notFound(); notFound();
} }
const focus = (await searchParams).focus;
let focusTimestamp: number | undefined;
if (typeof focus === "string") {
const parsedFocusTimestamp = Number(focus);
if (Number.isSafeInteger(parsedFocusTimestamp) && parsedFocusTimestamp > 0) {
focusTimestamp = parsedFocusTimestamp;
}
}
return ( return (
<LogContextProvider defaultLevel={logsEnv.LEVEL}> <LogContextProvider defaultLevel={logsEnv.LEVEL}>
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
@@ -58,7 +45,7 @@ export default async function LogsManagementPage({ searchParams }: LogsManagemen
</Group> </Group>
</Group> </Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black"> <Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent focusTimestamp={focusTimestamp} /> <ClientSideTerminalComponent />
</Box> </Box>
</LogContextProvider> </LogContextProvider>
); );
@@ -7,163 +7,25 @@ import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { LoggerMessage, LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { LOG_HISTORY_MAX_ENTRIES, logLevels } from "@homarr/core/infrastructure/logs/constants";
import { useI18n } from "@homarr/translation/client";
import { useLogContext } from "./log-context"; import { useLogContext } from "./log-context";
import classes from "./terminal.module.css"; import classes from "./terminal.module.css";
const ALL_LOG_LEVELS = [...logLevels]; export const TerminalComponent = () => {
const TERMINAL_SCROLLBACK_LINES = LOG_HISTORY_MAX_ENTRIES * 20;
const getFocusMarker = (
messages: LoggerMessage[],
visibleMessages: LoggerMessage[],
focusTimestamp: number | undefined,
markerText: string,
expiredMarkerText: string,
) => {
if (focusTimestamp === undefined) return null;
const oldestTimestamp = messages[0]?.timestamp.getTime();
if (oldestTimestamp === undefined || focusTimestamp < oldestTimestamp) {
return { index: 0, text: expiredMarkerText };
}
return {
index: visibleMessages.findLastIndex((message) => message.timestamp.getTime() <= focusTimestamp) + 1,
text: markerText,
};
};
const findMarkerLine = (terminal: Terminal, linesFromEnd: number) => {
const buffer = terminal.buffer.active;
const logicalLineStarts: number[] = [];
const cursorLine = buffer.baseY + buffer.cursorY;
for (let index = 0; index <= cursorLine; index++) {
const line = buffer.getLine(index);
if (!line) continue;
if (!line.isWrapped) {
logicalLineStarts.push(index);
}
}
return logicalLineStarts.at(-linesFromEnd) ?? null;
};
const renderMessages = ({
terminal,
messages,
activeLevels,
focusTimestamp,
markerText,
expiredMarkerText,
}: {
terminal: Terminal;
messages: LoggerMessage[];
activeLevels: LogLevel[];
focusTimestamp: number | undefined;
markerText: string;
expiredMarkerText: string;
}) => {
const activeLevelSet = new Set(activeLevels);
const visibleMessages = messages.filter((message) => activeLevelSet.has(message.level));
const focusMarker = getFocusMarker(messages, visibleMessages, focusTimestamp, markerText, expiredMarkerText);
const output: string[] = [];
let markerOutputIndex: number | null = null;
visibleMessages.forEach((message, index) => {
if (focusMarker?.index === index) {
markerOutputIndex = output.length;
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
}
output.push(message.message);
});
if (focusMarker?.index === visibleMessages.length) {
markerOutputIndex = output.length;
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
}
let markerLinesFromEnd: number | null = null;
if (markerOutputIndex !== null) {
markerLinesFromEnd =
1 + output.slice(markerOutputIndex).reduce((lineCount, line) => lineCount + line.split(/\r?\n/).length, 0);
}
terminal.write(`\x1bc${output.join("\r\n")}${output.length > 0 ? "\r\n" : ""}`, () => {
if (markerLinesFromEnd === null) {
terminal.scrollToBottom();
return;
}
const markerLine = findMarkerLine(terminal, markerLinesFromEnd);
if (markerLine !== null) {
terminal.scrollToLine(markerLine);
}
});
};
interface TerminalComponentProps {
focusTimestamp?: number;
}
export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { activeLevels, fontSize } = useLogContext(); const { activeLevels, fontSize } = useLogContext();
const t = useI18n();
const terminalRef = useRef<Terminal>(null); const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null); const fitAddonRef = useRef<FitAddon>(null);
const messagesRef = useRef<LoggerMessage[]>([]);
const initialFontSizeRef = useRef(fontSize);
const activeLevelsRef = useRef(activeLevels);
const markerTextRef = useRef("");
const expiredMarkerTextRef = useRef("");
const focusTimestampRef = useRef(focusTimestamp);
activeLevelsRef.current = activeLevels;
markerTextRef.current = t("log.context.widgetError");
expiredMarkerTextRef.current = t("log.context.expiredWidgetError");
focusTimestampRef.current = focusTimestamp;
const redrawTerminal = () => {
const terminal = terminalRef.current;
if (!terminal) return;
renderMessages({
terminal,
messages: messagesRef.current,
activeLevels: activeLevelsRef.current,
focusTimestamp: focusTimestampRef.current,
markerText: markerTextRef.current,
expiredMarkerText: expiredMarkerTextRef.current,
});
};
clientApi.log.subscribe.useSubscription( clientApi.log.subscribe.useSubscription(
{ {
levels: ALL_LOG_LEVELS, levels: activeLevels,
}, },
{ {
onData(event) { onData(data) {
if (event.type === "history") { terminalRef.current?.writeln(data.message);
messagesRef.current = event.messages.slice(-LOG_HISTORY_MAX_ENTRIES); terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
redrawTerminal();
return;
}
messagesRef.current.push(event.message);
if (messagesRef.current.length > LOG_HISTORY_MAX_ENTRIES) {
messagesRef.current.shift();
}
if (activeLevelsRef.current.includes(event.message.level)) {
terminalRef.current?.writeln(event.message.message);
}
}, },
onError(err) { onError(err) {
alert(err); alert(err);
@@ -182,26 +44,22 @@ export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) =>
cursorBlink: false, cursorBlink: false,
disableStdin: true, disableStdin: true,
convertEol: true, convertEol: true,
fontSize: initialFontSizeRef.current, fontSize,
scrollback: TERMINAL_SCROLLBACK_LINES,
}); });
terminalRef.current.open(ref.current); terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon); terminalRef.current.loadAddon(canvasAddon);
const fitTimeout = window.setTimeout(() => { setTimeout(() => {
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon); terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit(); fitAddon.fit();
redrawTerminal();
}); });
return () => { return () => {
window.clearTimeout(fitTimeout);
canvasAddon.dispose(); canvasAddon.dispose();
terminalRef.current?.dispose(); terminalRef.current?.dispose();
terminalRef.current = null; terminalRef.current = null;
fitAddonRef.current = null;
}; };
}, []); }, []);
@@ -211,9 +69,5 @@ export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) =>
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
}, [fontSize]); }, [fontSize]);
useEffect(() => {
redrawTerminal();
}, [activeLevels, focusTimestamp]);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>; return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
}; };
@@ -104,46 +104,6 @@ describe("assistant automatic approval tracker", () => {
await act(async () => root.unmount()); await act(async () => root.unmount());
}); });
test("does not approve a ready action while switching conversations", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
let confirmationCount = 0;
const renderConversation = async (conversationId: string, ready: boolean) => {
await act(async () => {
root.render(
createElement(
AssistantAutoApprovalProvider,
{ conversationId },
createElement(
Fragment,
null,
createElement(AutoApprovalProbe),
createElement(AutomaticActionProbe, {
ready,
completed: false,
confirm: () => {
confirmationCount += 1;
},
}),
),
),
);
});
};
await renderConversation("local-thread-1", false);
act(() => currentAutoApproval?.setEnabled(true));
await renderConversation("local-thread-2", true);
expect(confirmationCount).toBe(0);
expect(currentAutoApproval?.enabled).toBe(false);
expect(automaticActionInProgress).toBe(false);
await act(async () => root.unmount());
});
test("clears claimed calls when automatic approvals are turned off", async () => { test("clears claimed calls when automatic approvals are turned off", async () => {
const container = document.createElement("div"); const container = document.createElement("div");
containers.push(container); containers.push(container);
@@ -52,8 +52,6 @@ export const AssistantAutoApprovalProvider = ({
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS)); const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
const retryTimersRef = useRef(new Map<string, number>()); const retryTimersRef = useRef(new Map<string, number>());
const previousConversationIdRef = useRef(conversationId); const previousConversationIdRef = useRef(conversationId);
const conversationMatches = previousConversationIdRef.current === conversationId;
const effectiveEnabled = conversationMatches && enabled;
const clearRetryTimer = useCallback((toolCallId: string) => { const clearRetryTimer = useCallback((toolCallId: string) => {
const timer = retryTimersRef.current.get(toolCallId); const timer = retryTimersRef.current.get(toolCallId);
@@ -88,7 +86,7 @@ export const AssistantAutoApprovalProvider = ({
const requestAction = useCallback( const requestAction = useCallback(
(toolCallId: string, confirm: () => void) => { (toolCallId: string, confirm: () => void) => {
if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false; if (!enabled || !trackerRef.current.claim(toolCallId)) return false;
try { try {
confirm(); confirm();
@@ -107,7 +105,7 @@ export const AssistantAutoApprovalProvider = ({
return false; return false;
} }
}, },
[clearRetryTimer, effectiveEnabled], [clearRetryTimer, enabled],
); );
const completeAction = useCallback( const completeAction = useCallback(
@@ -118,15 +116,10 @@ export const AssistantAutoApprovalProvider = ({
[clearRetryTimer], [clearRetryTimer],
); );
const value = useMemo(() => { const value = useMemo(
return { () => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }),
enabled: effectiveEnabled, [completeAction, enabled, requestAction, retryRevision, setEnabled],
setEnabled, );
requestAction,
completeAction,
retryRevision,
};
}, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]);
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>; return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
}; };
@@ -20,15 +20,11 @@ export interface AssistantContextValue {
isRunning: boolean; isRunning: boolean;
isRefreshing: boolean; isRefreshing: boolean;
unreadCount: number; unreadCount: number;
hasVisibleWidget: boolean;
activeWidgetId: string | null;
open: () => void; open: () => void;
close: () => void; close: () => void;
toggle: () => void; toggle: () => void;
sendPrompt: (prompt: string) => boolean; sendPrompt: (prompt: string) => boolean;
refreshCurrentView: () => Promise<void>; refreshCurrentView: () => Promise<void>;
setWidgetVisible: (widgetId: string, visible: boolean) => void;
activateWidget: (widgetId: string) => void;
} }
export const AssistantContext = createContext<AssistantContextValue | null>(null); export const AssistantContext = createContext<AssistantContextValue | null>(null);
@@ -34,15 +34,11 @@ const DisabledAssistant = ({ children, description }: PropsWithChildren<{ descri
isRunning: false, isRunning: false,
isRefreshing: false, isRefreshing: false,
unreadCount: 0, unreadCount: 0,
hasVisibleWidget: false,
activeWidgetId: null,
open: () => undefined, open: () => undefined,
close: () => undefined, close: () => undefined,
toggle: () => undefined, toggle: () => undefined,
sendPrompt: () => false, sendPrompt: () => false,
refreshCurrentView: () => Promise.resolve(), refreshCurrentView: () => Promise.resolve(),
setWidgetVisible: () => undefined,
activateWidget: () => undefined,
}), }),
[description], [description],
); );
@@ -29,7 +29,7 @@ import { AppForm } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app"; import type { appManageSchema } from "@homarr/validation/app";
import { getAssistantAskUserOptionKind } from "./assistant-ask-user"; import { getAssistantAffirmativeResult, getAssistantAskUserOptionKind } from "./assistant-ask-user";
import { useAssistantAutomaticAction } from "./assistant-auto-approval"; import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress"; import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
import classes from "./assistant-panel.module.css"; import classes from "./assistant-panel.module.css";
@@ -67,6 +67,7 @@ export const AssistantAskUserTool = ({
result, result,
addResult, addResult,
status, status,
toolCallId,
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => { }: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
const t = useScopedI18n("common.assistant.askUser"); const t = useScopedI18n("common.assistant.askUser");
const [showOther, setShowOther] = useState(false); const [showOther, setShowOther] = useState(false);
@@ -74,6 +75,17 @@ export const AssistantAskUserTool = ({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const otherInputRef = useRef<HTMLInputElement>(null); const otherInputRef = useRef<HTMLInputElement>(null);
const options = Array.isArray(args?.options) ? args.options : []; const options = Array.isArray(args?.options) ? args.options : [];
const affirmativeResult = getAssistantAffirmativeResult(options);
const hasValidQuestion = hasCompleteAssistantToolArguments(status) && Boolean(args?.question) && options.length >= 2;
const autoConfirming = useAssistantAutomaticAction({
toolCallId,
ready: result === undefined && hasValidQuestion && affirmativeResult !== undefined,
completed: result !== undefined,
confirm: () => {
if (!affirmativeResult) return;
addResult(affirmativeResult);
},
});
useEffect(() => { useEffect(() => {
if (showOther) otherInputRef.current?.focus(); if (showOther) otherInputRef.current?.focus();
@@ -153,62 +165,67 @@ export const AssistantAskUserTool = ({
</Group> </Group>
<Stack gap="xs" mt="md"> <Stack gap="xs" mt="md">
{options.map((option, index) => { {autoConfirming ? (
const kind = getAssistantAskUserOptionKind(option); <AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
const icon = ) : (
kind === "affirmative" ? ( options.map((option, index) => {
<IconCheck size={17} /> const kind = getAssistantAskUserOptionKind(option);
) : kind === "negative" ? ( const icon =
<IconX size={17} /> kind === "affirmative" ? (
) : ( <IconCheck size={17} />
<IconArrowRight size={17} /> ) : kind === "negative" ? (
); <IconX size={17} />
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray"; ) : (
<IconArrowRight size={17} />
);
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
return ( return (
<UnstyledButton <UnstyledButton
key={`${option.id}:${index}`} key={`${option.id}:${index}`}
className={classes.humanToolOption} className={classes.humanToolOption}
data-kind={kind} data-kind={kind}
disabled={submitting} disabled={submitting}
onClick={() => onClick={() =>
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" }) submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
} }
>
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
{icon}
</ThemeIcon>
<Box className={classes.humanToolOptionText} ta="start">
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
{option.label}
</Text>
{option.description && (
<Text
component="span"
display="block"
size="xs"
c="dimmed"
fw={400}
className={classes.humanToolOptionDescription}
>
{option.description}
</Text>
)}
</Box>
<Badge
className={classes.humanToolOptionBadge}
classNames={{ label: classes.humanToolOptionBadgeLabel }}
variant="light"
color={color}
size="xs"
> >
{t(`optionKind.${kind}`)} <ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
</Badge> {icon}
</UnstyledButton> </ThemeIcon>
); <Box className={classes.humanToolOptionText} ta="start">
})} <Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
{option.label}
</Text>
{option.description && (
<Text
component="span"
display="block"
size="xs"
c="dimmed"
fw={400}
className={classes.humanToolOptionDescription}
>
{option.description}
</Text>
)}
</Box>
<Badge
className={classes.humanToolOptionBadge}
classNames={{ label: classes.humanToolOptionBadgeLabel }}
variant="light"
color={color}
size="xs"
>
{t(`optionKind.${kind}`)}
</Badge>
</UnstyledButton>
);
})
)}
{args.allowOther !== false && {!autoConfirming &&
args.allowOther !== false &&
(showOther ? ( (showOther ? (
<Group align="flex-end" gap="xs" wrap="nowrap"> <Group align="flex-end" gap="xs" wrap="nowrap">
<TextInput <TextInput
@@ -98,18 +98,18 @@
container-type: size; container-type: size;
position: fixed; position: fixed;
z-index: 290; z-index: 290;
inset-block: 1rem; inset-inline-start: 50%;
inset-inline-end: 1rem; bottom: 1rem;
inset-inline-start: auto;
display: flex; display: flex;
width: min(34rem, calc(100vw - 2rem)); width: min(46rem, calc(100vw - 2rem));
height: auto; height: min(36rem, calc(100dvh - 5rem));
min-height: min(30rem, calc(100dvh - 2rem)); min-height: min(22rem, calc(100dvh - 5rem));
margin: 0; margin: 0;
transform: translateX(-50%);
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
border: 1px solid var(--mantine-color-default-border); border: 0;
border-radius: var(--mantine-radius-lg); border-radius: var(--mantine-radius-lg);
color: var(--mantine-color-text); color: var(--mantine-color-text);
background: var(--mantine-color-body); background: var(--mantine-color-body);
@@ -157,28 +157,27 @@
border-bottom: 1px solid var(--mantine-color-default-border); border-bottom: 1px solid var(--mantine-color-default-border);
} }
.panelIdentity {
min-width: 0;
flex: 1 1 auto;
}
.panelIdentityIcon {
flex: 0 0 auto;
}
.panelIdentityTitle,
.panelThreadTitle {
max-width: 14rem;
}
.panelActions { .panelActions {
min-width: 0; min-width: 0;
flex: 0 0 auto; flex: 0 0 auto;
margin-inline-start: auto; margin-inline-start: auto;
} }
.newConversationButton { .windowControls {
flex: 0 0 auto; flex: 0 0 auto;
margin: 0;
padding: 0;
border: 0;
}
.windowControl {
width: 2.25rem;
height: 2.25rem;
}
.windowControl:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
} }
.panelAction { .panelAction {
@@ -192,6 +191,10 @@
padding: 0.45rem 0.5rem 0.45rem 0.65rem; padding: 0.45rem 0.5rem 0.45rem 0.65rem;
} }
.composerHints {
display: none;
}
.historyButton { .historyButton {
width: 2.625rem; width: 2.625rem;
max-width: 2.625rem; max-width: 2.625rem;
@@ -207,21 +210,6 @@
display: none; display: none;
} }
.newConversationButton {
width: 2.625rem;
max-width: 2.625rem;
height: 2.625rem;
padding-inline: 0.75rem;
}
.newConversationButton :global(.mantine-Button-section) {
margin-inline: 0;
}
.newConversationButton :global(.mantine-Button-label) {
display: none;
}
.messages { .messages {
padding: 0.5rem 0.65rem 1rem; padding: 0.5rem 0.65rem 1rem;
} }
@@ -258,27 +246,9 @@
} }
} }
@container assistant-widget (max-width: 36rem) {
.historyButton {
width: 2.625rem;
max-width: 2.625rem;
height: 2.625rem;
padding-inline: 0.75rem;
}
.historyButtonSection {
margin-inline-end: 0;
}
.historyButtonLabel {
display: none;
}
}
@container assistant-widget (max-width: 22rem) { @container assistant-widget (max-width: 22rem) {
.panelAction, .panelAction,
.historyButton, .historyButton {
.newConversationButton {
width: 2.25rem; width: 2.25rem;
max-width: 2.25rem; max-width: 2.25rem;
height: 2.25rem; height: 2.25rem;
@@ -298,11 +268,7 @@
padding-inline: 0.15rem; padding-inline: 0.15rem;
} }
.composerContextLabel { .composerContext > :last-child {
display: none;
}
.panelThreadTitle {
display: none; display: none;
} }
} }
@@ -332,70 +298,17 @@
} }
.historyMenu { .historyMenu {
min-width: 0;
overflow: hidden;
background: var(--mantine-color-body); background: var(--mantine-color-body);
} }
.historyMenu :global(.mantine-ScrollArea-content) {
display: block;
width: 100%;
min-width: 0;
}
.historyItem { .historyItem {
display: flex;
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
align-items: center;
gap: 0.2rem;
overflow: hidden;
padding: 0.15rem 0.2rem 0.15rem 0.35rem;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
} }
.historyItem:hover, .historyItem:hover {
.historyItem[data-active] {
background: var(--mantine-color-default-hover); background: var(--mantine-color-default-hover);
} }
.historyItemTrigger {
min-width: 0;
flex: 1;
overflow: hidden;
padding: 0.35rem 0.25rem;
text-align: start;
}
.historyItemTrigger > span {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.historyRenameInput {
min-width: 0;
flex: 1;
}
.historyItemTrigger:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -1px;
}
.historyItemMenu {
flex: 0 0 auto;
opacity: 1;
}
.historyItemActions {
flex: 0 0 auto;
}
.thread { .thread {
position: relative; position: relative;
display: flex; display: flex;
@@ -428,11 +341,6 @@
margin-block: 0.9rem; margin-block: 0.9rem;
} }
.message[data-static] {
contain-intrinsic-size: auto 12rem;
content-visibility: auto;
}
.userMessageWrap { .userMessageWrap {
width: fit-content; width: fit-content;
max-width: 86%; max-width: 86%;
@@ -798,21 +706,24 @@
.pendingQuestionDock { .pendingQuestionDock {
min-height: 0; min-height: 0;
max-height: min(22rem, 48dvh); flex: 1 1 auto;
flex: 0 1 auto;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
padding: 0.75rem; padding: 0.75rem;
border-top: 1px solid var(--mantine-color-default-border);
background: var(--mantine-color-body); background: var(--mantine-color-body);
} }
.thread[data-pending-question] .viewport {
display: none;
}
.pendingQuestionTarget { .pendingQuestionTarget {
display: flex; display: flex;
width: 100%; width: 100%;
min-height: 100%;
max-width: 44rem; max-width: 44rem;
margin-inline: auto; margin-inline: auto;
align-items: flex-start; align-items: safe center;
} }
.pendingQuestionTarget .humanTool { .pendingQuestionTarget .humanTool {
@@ -1180,43 +1091,6 @@
flex: 0 1 auto; flex: 0 1 auto;
} }
.messageMoreMenu {
z-index: 410;
min-width: 11rem;
padding: 0.25rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
color: var(--mantine-color-text);
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-md);
}
.messageMoreItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.55rem;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
cursor: pointer;
outline: 0;
}
.messageMoreItem[data-highlighted] {
background: var(--mantine-color-default-hover);
}
.messageMoreItem:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
.messageMoreSeparator {
height: 1px;
margin-block: 0.2rem;
background: var(--mantine-color-default-border);
}
.userActions { .userActions {
min-height: 1.75rem; min-height: 1.75rem;
} }
@@ -1299,13 +1173,13 @@
.composerContext { .composerContext {
display: flex; display: flex;
width: auto; width: 1.75rem;
height: 1.75rem; height: 1.75rem;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding-inline: 0.3rem 0.42rem; padding: 0;
border: 1px solid var(--mantine-color-default-border); border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm); border-radius: 50%;
color: var(--mantine-color-text); color: var(--mantine-color-text);
background: var(--mantine-color-body); background: var(--mantine-color-body);
} }
@@ -1541,6 +1415,10 @@
pointer-events: none; pointer-events: none;
} }
.composerHints {
padding-inline: 0.45rem;
}
.composerFooter { .composerFooter {
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
@@ -1763,12 +1641,12 @@
@keyframes assistant-panel-in { @keyframes assistant-panel-in {
from { from {
opacity: 0; opacity: 0;
transform: translateY(0.75rem) scale(0.985); transform: translate(-50%, 0.75rem) scale(0.985);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translate(-50%, 0) scale(1);
} }
} }
@@ -1778,11 +1656,10 @@
} }
.floatingPanel { .floatingPanel {
inset: 0; bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
width: 100vw; width: calc(100vw - 1rem);
height: 100dvh; height: min(44rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
min-height: 100dvh; min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
border-radius: 0;
} }
.panelHeader { .panelHeader {
@@ -1809,6 +1686,10 @@
padding-inline: 0.1rem; padding-inline: 0.1rem;
} }
.composerHints {
display: none;
}
.runtimeSelectorTrigger { .runtimeSelectorTrigger {
max-width: 100%; max-width: 100%;
} }
@@ -1824,10 +1705,6 @@
} }
@media (max-width: 30em) { @media (max-width: 30em) {
.historyItemMenu {
opacity: 1;
}
.historyButton { .historyButton {
width: 2.625rem; width: 2.625rem;
max-width: 2.625rem; max-width: 2.625rem;
@@ -25,8 +25,6 @@ import type {
import type { MessageStatus } from "@assistant-ui/react"; import type { MessageStatus } from "@assistant-ui/react";
import { import {
ActionBarPrimitive, ActionBarPrimitive,
ActionBarMorePrimitive,
AuiIf,
AttachmentPrimitive, AttachmentPrimitive,
BranchPickerPrimitive, BranchPickerPrimitive,
ChainOfThoughtByIndicesProvider, ChainOfThoughtByIndicesProvider,
@@ -68,7 +66,6 @@ import {
SegmentedControl, SegmentedControl,
Stack, Stack,
Text, Text,
TextInput,
ThemeIcon, ThemeIcon,
Tooltip, Tooltip,
UnstyledButton, UnstyledButton,
@@ -94,7 +91,6 @@ import {
IconChevronUp, IconChevronUp,
IconCopy, IconCopy,
IconDownload, IconDownload,
IconDotsVertical,
IconFile, IconFile,
IconFileExport, IconFileExport,
IconLink, IconLink,
@@ -116,8 +112,6 @@ import {
IconThumbUp, IconThumbUp,
IconTool, IconTool,
IconTrash, IconTrash,
IconVolume,
IconVolumeOff,
IconX, IconX,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
@@ -130,7 +124,6 @@ import { useTimeAgo } from "@homarr/common";
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions"; import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions"; import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useConfirmModal } from "@homarr/modals";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css"; import classes from "./assistant-panel.module.css";
@@ -191,7 +184,6 @@ interface AssistantPanelProps extends AssistantConversationControls {
onClose: () => void; onClose: () => void;
onDismissActivity: () => void; onDismissActivity: () => void;
activityDismissed: boolean; activityDismissed: boolean;
hasVisibleWidget: boolean;
isRunning: boolean; isRunning: boolean;
unreadCount: number; unreadCount: number;
latestAssistantText: string; latestAssistantText: string;
@@ -1115,43 +1107,63 @@ const downloadAssistantMarkdown = (markdown: string, filename: string) => {
setTimeout(() => URL.revokeObjectURL(blobUrl), 0); setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
}; };
const AssistantMessageActions = () => { const AssistantMessageExportAction = () => {
const t = useScopedI18n("common.assistant"); const t = useScopedI18n("common.assistant");
const aui = useAui(); const message = useAuiState((state) => state.message);
const exportMessage = () => {
const message = aui.message().getState();
const markdown = buildAssistantMessageMarkdown({
id: message.id,
parentId: message.parentId,
format: "assistant-ui/runtime",
content: message,
createdAt: message.createdAt,
});
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
};
return ( return (
<Box className={classes.messageActions}> <Tooltip label={t("exportMarkdown")}>
<ActionBarPrimitive.Root <ActionIcon
hideWhenRunning variant="subtle"
autohide="not-last" color="gray"
autohideFloat="single-branch" size="sm"
className={classes.messageActionBar} aria-label={t("exportMarkdown")}
onClick={() => {
const markdown = buildAssistantMessageMarkdown({
id: message.id,
parentId: message.parentId,
format: "assistant-ui/runtime",
content: message,
createdAt: message.createdAt,
});
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
}}
> >
<Group gap={2} wrap="nowrap"> <IconFileExport size={14} />
</ActionIcon>
</Tooltip>
);
};
const AssistantMessageActions = () => {
const t = useScopedI18n("common.assistant");
return (
<Box className={classes.messageActions}>
<ActionBarPrimitive.Root hideWhenRunning className={classes.messageActionBar}>
<Group gap={2} wrap="wrap">
<ProviderMessageInfo /> <ProviderMessageInfo />
<Tooltip label={t("copy")}> <Tooltip label={t("copy")}>
<ActionBarPrimitive.Copy asChild> <ActionBarPrimitive.Copy asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}> <ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
<AuiIf condition={(state) => !state.message.isCopied}> <IconCopy size={14} />
<IconCopy size={14} />
</AuiIf>
<AuiIf condition={(state) => state.message.isCopied}>
<IconCheck size={14} />
</AuiIf>
</ActionIcon> </ActionIcon>
</ActionBarPrimitive.Copy> </ActionBarPrimitive.Copy>
</Tooltip> </Tooltip>
<Tooltip label={t("helpful")}>
<ActionBarPrimitive.FeedbackPositive asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("helpful")}>
<IconThumbUp size={14} />
</ActionIcon>
</ActionBarPrimitive.FeedbackPositive>
</Tooltip>
<Tooltip label={t("notHelpful")}>
<ActionBarPrimitive.FeedbackNegative asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("notHelpful")}>
<IconThumbDown size={14} />
</ActionIcon>
</ActionBarPrimitive.FeedbackNegative>
</Tooltip>
<AssistantMessageExportAction />
<Tooltip label={t("regenerate")}> <Tooltip label={t("regenerate")}>
<ActionBarPrimitive.Reload asChild> <ActionBarPrimitive.Reload asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}> <ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
@@ -1159,50 +1171,6 @@ const AssistantMessageActions = () => {
</ActionIcon> </ActionIcon>
</ActionBarPrimitive.Reload> </ActionBarPrimitive.Reload>
</Tooltip> </Tooltip>
<ActionBarMorePrimitive.Root>
<ActionBarMorePrimitive.Trigger asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("moreActions")}>
<IconDotsVertical size={14} />
</ActionIcon>
</ActionBarMorePrimitive.Trigger>
<ActionBarMorePrimitive.Content className={classes.messageMoreMenu} sideOffset={4} align="end">
<ActionBarPrimitive.FeedbackPositive asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconThumbUp size={15} />
{t("helpful")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.FeedbackPositive>
<ActionBarPrimitive.FeedbackNegative asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconThumbDown size={15} />
{t("notHelpful")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.FeedbackNegative>
<AuiIf condition={(state) => state.thread.capabilities.speech && state.message.speech === undefined}>
<ActionBarPrimitive.Speak asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconVolume size={15} />
{t("readAloud")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.Speak>
</AuiIf>
<AuiIf condition={(state) => state.message.speech !== undefined}>
<ActionBarPrimitive.StopSpeaking asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconVolumeOff size={15} />
{t("stopReading")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.StopSpeaking>
</AuiIf>
<ActionBarMorePrimitive.Separator className={classes.messageMoreSeparator} />
<ActionBarPrimitive.ExportMarkdown onExport={() => exportMessage()} asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconFileExport size={15} />
{t("exportMarkdown")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.ExportMarkdown>
</ActionBarMorePrimitive.Content>
</ActionBarMorePrimitive.Root>
</Group> </Group>
</ActionBarPrimitive.Root> </ActionBarPrimitive.Root>
<BranchPicker /> <BranchPicker />
@@ -1213,7 +1181,7 @@ const AssistantMessageActions = () => {
const UserMessageActions = () => { const UserMessageActions = () => {
const t = useScopedI18n("common.assistant"); const t = useScopedI18n("common.assistant");
return ( return (
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className={classes.userActions}> <ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
<Group gap={2}> <Group gap={2}>
<Tooltip label={t("copyMessage")}> <Tooltip label={t("copyMessage")}>
<ActionBarPrimitive.Copy asChild> <ActionBarPrimitive.Copy asChild>
@@ -1257,7 +1225,7 @@ const UserMessage = () => {
const isEditing = useAuiState((state) => state.composer.isEditing); const isEditing = useAuiState((state) => state.composer.isEditing);
if (isEditing) return <EditComposer />; if (isEditing) return <EditComposer />;
return ( return (
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`} data-static> <MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
<Box className={classes.userMessage}> <Box className={classes.userMessage}>
<MessagePrimitive.Quote> <MessagePrimitive.Quote>
{(quote) => ( {(quote) => (
@@ -1764,17 +1732,12 @@ const ConversationContext = () => {
aria-haspopup="dialog" aria-haspopup="dialog"
onClick={() => setOpened((value) => !value)} onClick={() => setOpened((value) => !value)}
> >
<Group gap={5} wrap="nowrap"> <RingProgress
<RingProgress size={24}
size={22} thickness={3}
thickness={3} roundCaps
roundCaps sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []} />
/>
<Text className={classes.composerContextLabel} component="span" size="xs" fw={650}>
{hasContext ? `${contextPercentage}%` : t("mentions.context")}
</Text>
</Group>
</UnstyledButton> </UnstyledButton>
</Popover.Target> </Popover.Target>
<Popover.Dropdown className={classes.conversationContextPopover}> <Popover.Dropdown className={classes.conversationContextPopover}>
@@ -1939,80 +1902,60 @@ const WebSearchActivity = () => {
); );
}; };
const AssistantMessage = () => { const AssistantMessage = () => (
const isComplete = useAuiState((state) => state.message.status?.type === "complete"); <MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
return ( {({ part, children }) => {
<MessagePrimitive.Root switch (part.type) {
className={`${classes.message} ${classes.assistantMessage}`} case "group-agent-trace": {
data-static={isComplete || undefined} const startIndex = part.indices[0];
> const endIndex = part.indices.at(-1);
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty"> if (startIndex === undefined || endIndex === undefined) return null;
{({ part, children }) => { return (
switch (part.type) { <ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
case "group-agent-trace": { <AssistantChainOfThought>{children}</AssistantChainOfThought>
const startIndex = part.indices[0]; </ChainOfThoughtByIndicesProvider>
const endIndex = part.indices.at(-1); );
if (startIndex === undefined || endIndex === undefined) return null;
return (
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
<AssistantChainOfThought>{children}</AssistantChainOfThought>
</ChainOfThoughtByIndicesProvider>
);
}
case "group-reasoning":
return <>{children}</>;
case "group-tool":
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
case "indicator":
return <AssistantMessagePending status={{ type: "running" }} />;
case "text":
return <AssistantTextPart />;
case "reasoning":
return <ReasoningPart {...part} />;
case "tool-call":
return part.toolUI ?? <ToolPart {...part} />;
case "source":
return <SourcePart {...part} />;
case "file":
return <FilePart {...part} />;
case "image":
return <ImagePart {...part} />;
case "data":
return part.dataRendererUI;
default:
return null;
} }
}} case "group-reasoning":
</MessagePrimitive.GroupedParts> return <>{children}</>;
<RuntimeError /> case "group-tool":
<WebSearchActivity /> return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
<AssistantMessageActions /> case "indicator":
</MessagePrimitive.Root> return <AssistantMessagePending status={{ type: "running" }} />;
); case "text":
}; return <AssistantTextPart />;
case "reasoning":
const assistantThreadMessageComponents = { UserMessage, AssistantMessage }; return <ReasoningPart {...part} />;
case "tool-call":
return part.toolUI ?? <ToolPart {...part} />;
case "source":
return <SourcePart {...part} />;
case "file":
return <FilePart {...part} />;
case "image":
return <ImagePart {...part} />;
case "data":
return part.dataRendererUI;
default:
return null;
}
}}
</MessagePrimitive.GroupedParts>
<RuntimeError />
<WebSearchActivity />
<AssistantMessageActions />
</MessagePrimitive.Root>
);
const HistorySelectContext = createContext<() => void>(() => undefined); const HistorySelectContext = createContext<() => void>(() => undefined);
const ThreadListItem = () => { const ThreadListItem = () => {
const t = useScopedI18n("common.assistant"); const t = useScopedI18n("common.assistant");
const aui = useAui();
const { openConfirmModal } = useConfirmModal();
const onSelect = useContext(HistorySelectContext); const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId); const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title); const title = useAuiState((state) => state.threadListItem.title);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [actionsOpened, setActionsOpened] = useState(false);
const [renameOpened, setRenameOpened] = useState(false);
const [renaming, setRenaming] = useState(false);
const [nextTitle, setNextTitle] = useState(title ?? "");
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renameOpened) renameInputRef.current?.focus();
}, [renameOpened]);
const exportConversation = async () => { const exportConversation = async () => {
if (!remoteId || exporting) return; if (!remoteId || exporting) return;
@@ -2034,109 +1977,23 @@ const ThreadListItem = () => {
} }
}; };
const renameConversation = async () => {
const trimmedTitle = nextTitle.trim();
if (!trimmedTitle || renaming) return;
setRenaming(true);
try {
await aui.threadListItem().rename(trimmedTitle);
setRenameOpened(false);
setActionsOpened(false);
} catch {
showErrorNotification({
title: t("renameConversation.failedTitle"),
message: t("renameConversation.failedDescription"),
});
} finally {
setRenaming(false);
}
};
const deleteConversation = () => {
openConfirmModal({
title: t("deleteConversation.title"),
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
confirmProps: { color: "red" },
labels: { confirm: t("delete"), cancel: t("cancel") },
onConfirm: async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
},
});
};
if (renameOpened) {
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<TextInput
ref={renameInputRef}
className={classes.historyRenameInput}
value={nextTitle}
onChange={(event) => setNextTitle(event.currentTarget.value)}
aria-label={t("renameConversation.label")}
maxLength={72}
size="xs"
onKeyDown={(event) => {
if (event.key === "Enter") void renameConversation();
if (event.key === "Escape" && !renaming) setRenameOpened(false);
}}
/>
<ActionIcon
variant="subtle"
color="green"
size="sm"
aria-label={t("renameConversation.save")}
disabled={!nextTitle.trim()}
loading={renaming}
loaderProps={{ type: "bars" }}
onClick={() => void renameConversation()}
>
<IconCheck size={15} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("cancel")}
disabled={renaming}
onClick={() => setRenameOpened(false)}
>
<IconX size={15} />
</ActionIcon>
</ThreadListItemPrimitive.Root>
);
}
return ( return (
<ThreadListItemPrimitive.Root className={classes.historyItem}> <ThreadListItemPrimitive.Root className={classes.historyItem}>
<ThreadListItemPrimitive.Trigger asChild> <Group gap={4} wrap="nowrap">
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}> <ThreadListItemPrimitive.Trigger asChild>
<Text component="span" size="sm" fw={500} truncate> <Button
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Text>
</UnstyledButton>
</ThreadListItemPrimitive.Trigger>
{actionsOpened && (
<Group className={classes.historyItemActions} gap={1} wrap="nowrap">
<ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size="sm" size="compact-sm"
title={t("renameConversation.action")} justify="flex-start"
aria-label={t("renameConversation.action")} flex={1}
onClick={() => { style={{ minWidth: 0 }}
setNextTitle(title ?? ""); onClick={onSelect}
setRenameOpened(true);
}}
> >
<IconPencil size={14} /> <ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</ActionIcon> </Button>
</ThreadListItemPrimitive.Trigger>
<Tooltip label={t("exportConversation.action")}>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
@@ -2144,49 +2001,24 @@ const ThreadListItem = () => {
loading={exporting} loading={exporting}
loaderProps={{ type: "bars" }} loaderProps={{ type: "bars" }}
disabled={!remoteId} disabled={!remoteId}
title={t("exportConversation.action")}
aria-label={t("exportConversation.action")} aria-label={t("exportConversation.action")}
onClick={() => void exportConversation()} onClick={() => void exportConversation()}
> >
<IconFileExport size={14} /> <IconFileExport size={14} />
</ActionIcon> </ActionIcon>
<ActionIcon </Tooltip>
variant="subtle" <ThreadListItemPrimitive.Delete asChild>
color="red" <ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
size="sm"
title={t("delete")}
aria-label={t("delete")}
onClick={deleteConversation}
>
<IconTrash size={14} /> <IconTrash size={14} />
</ActionIcon> </ActionIcon>
</Group> </ThreadListItemPrimitive.Delete>
)} </Group>
<ActionIcon
className={classes.historyItemMenu}
variant="subtle"
color="gray"
size="sm"
title={t("conversationActions")}
aria-label={t("conversationActions")}
aria-expanded={actionsOpened}
onClick={() => setActionsOpened((current) => !current)}
>
{actionsOpened ? <IconX size={15} /> : <IconDotsVertical size={15} />}
</ActionIcon>
</ThreadListItemPrimitive.Root> </ThreadListItemPrimitive.Root>
); );
}; };
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => { const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
const t = useScopedI18n("common.assistant"); const t = useScopedI18n("common.assistant");
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase();
const threadItems = useAuiState((state) => state.threads.threadItems);
const hasMatchingConversation = threadItems.some((item) => {
const itemTitle = item.title ?? t("newConversation");
return itemTitle.toLocaleLowerCase().includes(normalizedQuery);
});
return ( return (
<HistorySelectContext.Provider value={onSelect}> <HistorySelectContext.Provider value={onSelect}>
<Stack className={classes.historyMenu} gap="xs" p="xs"> <Stack className={classes.historyMenu} gap="xs" p="xs">
@@ -2203,28 +2035,9 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
{t("conversations")} {t("conversations")}
</Text> </Text>
</Group> </Group>
<TextInput <ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" offsetScrollbars>
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={15} />}
placeholder={t("searchConversations")}
aria-label={t("searchConversations")}
size="xs"
/>
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" scrollbars="y" offsetScrollbars>
<Stack gap={3}> <Stack gap={3}>
<ThreadListPrimitive.Items> <ThreadListPrimitive.Items components={{ ThreadListItem }} />
{({ threadListItem }) => {
const itemTitle = threadListItem.title ?? t("newConversation");
if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null;
return <ThreadListItem />;
}}
</ThreadListPrimitive.Items>
{normalizedQuery && !hasMatchingConversation && (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("noMatchingConversations")}
</Text>
)}
</Stack> </Stack>
</ScrollArea.Autosize> </ScrollArea.Autosize>
</Stack> </Stack>
@@ -2239,6 +2052,7 @@ const ConversationHistory = () => {
<Popover <Popover
opened={opened} opened={opened}
onChange={setOpened} onChange={setOpened}
onDismiss={() => setOpened(false)}
position="bottom-end" position="bottom-end"
width="min(22rem, calc(100vw - 1rem))" width="min(22rem, calc(100vw - 1rem))"
shadow="md" shadow="md"
@@ -2377,7 +2191,7 @@ const EmptyThread = () => {
</Text> </Text>
</Stack> </Stack>
<Box className={classes.suggestions}> <Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send={false} clearComposer asChild> <ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
<Button <Button
variant="default" variant="default"
className={classes.suggestion} className={classes.suggestion}
@@ -2386,17 +2200,17 @@ const EmptyThread = () => {
{t("suggestions.health.label")} {t("suggestions.health.label")}
</Button> </Button>
</ThreadPrimitive.Suggestion> </ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send={false} clearComposer asChild> <ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}> <Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
{t("suggestions.explore.label")} {t("suggestions.explore.label")}
</Button> </Button>
</ThreadPrimitive.Suggestion> </ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send={false} clearComposer asChild> <ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}> <Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
{t("suggestions.media.label")} {t("suggestions.media.label")}
</Button> </Button>
</ThreadPrimitive.Suggestion> </ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send={false} clearComposer asChild> <ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}> <Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
{t("suggestions.style.label")} {t("suggestions.style.label")}
</Button> </Button>
@@ -2978,6 +2792,14 @@ const Composer = (props: ComposerProps) => {
<HomarrProviderQuota /> <HomarrProviderQuota />
<ConversationContext /> <ConversationContext />
</Group> </Group>
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("mentions.hint")}
</Text>
<Text size="xs" c="dimmed">
{t("commands.hint")}
</Text>
</Group>
</Group> </Group>
</ComposerPrimitive.Root> </ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone> </ComposerPrimitive.AttachmentDropzone>
@@ -3157,30 +2979,9 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
pendingAction: AssistantPendingAction | undefined; pendingAction: AssistantPendingAction | undefined;
onExpand?: () => void; onExpand?: () => void;
onMinimize?: () => void; onMinimize?: () => void;
onDismiss?: () => void; onWindowClose?: () => void;
} }
const AssistantPanelIdentity = () => {
const t = useScopedI18n("common.assistant");
const threadTitle = useAuiState((state) => state.threadListItem.title);
return (
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon className={classes.panelIdentityIcon} variant="light" color="red" radius="xl" size="md">
<IconRobot size={16} />
</ThemeIcon>
<Box miw={0}>
<Text className={classes.panelIdentityTitle} size="sm" fw={700} truncate>
{t("title")}
</Text>
<Text className={classes.panelThreadTitle} size="xs" c="dimmed" truncate>
{threadTitle ?? t("newConversation")}
</Text>
</Box>
</Group>
);
};
export const AssistantConversationSurface = ({ export const AssistantConversationSurface = ({
isRunning, isRunning,
pendingAction, pendingAction,
@@ -3195,7 +2996,7 @@ export const AssistantConversationSurface = ({
onReasoningChange, onReasoningChange,
onExpand, onExpand,
onMinimize, onMinimize,
onDismiss, onWindowClose,
}: AssistantConversationSurfaceProps) => { }: AssistantConversationSurfaceProps) => {
const t = useScopedI18n("common.assistant"); const t = useScopedI18n("common.assistant");
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
@@ -3210,23 +3011,52 @@ export const AssistantConversationSurface = ({
return ( return (
<AssistantDirectiveEntitiesProvider> <AssistantDirectiveEntitiesProvider>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs"> <Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
<AssistantPanelIdentity /> {onWindowClose && onMinimize && (
<Group
component="fieldset"
className={classes.windowControls}
gap={4}
wrap="nowrap"
aria-label={t("windowControls")}
>
<Tooltip label={t("close")}>
<ActionIcon
className={classes.windowControl}
variant="subtle"
color="gray"
onClick={onWindowClose}
aria-label={t("close")}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("minimize")}>
<ActionIcon
className={classes.windowControl}
variant="subtle"
color="gray"
onClick={onMinimize}
aria-label={t("minimize")}
>
<IconMinus size={17} />
</ActionIcon>
</Tooltip>
</Group>
)}
<Group className={classes.panelActions} gap={2} wrap="nowrap"> <Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory /> <ConversationHistory />
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} /> <ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
<AutoApprovalControl /> <AutoApprovalControl />
<Tooltip label={t("newConversation")}> <Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild> <ThreadListPrimitive.New asChild>
<Button <ActionIcon
className={classes.newConversationButton} className={classes.panelAction}
variant="subtle" variant="subtle"
color="gray" color="gray"
size="compact-sm"
leftSection={<IconPlus size={17} />}
aria-label={t("newConversation")} aria-label={t("newConversation")}
> >
{t("newConversation")} <IconPlus size={17} />
</Button> </ActionIcon>
</ThreadListPrimitive.New> </ThreadListPrimitive.New>
</Tooltip> </Tooltip>
{onExpand && ( {onExpand && (
@@ -3242,32 +3072,6 @@ export const AssistantConversationSurface = ({
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
{onMinimize && (
<Tooltip label={t("minimize")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onMinimize}
aria-label={t("minimize")}
>
<IconMinus size={17} />
</ActionIcon>
</Tooltip>
)}
{onDismiss && (
<Tooltip label={t("close")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onDismiss}
aria-label={t("close")}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
)}
</Group> </Group>
</Group> </Group>
<AssistantQuestionPortalProvider target={questionPortalTarget}> <AssistantQuestionPortalProvider target={questionPortalTarget}>
@@ -3279,7 +3083,7 @@ export const AssistantConversationSurface = ({
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll> <ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
<Box className={classes.messages}> <Box className={classes.messages}>
<EmptyThread /> <EmptyThread />
<ThreadPrimitive.Messages components={assistantThreadMessageComponents} /> <ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box> </Box>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}> <SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild> <SelectionToolbarPrimitive.Quote asChild>
@@ -3326,7 +3130,6 @@ export const AssistantPanel = ({
onClose, onClose,
onDismissActivity, onDismissActivity,
activityDismissed, activityDismissed,
hasVisibleWidget,
isRunning, isRunning,
unreadCount, unreadCount,
latestAssistantText, latestAssistantText,
@@ -3344,18 +3147,8 @@ export const AssistantPanel = ({
onReasoningChange, onReasoningChange,
}: AssistantPanelProps) => { }: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant"); const t = useScopedI18n("common.assistant");
const aui = useAui();
const previousFocusRef = useRef<HTMLElement | null>(null); const previousFocusRef = useRef<HTMLElement | null>(null);
const panelRef = useRef<HTMLDialogElement>(null); const panelRef = useRef<HTMLDialogElement>(null);
const stopSpeechWithoutSurface = () => {
if (hasVisibleWidget) return;
const thread = aui.thread();
if (thread.getState().speech !== undefined) thread.stopSpeaking();
};
const minimize = () => {
stopSpeechWithoutSurface();
onClose();
};
useWindowEvent("keydown", (event) => { useWindowEvent("keydown", (event) => {
if ( if (
@@ -3367,7 +3160,7 @@ export const AssistantPanel = ({
) )
return; return;
event.preventDefault(); event.preventDefault();
minimize(); onClose();
}); });
useEffect(() => { useEffect(() => {
if (!opened) { if (!opened) {
@@ -3389,7 +3182,7 @@ export const AssistantPanel = ({
return ( return (
<> <>
{!opened && !hasVisibleWidget && ( {!opened && (
<AssistantActivityBar <AssistantActivityBar
onOpen={onOpen} onOpen={onOpen}
onDismissActivity={onDismissActivity} onDismissActivity={onDismissActivity}
@@ -3417,9 +3210,8 @@ export const AssistantPanel = ({
onModelChange={onModelChange} onModelChange={onModelChange}
onReasoningChange={onReasoningChange} onReasoningChange={onReasoningChange}
autoFocusComposer autoFocusComposer
onMinimize={minimize} onMinimize={onClose}
onDismiss={() => { onWindowClose={() => {
stopSpeechWithoutSurface();
onDismissActivity(); onDismissActivity();
onClose(); onClose();
}} }}
@@ -7,19 +7,11 @@ import type {
AttachmentAdapter, AttachmentAdapter,
MessageFormatAdapter, MessageFormatAdapter,
RemoteThreadListAdapter, RemoteThreadListAdapter,
SpeechSynthesisAdapter,
ThreadHistoryAdapter, ThreadHistoryAdapter,
ThreadMessage, ThreadMessage,
Toolkit, Toolkit,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react";
defineToolkit,
useAui,
useAuiEvent,
useAuiState,
useRemoteThreadListRuntime,
WebSpeechSynthesisAdapter,
} from "@assistant-ui/react";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk"; import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
@@ -49,8 +41,7 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences"; import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options"; import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
import { import {
AssistantComposerRuntimeProvider, AssistantComposerSurfaceProvider,
AssistantComposerSurfaceBoundary,
AssistantRunFocusPreserver, AssistantRunFocusPreserver,
AssistantRuntimeProviderWithTools, AssistantRuntimeProviderWithTools,
} from "./assistant-runtime-provider"; } from "./assistant-runtime-provider";
@@ -484,12 +475,6 @@ const AssistantThreadRuntime = () => {
), ),
[selectedModel], [selectedModel],
); );
const [speech, setSpeech] = useState<SpeechSynthesisAdapter>();
useEffect(() => {
if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") {
setSpeech(new WebSpeechSynthesisAdapter());
}
}, []);
const feedback = useMemo( const feedback = useMemo(
() => ({ () => ({
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => { submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
@@ -527,7 +512,6 @@ const AssistantThreadRuntime = () => {
history, history,
attachments, attachments,
feedback, feedback,
speech,
}, },
}); });
transport.setRuntime(runtime); transport.setRuntime(runtime);
@@ -683,7 +667,6 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [activityDismissed, setActivityDismissed] = useState(true); const [activityDismissed, setActivityDismissed] = useState(true);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [visibleWidgetIds, setVisibleWidgetIds] = useState<Set<string>>(() => new Set());
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null); const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
const aui = useAui(); const aui = useAui();
const preferences = useAssistantPreferences(); const preferences = useAssistantPreferences();
@@ -731,21 +714,6 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
setOpened(true); setOpened(true);
}, [markRead]); }, [markRead]);
const close = useCallback(() => setOpened(false), []); const close = useCallback(() => setOpened(false), []);
const setWidgetVisible = useCallback((widgetId: string, visible: boolean) => {
setVisibleWidgetIds((current) => {
if (current.has(widgetId) === visible) return current;
const next = new Set(current);
if (visible) next.add(widgetId);
else next.delete(widgetId);
return next;
});
}, []);
const activateWidget = useCallback((widgetId: string) => {
setVisibleWidgetIds((current) => {
if (!current.has(widgetId) || current.values().next().value === widgetId) return current;
return new Set([widgetId, ...Array.from(current).filter((id) => id !== widgetId)]);
});
}, []);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (opened) close(); if (opened) close();
else open(); else open();
@@ -867,7 +835,6 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]); }, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
const activeWidgetId = visibleWidgetIds.values().next().value ?? null;
const value = useMemo( const value = useMemo(
() => ({ () => ({
enabled: true, enabled: true,
@@ -876,67 +843,46 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
isRunning: assistantIsRunning, isRunning: assistantIsRunning,
isRefreshing, isRefreshing,
unreadCount, unreadCount,
hasVisibleWidget: visibleWidgetIds.size > 0,
activeWidgetId,
open, open,
close, close,
toggle, toggle,
sendPrompt, sendPrompt,
refreshCurrentView, refreshCurrentView,
setWidgetVisible,
activateWidget,
}), }),
[ [assistantIsRunning, close, isRefreshing, open, opened, refreshCurrentView, sendPrompt, toggle, unreadCount],
assistantIsRunning,
activateWidget,
activeWidgetId,
close,
isRefreshing,
open,
opened,
refreshCurrentView,
sendPrompt,
setWidgetVisible,
toggle,
unreadCount,
visibleWidgetIds.size,
],
); );
return ( return (
<AssistantAutoApprovalProvider conversationId={conversationId}> <AssistantAutoApprovalProvider conversationId={conversationId}>
<AssistantContext.Provider value={value}> <AssistantContext.Provider value={value}>
<AssistantComposerRuntimeProvider> <AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider> <AssistantComposerSurfaceProvider surfaceId="floating-panel">
<AssistantComposerSurfaceBoundary surfaceId="assistant-panel"> <AssistantPanel
<AssistantPanel opened={opened}
opened={opened} onOpen={open}
onOpen={open} onClose={close}
onClose={close} onDismissActivity={() => {
onDismissActivity={() => { markRead();
markRead(); setActivityDismissed(true);
setActivityDismissed(true); }}
}} activityDismissed={activityDismissed}
activityDismissed={activityDismissed} isRunning={assistantIsRunning}
hasVisibleWidget={visibleWidgetIds.size > 0} unreadCount={unreadCount}
isRunning={assistantIsRunning} latestAssistantText={latestAssistantText}
unreadCount={unreadCount} latestAssistantPartType={latestAssistantPartType}
latestAssistantText={latestAssistantText} latestUserText={queuedPrompt ?? latestUserText}
latestAssistantPartType={latestAssistantPartType} latestStatus={latestStatus}
latestUserText={queuedPrompt ?? latestUserText} pendingAction={pendingAction}
latestStatus={latestStatus} modelId={preferences.modelId}
pendingAction={pendingAction} models={preferences.models}
modelId={preferences.modelId} modelOptionsLoading={preferences.isLoading}
models={preferences.models} reasoning={preferences.reasoning}
modelOptionsLoading={preferences.isLoading} isRefreshing={isRefreshing}
reasoning={preferences.reasoning} onRefresh={refreshCurrentView}
isRefreshing={isRefreshing} onModelChange={selectModel}
onRefresh={refreshCurrentView} onReasoningChange={preferences.setReasoning}
onModelChange={selectModel} />
onReasoningChange={preferences.setReasoning} </AssistantComposerSurfaceProvider>
/>
</AssistantComposerSurfaceBoundary>
</AssistantComposerRuntimeProvider>
</AssistantContext.Provider> </AssistantContext.Provider>
</AssistantAutoApprovalProvider> </AssistantAutoApprovalProvider>
); );
@@ -26,8 +26,7 @@ import { z } from "zod/v4";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification } from "@homarr/notifications";
import { import {
AssistantComposerRuntimeProvider, AssistantComposerSurfaceProvider,
AssistantComposerSurfaceBoundary,
AssistantRunFocusPreserver, AssistantRunFocusPreserver,
AssistantRuntimeProviderWithTools, AssistantRuntimeProviderWithTools,
assistantSurfaceComposerCacheLimit, assistantSurfaceComposerCacheLimit,
@@ -149,22 +148,14 @@ const SurfaceTestAssistant = ({
{ runtime, toolkit }, { runtime, toolkit },
createElement(AssistantRunFocusPreserver), createElement(AssistantRunFocusPreserver),
createElement( createElement(
AssistantComposerRuntimeProvider, AssistantComposerSurfaceProvider,
null, { surfaceId: "surface-a" },
createElement( createElement(SurfaceProbe, { id: "a", onReady }),
AssistantComposerSurfaceBoundary,
{ surfaceId: "surface-a" },
createElement(SurfaceProbe, { id: "a", onReady }),
),
), ),
createElement( createElement(
AssistantComposerRuntimeProvider, AssistantComposerSurfaceProvider,
null, { surfaceId: "surface-b" },
createElement( createElement(SurfaceProbe, { id: "b", onReady }),
AssistantComposerSurfaceBoundary,
{ surfaceId: "surface-b" },
createElement(SurfaceProbe, { id: "b", onReady }),
),
), ),
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null, simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
); );
@@ -157,6 +157,9 @@ const createSurfaceComposerHost = (
get attachments() { get attachments() {
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined; return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
}, },
get dictation() {
return getThreadAdapters(threadCore)?.dictation;
},
}; };
return new Proxy(threadCore as ThreadCoreWithAdapters, { return new Proxy(threadCore as ThreadCoreWithAdapters, {
@@ -291,21 +294,18 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
subscribe(callback) { subscribe(callback) {
let activeCore = sharedBinding.getState(); let activeCore = sharedBinding.getState();
let activeConversationId = getConversationId(); let activeConversationId = getConversationId();
let activeComposer = getComposer(activeConversationId, activeCore);
retainConversation(activeConversationId); retainConversation(activeConversationId);
let unsubscribeComposer = activeComposer.subscribe(callback); let unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
const handleSharedUpdate = () => { const handleSharedUpdate = () => {
const nextCore = sharedBinding.getState(); const nextCore = sharedBinding.getState();
const nextConversationId = getConversationId(); const nextConversationId = getConversationId();
if (nextCore !== activeCore || nextConversationId !== activeConversationId) { if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
if (activeCore.speech !== undefined) activeCore.stopSpeaking();
unsubscribeComposer(); unsubscribeComposer();
releaseConversation(activeConversationId); releaseConversation(activeConversationId);
activeCore = nextCore; activeCore = nextCore;
activeConversationId = nextConversationId; activeConversationId = nextConversationId;
retainConversation(activeConversationId); retainConversation(activeConversationId);
activeComposer = getComposer(activeConversationId, activeCore); unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
unsubscribeComposer = activeComposer.subscribe(callback);
} }
callback(); callback();
}; };
@@ -360,13 +360,6 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
registerModelContextProvider: () => () => {}, registerModelContextProvider: () => () => {},
dispose() { dispose() {
activeConversationCounts.clear(); activeConversationCounts.clear();
const threadCores = new Set([
...Array.from(surfaceCoreByConversation.values(), (entry) => entry.source),
...Array.from(composerByConversation.values(), (entry) => entry.source),
]);
for (const threadCore of threadCores) {
if (threadCore.speech !== undefined) threadCore.stopSpeaking();
}
for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry); for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry);
}, },
}; };
@@ -380,7 +373,7 @@ const AssistantComposerSurfaceContext = createContext<AssistantComposerSurfaceVa
export const useAssistantComposerSurface = () => { export const useAssistantComposerSurface = () => {
const value = useContext(AssistantComposerSurfaceContext); const value = useContext(AssistantComposerSurfaceContext);
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceBoundary"); if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceProvider");
return value; return value;
}; };
@@ -444,23 +437,21 @@ const AssistantComposerSurfaceEvents = () => {
return null; return null;
}; };
export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => { const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
const t = useScopedI18n("common.assistant"); const t = useScopedI18n("common.assistant");
const aui = useAui(); const aui = useAui();
const instanceId = useId();
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
const elementRef = useRef<HTMLDivElement>(null); const elementRef = useRef<HTMLDivElement>(null);
const contextValue = useMemo(() => ({ id: resolvedSurfaceId }), [resolvedSurfaceId]); const contextValue = useMemo(() => ({ id: surfaceId }), [surfaceId]);
useLayoutEffect(() => { useLayoutEffect(() => {
const element = elementRef.current; const element = elementRef.current;
if (!element) return undefined; if (!element) return undefined;
surfaceElements.set(resolvedSurfaceId, element); surfaceElements.set(surfaceId, element);
return () => { return () => {
if (surfaceElements.get(resolvedSurfaceId) === element) surfaceElements.delete(resolvedSurfaceId); if (surfaceElements.get(surfaceId) === element) surfaceElements.delete(surfaceId);
}; };
}, [resolvedSurfaceId]); }, [surfaceId]);
const handlePasteCapture = (event: ClipboardEvent<HTMLDivElement>) => { const handlePasteCapture = (event: ClipboardEvent<HTMLDivElement>) => {
const target = event.target; const target = event.target;
@@ -516,6 +507,7 @@ export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsW
}} }}
onPasteCapture={handlePasteCapture} onPasteCapture={handlePasteCapture}
> >
<AssistantComposerSurfaceEvents />
{children} {children}
</div> </div>
</AssistantComposerSurfaceContext.Provider> </AssistantComposerSurfaceContext.Provider>
@@ -574,12 +566,18 @@ export const AssistantRunFocusPreserver = () => {
return null; return null;
}; };
interface AssistantComposerSurfaceProviderProps extends PropsWithChildren {
surfaceId: string;
}
/** /**
* Gives every rendered assistant surface one shared local composer while preserving the shared * Gives one rendered assistant surface a local composer while preserving the shared conversation.
* conversation. Individual surfaces should add AssistantComposerSurfaceBoundary around their UI. * Every component below this boundary can keep using the normal assistant-ui primitives and hooks.
*/ */
export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren) => { export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => {
const parentAui = useAui(); const parentAui = useAui();
const instanceId = useId();
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
// oxlint-disable-next-line no-underscore-dangle -- required to derive a scoped runtime without cloning the shared thread. // oxlint-disable-next-line no-underscore-dangle -- required to derive a scoped runtime without cloning the shared thread.
const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.(); const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.();
const surfaceRuntime = useMemo(() => { const surfaceRuntime = useMemo(() => {
@@ -590,8 +588,7 @@ export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren
return ( return (
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}> <AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
<AssistantComposerSurfaceEvents /> <AssistantComposerSurfaceBoundary surfaceId={resolvedSurfaceId}>{children}</AssistantComposerSurfaceBoundary>
{children}
</AssistantRuntimeProvider> </AssistantRuntimeProvider>
); );
}; };
@@ -1,9 +1,8 @@
"use client"; "use client";
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { useEffect, useId, useRef } from "react";
import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { IconArrowsMaximize, IconArrowsMinimize, IconRobot } from "@tabler/icons-react"; import { IconArrowsMaximize, IconRobot } from "@tabler/icons-react";
import { useAui, useAuiState } from "@assistant-ui/react"; import { useAui, useAuiState } from "@assistant-ui/react";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -14,47 +13,23 @@ import classes from "./assistant-panel.module.css";
import { AssistantConversationSurface } from "./assistant-panel"; import { AssistantConversationSurface } from "./assistant-panel";
import { getPendingAssistantAction } from "./assistant-pending-action"; import { getPendingAssistantAction } from "./assistant-pending-action";
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context"; import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
import { AssistantComposerSurfaceBoundary } from "./assistant-runtime-provider"; import { AssistantComposerSurfaceProvider } from "./assistant-runtime-provider";
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => { export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
const assistant = useHomarrAssistant(); const assistant = useHomarrAssistant();
const { enabled, setWidgetVisible } = assistant;
const generatedWidgetId = useId();
const widgetId = props.itemId ?? generatedWidgetId;
const widgetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const widget = widgetRef.current;
if (!enabled || !widget) return;
const observer = new IntersectionObserver(([entry]) => setWidgetVisible(widgetId, entry?.isIntersecting === true), {
threshold: 0.15,
});
observer.observe(widget);
return () => {
observer.disconnect();
setWidgetVisible(widgetId, false);
};
}, [enabled, setWidgetVisible, widgetId]);
if (!assistant.enabled) { if (!assistant.enabled) {
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />; return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
} }
return ( return (
<AssistantComposerSurfaceBoundary surfaceId={`assistant-widget-${widgetId}`}> <AssistantComposerSurfaceProvider surfaceId={`board-widget-${props.itemId ?? "preview"}`}>
<Box ref={widgetRef} w="100%" h="100%"> <EnabledAssistantBoardWidget {...props} />
<EnabledAssistantBoardWidget {...props} assistantWidgetId={widgetId} /> </AssistantComposerSurfaceProvider>
</Box>
</AssistantComposerSurfaceBoundary>
); );
}; };
const EnabledAssistantBoardWidget = ({ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
width,
height,
isEditMode,
assistantWidgetId,
}: WidgetComponentProps<"assistant"> & { assistantWidgetId: string }) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const assistant = useHomarrAssistant(); const assistant = useHomarrAssistant();
const preferences = useAssistantPreferences(); const preferences = useAssistantPreferences();
@@ -70,14 +45,6 @@ const EnabledAssistantBoardWidget = ({
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId }); threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
}; };
if (assistant.opened) {
return <CompactAssistantWidget onOpen={assistant.close} location="panel" />;
}
if (assistant.activeWidgetId !== null && assistant.activeWidgetId !== assistantWidgetId) {
return <CompactAssistantWidget onOpen={() => assistant.activateWidget(assistantWidgetId)} location="widget" />;
}
if (width < 300 || height < 280) { if (width < 300 || height < 280) {
return <CompactAssistantWidget onOpen={assistant.open} />; return <CompactAssistantWidget onOpen={assistant.open} />;
} }
@@ -108,30 +75,8 @@ const EnabledAssistantBoardWidget = ({
); );
}; };
const CompactAssistantWidget = ({ const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
onOpen,
location = "compact",
}: {
onOpen: () => void;
location?: "compact" | "panel" | "widget";
}) => {
const t = useI18n(); const t = useI18n();
let title = t("widget.assistant.compact.title");
let description = t("widget.assistant.compact.description");
let actionLabel = t("widget.assistant.compact.open");
let actionIcon = <IconArrowsMaximize size={18} />;
if (location === "panel") {
title = t("widget.assistant.compact.openTitle");
description = t("widget.assistant.compact.openDescription");
actionLabel = t("widget.assistant.compact.returnToWidget");
actionIcon = <IconArrowsMinimize size={18} />;
}
if (location === "widget") {
title = t("widget.assistant.compact.otherWidgetTitle");
description = t("widget.assistant.compact.otherWidgetDescription");
actionLabel = t("widget.assistant.compact.useHere");
}
return ( return (
<Box className={classes.widgetPanel}> <Box className={classes.widgetPanel}>
@@ -142,16 +87,23 @@ const CompactAssistantWidget = ({
</ThemeIcon> </ThemeIcon>
<Stack className={classes.compactCopy} gap={1}> <Stack className={classes.compactCopy} gap={1}>
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}> <Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
{title} {t("widget.assistant.compact.title")}
</Text> </Text>
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}> <Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
{description} {t("widget.assistant.compact.description")}
</Text> </Text>
</Stack> </Stack>
</Group> </Group>
<Tooltip label={actionLabel}> <Tooltip label={t("widget.assistant.compact.open")}>
<ActionIcon variant="light" color="red" size={44} radius="md" onClick={onOpen} aria-label={actionLabel}> <ActionIcon
{actionIcon} variant="light"
color="red"
size={44}
radius="md"
onClick={onOpen}
aria-label={t("widget.assistant.compact.open")}
>
<IconArrowsMaximize size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group> </Group>
@@ -1,123 +0,0 @@
/**
* Procedural UI sounds for Homarr using the Web Audio API.
* Zero audio files shipped. Pure runtime synthesis with oscillators, noise buffers, and gain envelopes.
*/
let audioContext: AudioContext | null = null;
const getAudioContext = (): AudioContext | null => {
if (typeof window === "undefined") return null;
try {
if (!audioContext) {
const AudioContextClass =
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (AudioContextClass) audioContext = new AudioContextClass();
}
if (audioContext?.state === "suspended") void audioContext.resume().catch(() => undefined);
return audioContext;
} catch {
return null;
}
};
/**
* Creates a white noise audio buffer for percussive textures
*/
const createNoiseBuffer = (ctx: AudioContext, durationSeconds = 0.2): AudioBuffer => {
const bufferSize = Math.floor(ctx.sampleRate * durationSeconds);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
};
/**
* Procedural trash / delete crunch sound.
* Synthesizes a crunchy bin/paper crumple noise burst and downward pitch thud.
*/
export const playTrashSound = (volume = 0.35) => {
const ctx = getAudioContext();
if (!ctx) return;
try {
const now = ctx.currentTime;
// Master Gain
const masterGain = ctx.createGain();
masterGain.gain.setValueAtTime(volume, now);
masterGain.connect(ctx.destination);
// 1. Downward sweep sub-oscillator (physical thud)
const osc = ctx.createOscillator();
const oscGain = ctx.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(260, now);
osc.frequency.exponentialRampToValueAtTime(35, now + 0.18);
oscGain.gain.setValueAtTime(0.7, now);
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
osc.connect(oscGain);
oscGain.connect(masterGain);
osc.start(now);
osc.stop(now + 0.2);
// 2. High-crunch noise burst (crush / crumple texture)
try {
const noiseBuffer = createNoiseBuffer(ctx, 0.22);
const noiseSource = ctx.createBufferSource();
noiseSource.buffer = noiseBuffer;
const noiseFilter = ctx.createBiquadFilter();
noiseFilter.type = "bandpass";
noiseFilter.frequency.setValueAtTime(1400, now);
noiseFilter.frequency.exponentialRampToValueAtTime(320, now + 0.2);
noiseFilter.Q.setValueAtTime(2.2, now);
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0.9, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
noiseSource.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(masterGain);
noiseSource.start(now);
noiseSource.stop(now + 0.22);
} catch {
// The noise layer is optional; retain the oscillator when buffers are constrained.
}
} catch {
// Sound feedback must never interrupt deleting board items.
}
};
/**
* Procedural select / pop sound.
*/
export const playPopSound = (volume = 0.25) => {
const ctx = getAudioContext();
if (!ctx) return;
try {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(460, now);
osc.frequency.exponentialRampToValueAtTime(780, now + 0.05);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.07);
} catch {
// Sound feedback must never interrupt selecting board items.
}
};
@@ -11,19 +11,6 @@
border-color: var(--border-color) !important; border-color: var(--border-color) !important;
} }
.settingsButton {
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
:global([data-type="item"]:hover) .settingsButton,
:global([data-type="item"]:focus-within) .settingsButton,
.settingsButton[data-menu-open="true"] {
opacity: 1;
pointer-events: auto;
}
.editModeInertContent { .editModeInertContent {
display: contents; display: contents;
} }
@@ -52,9 +39,3 @@
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
@media (prefers-reduced-motion: reduce) {
.settingsButton {
transition: none;
}
}
@@ -14,7 +14,6 @@ import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-ite
import { useSectionContext } from "../sections/section-context"; import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal"; import { useOpenItemMoveModal } from "./item-move-modal";
import itemContentClasses from "./item-content.module.css";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps { interface BoardItemMenuProps {
@@ -106,11 +105,9 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
size={24} size={24}
radius="sm" radius="sm"
pos="absolute" pos="absolute"
top={4} top={12}
right={4} right={8}
style={{ zIndex: 30 }} style={{ zIndex: 30 }}
className={itemContentClasses.settingsButton}
data-menu-open={isMenuOpen || undefined}
data-board-widget-settings data-board-widget-settings
aria-label={tItem("menu.label.settingsFor", { name: label })} aria-label={tItem("menu.label.settingsFor", { name: label })}
> >
@@ -1,7 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { IntegrationKind } from "@homarr/definitions";
import { import {
getWidgetConnectionStatus, getWidgetConnectionStatus,
resolveMatchingIntegrationsAsync, resolveMatchingIntegrationsAsync,
@@ -10,246 +8,56 @@ import {
} from "./item-select-data"; } from "./item-select-data";
describe("getWidgetConnectionStatus", () => { describe("getWidgetConnectionStatus", () => {
const availableKinds = new Set<IntegrationKind>(["sonarr", "plex"]); const availableKinds = new Set(["sonarr" as const]);
describe("ready status", () => { it("reports widgets with a configured compatible integration as ready", () => {
it("returns 'ready' when at least one supported integration is in availableKinds", () => { expect(
expect( getWidgetConnectionStatus({
getWidgetConnectionStatus({ supportedIntegrations: ["sonarr", "radarr"],
supportedIntegrations: ["sonarr", "radarr"], availableKinds,
availableKinds, connectionOptional: false,
connectionOptional: false, }),
}), ).toBe("ready");
).toBe("ready");
});
it("returns 'ready' when all supported integrations are in availableKinds", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "plex"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
});
it("returns 'ready' even if connectionOptional is true as long as a matching integration exists", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr"],
availableKinds,
connectionOptional: true,
}),
).toBe("ready");
});
}); });
describe("needsSetup status", () => { it("reports required integrations that are not configured as needing setup", () => {
it("returns 'needsSetup' when supported integrations are non-empty, none are available, and connectionOptional is false", () => { expect(
expect( getWidgetConnectionStatus({
getWidgetConnectionStatus({ supportedIntegrations: ["radarr"],
supportedIntegrations: ["radarr", "sabNzbd"], availableKinds,
availableKinds, connectionOptional: false,
connectionOptional: false, }),
}), ).toBe("needsSetup");
).toBe("needsSetup");
});
it("returns 'needsSetup' when availableKinds is empty and connection is required", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr"],
availableKinds: new Set<IntegrationKind>(),
connectionOptional: false,
}),
).toBe("needsSetup");
});
}); });
describe("noConnectionRequired status", () => { it.each([
it("returns 'noConnectionRequired' when supportedIntegrations is empty regardless of connectionOptional", () => { { supportedIntegrations: [], connectionOptional: false },
expect( { supportedIntegrations: ["radarr" as const], connectionOptional: true },
getWidgetConnectionStatus({ ])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => {
supportedIntegrations: [], expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe(
availableKinds, "noConnectionRequired",
connectionOptional: false, );
}),
).toBe("noConnectionRequired");
expect(
getWidgetConnectionStatus({
supportedIntegrations: [],
availableKinds: new Set(),
connectionOptional: true,
}),
).toBe("noConnectionRequired");
});
it("returns 'noConnectionRequired' when connectionOptional is true and no supported integrations are available", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr", "jellyfin"],
availableKinds,
connectionOptional: true,
}),
).toBe("noConnectionRequired");
});
it("returns 'noConnectionRequired' when connectionOptional is true and availableKinds is empty", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr"],
availableKinds: new Set(),
connectionOptional: true,
}),
).toBe("noConnectionRequired");
});
}); });
}); });
describe("selection concurrency locks", () => { describe("widget selection lock", () => {
it("acquires lock on unlocked ref and sets current to true", () => { it("rejects repeated selection synchronously until the active selection finishes", () => {
const lock = { current: false };
const acquired = tryLockSelection(lock);
expect(acquired).toBe(true);
expect(lock.current).toBe(true);
});
it("rejects lock acquisition on an already locked ref", () => {
const lock = { current: true };
const acquired = tryLockSelection(lock);
expect(acquired).toBe(false);
expect(lock.current).toBe(true);
});
it("unlocks lock and allows subsequent acquisition", () => {
const lock = { current: false }; const lock = { current: false };
expect(tryLockSelection(lock)).toBe(true); expect(tryLockSelection(lock)).toBe(true);
expect(tryLockSelection(lock)).toBe(false); expect(tryLockSelection(lock)).toBe(false);
unlockSelection(lock); unlockSelection(lock);
expect(lock.current).toBe(false);
expect(tryLockSelection(lock)).toBe(true); expect(tryLockSelection(lock)).toBe(true);
}); });
it("handles repeated unlock safely without throwing", () => {
const lock = { current: false };
expect(() => {
unlockSelection(lock);
unlockSelection(lock);
}).not.toThrow();
expect(lock.current).toBe(false);
});
it("prevents multiple concurrent callers from proceeding simultaneously", () => {
const lock = { current: false };
const results: boolean[] = [];
// Simulate 5 simultaneous selection attempts
for (let i = 0; i < 5; i++) {
results.push(tryLockSelection(lock));
}
expect(results).toEqual([true, false, false, false, false]);
});
}); });
describe("resolveMatchingIntegrationsAsync", () => { describe("resolveMatchingIntegrationsAsync", () => {
it("returns empty array immediately when widget does not support integrations without calling ensureDataAsync", async () => { it("waits for the shared integration query before a cold integration-backed selection", async () => {
const ensureDataAsync = vi.fn(async () => [{ id: "1", kind: "sonarr" as IntegrationKind }]); const ensureDataAsync = vi.fn(async () => [
{ id: "matching", kind: "sonarr" as const },
const result = await resolveMatchingIntegrationsAsync({ { id: "other", kind: "radarr" as const },
hasIntegrationSupport: false, ]);
supportedIntegrations: ["sonarr"],
currentData: undefined,
ensureDataAsync,
});
expect(result).toEqual([]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
it("uses currentData synchronously when available without calling ensureDataAsync", async () => {
const currentData = [
{ id: "1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
{ id: "2", kind: "radarr" as IntegrationKind, permissions: { hasUseAccess: true } },
{ id: "3", kind: "plex" as IntegrationKind, permissions: { hasUseAccess: true } },
];
const ensureDataAsync = vi.fn();
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr", "radarr"],
currentData,
ensureDataAsync,
});
expect(result).toEqual([currentData[0], currentData[1]]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
it("calls ensureDataAsync when currentData is undefined and filters by supported kinds", async () => {
const fetchedData = [
{ id: "1", kind: "sonarr" as IntegrationKind },
{ id: "2", kind: "radarr" as IntegrationKind },
{ id: "3", kind: "sabNzbd" as IntegrationKind },
];
const ensureDataAsync = vi.fn(async () => fetchedData);
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["radarr", "sabNzbd"],
currentData: undefined,
ensureDataAsync,
});
expect(result).toEqual([fetchedData[1], fetchedData[2]]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
it("excludes integrations where permissions.hasUseAccess is explicitly false", async () => {
const currentData = [
{ id: "allowed-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
{ id: "blocked-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: false } },
{ id: "allowed-no-perms", kind: "sonarr" as IntegrationKind },
];
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData,
ensureDataAsync: vi.fn(),
});
expect(result).toEqual([currentData[0], currentData[2]]);
});
it("returns empty array when none of the fetched integrations match supported kinds", async () => {
const fetchedData = [
{ id: "1", kind: "plex" as IntegrationKind },
{ id: "2", kind: "jellyfin" as IntegrationKind },
];
const ensureDataAsync = vi.fn(async () => fetchedData);
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr", "radarr"],
currentData: undefined,
ensureDataAsync,
});
expect(result).toEqual([]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
it("propagates error when ensureDataAsync rejects", async () => {
const ensureDataAsync = vi.fn(async () => {
throw new Error("Network timeout");
});
await expect( await expect(
resolveMatchingIntegrationsAsync({ resolveMatchingIntegrationsAsync({
@@ -258,6 +66,37 @@ describe("resolveMatchingIntegrationsAsync", () => {
currentData: undefined, currentData: undefined,
ensureDataAsync, ensureDataAsync,
}), }),
).rejects.toThrow("Network timeout"); ).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
it("does not wait for integrations when the widget has no integration support", async () => {
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: false,
supportedIntegrations: [],
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
it("excludes matching integrations the user cannot use", async () => {
const currentData = [
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
];
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData,
ensureDataAsync: vi.fn(),
}),
).resolves.toEqual([currentData[0]]);
}); });
}); });
@@ -0,0 +1,20 @@
.action {
opacity: 0;
transition: opacity 150ms ease;
}
.card:hover .action,
.card:focus-within .action {
opacity: 1;
}
@media (hover: none), (pointer: coarse) {
.action {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.action {
transition: none;
}
}
@@ -1,211 +0,0 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { ConnectionStatusBadge, SupportedIntegrations, WidgetItem } from "./item-select-modal";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => {
const translations: Record<string, string> = {
"item.create.connectionStatus.ready": "Ready",
"item.create.connectionStatus.needsSetup": "Needs setup",
"item.create.connectionStatus.noConnectionRequired": "No connection required",
"item.create.standalone": "Standalone",
"widget.mediaServer.name": "Media Server",
"widget.mediaServer.description": "Stream your media",
"widget.clock.name": "Clock",
"widget.clock.description": "Displays current time",
};
return translations[key] ?? key;
},
useScopedI18n: () => (key: string) => key,
}));
vi.mock("@homarr/definitions", async () => {
const actual = await vi.importActual<Record<string, unknown>>("@homarr/definitions");
return {
...actual,
getIntegrationName: (kind: string) => kind.charAt(0).toUpperCase() + kind.slice(1),
};
});
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("Advanced Add App - Modal Subcomponents", () => {
describe("ConnectionStatusBadge", () => {
it("renders 'Ready' badge with teal color when status is 'ready'", async () => {
await act(async () =>
root.render(
<MantineProvider>
<ConnectionStatusBadge status="ready" />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Ready");
const badge = host.querySelector("span, div");
expect(badge).not.toBeNull();
});
it("renders 'Needs setup' badge with orange color when status is 'needsSetup'", async () => {
await act(async () =>
root.render(
<MantineProvider>
<ConnectionStatusBadge status="needsSetup" />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Needs setup");
});
it("renders null when status is 'noConnectionRequired'", async () => {
await act(async () =>
root.render(
<MantineProvider>
<ConnectionStatusBadge status="noConnectionRequired" />
</MantineProvider>,
),
);
expect(host.querySelector("[class*='Badge-root']")).toBeNull();
});
});
describe("SupportedIntegrations", () => {
it("renders up to 5 avatars for supported integrations", async () => {
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex"];
await act(async () =>
root.render(
<MantineProvider>
<SupportedIntegrations integrations={integrations} />
</MantineProvider>,
),
);
// Avatars should be rendered for the 3 items
expect(host.querySelectorAll("img, svg, div").length).toBeGreaterThan(0);
expect(host.textContent).not.toContain("+");
});
it("renders overflow indicator when 6 or more integrations are supported", async () => {
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby", "deluge"];
await act(async () =>
root.render(
<MantineProvider>
<SupportedIntegrations integrations={integrations} />
</MantineProvider>,
),
);
// 6 integrations total: 4 shown, +2 in badge
expect(host.textContent).toContain("+2");
});
it("renders Standalone label when integrations list is empty", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SupportedIntegrations integrations={[]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Standalone");
});
});
describe("WidgetItem", () => {
const FakeIcon = () => <span data-testid="widget-icon">Icon</span>;
it("renders widget card with icon, title, description, and connection status", async () => {
const onSelect = vi.fn();
const item = {
kind: "mediaServer" as WidgetKind,
name: "Media Server",
description: "Stream your media collection effortlessly",
icon: FakeIcon as never,
supportedIntegrations: ["plex" as IntegrationKind, "jellyfin" as IntegrationKind],
};
await act(async () =>
root.render(
<MantineProvider>
<WidgetItem item={item} disabled={false} loading={false} connectionStatus="ready" onSelect={onSelect} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button).not.toBeNull();
expect(host.textContent).toContain("Media Server");
expect(host.textContent).toContain("Stream your media collection effortlessly");
expect(host.textContent).toContain("Ready");
expect(host.querySelector("[data-testid='widget-icon']")).not.toBeNull();
await act(async () => {
button?.click();
});
expect(onSelect).toHaveBeenCalledOnce();
});
it("does not mark widget cards as selected", async () => {
const item = {
kind: "clock" as WidgetKind,
name: "Clock",
description: "Displays current time",
icon: FakeIcon as never,
supportedIntegrations: [] as IntegrationKind[],
};
await act(async () =>
root.render(
<MantineProvider>
<WidgetItem
item={item}
disabled={false}
loading={false}
connectionStatus="noConnectionRequired"
onSelect={vi.fn()}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.hasAttribute("data-selected")).toBe(false);
});
});
});
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
Avatar, Avatar,
Badge, Badge,
@@ -6,18 +6,16 @@ import {
Button, Button,
Center, Center,
Divider, Divider,
Flex,
Group, Group,
Image, Image,
Input,
Select, Select,
SimpleGrid,
Stack, Stack,
Text, Text,
ThemeIcon,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IconApi, IconBuildingStore, IconSearch } from "@tabler/icons-react"; import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -25,8 +23,8 @@ import { useSession } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { import {
getBoardLaneColumnCount,
getIconUrl, getIconUrl,
getBoardLaneColumnCount,
getIntegrationName, getIntegrationName,
getRootSectionLane, getRootSectionLane,
widgetIntegrationSupport, widgetIntegrationSupport,
@@ -38,14 +36,14 @@ import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { SelectableCard } from "@homarr/ui"; import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import type { WidgetConnectionStatus } from "./item-select-data"; import type { WidgetConnectionStatus } from "./item-select-data";
import { import {
@@ -66,16 +64,6 @@ interface ItemSelectModalContentProps {
initialSearch?: string; initialSearch?: string;
} }
export interface WidgetItemData {
kind: WidgetKind;
supportedIntegrations: IntegrationKind[];
icon: TablerIcon;
name: string;
description: string;
}
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
const ItemSelectModalContent = ({ const ItemSelectModalContent = ({
actions, actions,
integrationData, integrationData,
@@ -120,14 +108,15 @@ const ItemSelectModalContent = ({
[board.sections, currentLayout, mainCanvasSection, t], [board.sections, currentLayout, mainCanvasSection, t],
); );
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null); const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
? targetSectionId
: (placementOptions[0]?.value ?? null);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null); const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false); const selectionLock = useRef(false);
const { createItem, removeItem } = useItemActions(); const { createItem, removeItem } = useItemActions();
useEffect(() => {
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
setTargetSectionId(placementOptions[0]?.value ?? null);
}, [placementOptions, targetSectionId]);
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal); const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal); const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const settings = useSettings(); const settings = useSettings();
@@ -139,7 +128,7 @@ const ItemSelectModalContent = ({
[integrationData], [integrationData],
); );
const items = useMemo<WidgetItemData[]>( const items = useMemo(
() => () =>
widgetKinds widgetKinds
.filter((kind) => kind !== "customApi") .filter((kind) => kind !== "customApi")
@@ -199,7 +188,7 @@ const ItemSelectModalContent = ({
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ?? createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
createdItem.layouts[0]?.sectionId; createdItem.layouts[0]?.sectionId;
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label; const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination; const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
const notificationId = `board-item-created:${itemId}`; const notificationId = `board-item-created:${itemId}`;
showSuccessNotification({ showSuccessNotification({
id: notificationId, id: notificationId,
@@ -258,7 +247,7 @@ const ItemSelectModalContent = ({
kind: "customApi", kind: "customApi",
integrationIds: [], integrationIds: [],
options: configuredOptions, options: configuredOptions,
targetSectionId: effectiveSectionId ?? undefined, targetSectionId: targetSectionId ?? undefined,
advancedOptions, advancedOptions,
}); });
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name); notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
@@ -321,7 +310,7 @@ const ItemSelectModalContent = ({
kind, kind,
options, options,
integrationIds: newIntegrationIds, integrationIds: newIntegrationIds,
targetSectionId: effectiveSectionId ?? undefined, targetSectionId: targetSectionId ?? undefined,
advancedOptions, advancedOptions,
}); });
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return; if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
@@ -388,233 +377,125 @@ const ItemSelectModalContent = ({
}; };
return ( return (
<Stack gap="md"> <Flex gap="md" align="stretch">
{/* Top Search & Destination Selector */} <Box style={{ flex: "1 1 0%", minWidth: 0 }}>
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm"> <SelectGridLayout
<Box style={{ flex: "1 1 240px", minWidth: 200 }}> search={search}
<Input onSearchChange={setSearch}
value={search} placeholder={`${t("item.create.search")}...`}
onChange={(event) => setSearch(event.currentTarget.value)} ariaLabel={t("item.create.search")}
leftSection={<IconSearch size={16} />} onSearchKeyDown={(event) => {
placeholder={`${t("item.create.search")}...`} if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
aria-label={t("item.create.search")} const [item] = filteredItems;
data-autofocus if (item) void handleAdd(item.kind);
onKeyDown={(event) => { }
if ( }}
event.key === "Enter" && >
loadingSelection === null && {placementOptions.length > 1 && (
filteredItems.length === 1 && <Box style={{ gridColumn: "1 / -1" }}>
filteredItems[0] <Select
) { label={t("item.create.destination.label")}
void handleAdd(filteredItems[0].kind); description={t("item.create.destination.description")}
} data={placementOptions}
}} value={targetSectionId}
/> onChange={setTargetSectionId}
</Box> allowDeselect={false}
{placementOptions.length > 1 && (
<Box style={{ flex: "0 1 280px", minWidth: 200 }}>
<Select
label={t("item.create.destination.label")}
data={placementOptions}
value={effectiveSectionId}
onChange={setTargetSectionId}
allowDeselect={false}
size="xs"
/>
</Box>
)}
</Group>
{/* Grid of Widgets */}
<SimpleGrid cols={selectGridCols} spacing="sm">
{filteredItems.map((item) => (
<WidgetItem
key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onFocus={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
onPointerEnter={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
connectionStatus={getWidgetConnectionStatus({
supportedIntegrations: item.supportedIntegrations,
availableKinds,
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
})}
/>
))}
{isAdmin && (
<>
<Divider
label={
<Group gap="xs">
<IconApi size={14} />
<Text size="xs" fw={600}>
{t("customWidget.page.list.title")}
</Text>
</Group>
}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<SelectableCard
key={def.id}
onClick={() => void handleAddCustomWidget(def)}
aria-label={def.name}
icon={
def.iconUrl ? (
<Image src={def.iconUrl} w={24} h={24} fit="contain" style={{ flexShrink: 0 }} />
) : (
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
<IconApi size={20} />
</ThemeIcon>
)
}
title={def.name}
description={def.description ?? ""}
footerLeft={
<Text size="xs" c="dimmed">
{t("item.create.customWidget")}
</Text>
}
/> />
))}
<Box mt="xs" style={{ gridColumn: "1 / -1" }}>
<Button
component="a"
href="/manage/custom-widgets/workshop"
target="_blank"
rel="noopener noreferrer"
variant="default"
fullWidth
leftSection={<IconBuildingStore size={16} />}
>
{t("workshop.browseWorkshop")}
</Button>
</Box> </Box>
</>
)}
</SimpleGrid>
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</Stack>
);
};
// =========================================================================
// WidgetItem: SelectableCard with Inset, Title, Status Badge, and Footer
// =========================================================================
export const WidgetItem = ({
item,
onSelect,
onFocus,
onPointerEnter,
disabled,
loading,
connectionStatus,
}: {
item: WidgetItemData;
onSelect: () => void;
onFocus?: () => void;
onPointerEnter?: () => void;
disabled: boolean;
loading: boolean;
connectionStatus: WidgetConnectionStatus;
}) => {
return (
<SelectableCard
disabled={disabled}
loading={loading}
onClick={onSelect}
onFocus={onFocus}
onPointerEnter={onPointerEnter}
aria-label={item.name}
icon={
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
<item.icon size={20} />
</ThemeIcon>
}
title={item.name}
topRight={<ConnectionStatusBadge status={connectionStatus} />}
description={item.description}
footerLeft={<SupportedIntegrations integrations={item.supportedIntegrations} />}
/>
);
};
export const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
const t = useI18n();
if (status === "noConnectionRequired") return null;
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
return (
<Badge variant="dot" color={color} size="xs" tt="none" w="fit-content" maw="100%">
{t(`item.create.connectionStatus.${status}`)}
</Badge>
);
};
export const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
const t = useI18n();
if (integrations.length === 0) {
return (
<Text size="xs" c="dimmed" fs="italic">
{t("item.create.standalone")}
</Text>
);
}
const countToShow = integrations.length >= 6 ? 4 : 5;
const moreCount = integrations.length - countToShow;
return (
<Group gap={2}>
<Tooltip.Group closeDelay={100}>
<Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => (
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
</Tooltip>
))}
{moreCount > 0 && (
<Tooltip
withArrow
label={
<Stack gap={2}>
{integrations.slice(countToShow).map((integration) => (
<Text key={integration} size="xs">
{getIntegrationName(integration)}
</Text>
))}
</Stack>
}
>
<Avatar radius="xl" size="xs">
+{moreCount}
</Avatar>
</Tooltip>
)} )}
</Group> {filteredItems.map((item) => (
</Tooltip.Group> <WidgetItem
</Group> key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onFocus={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
onPointerEnter={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
connectionStatus={getWidgetConnectionStatus({
supportedIntegrations: item.supportedIntegrations,
availableKinds,
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
})}
/>
))}
{isAdmin && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<CatalogItem
key={def.id}
height={selectGridCardHeight}
label={def.name}
status={t("item.create.connectionStatus.noConnectionRequired")}
disabled={loadingSelection !== null}
busy={loadingSelection === `custom:${def.id}`}
onSelect={() => void handleAddCustomWidget(def)}
onFocus={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
}}
onPointerEnter={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
}}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
<ConnectionStatusBadge status="noConnectionRequired" />
</Stack>
</CatalogItem>
))}
<Box style={{ gridColumn: "1 / -1" }}>
{/* Opens in a new tab so unsaved board changes survive the detour. */}
<Button
component="a"
href="/manage/custom-widgets/workshop"
target="_blank"
rel="noopener noreferrer"
variant="default"
fullWidth
leftSection={<IconBuildingStore size={16} />}
>
{t("workshop.browseWorkshop")}
</Button>
</Box>
</>
)}
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
</Box>
</Flex>
); );
}; };
const ItemSelectModalFrame = ({ const ItemSelectModalFrame = ({
actions, actions,
innerProps, innerProps,
@@ -660,3 +541,109 @@ export const ItemSelectModal = createModal<{
defaultTitle: (t) => t("item.create.title"), defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect, size: modalSizeSelect,
}); });
const WidgetItem = ({
item,
onSelect,
onFocus,
onPointerEnter,
disabled,
loading,
connectionStatus,
}: {
item: {
kind: WidgetKind;
supportedIntegrations: IntegrationKind[];
name: string;
description: string;
icon: TablerIcon;
};
onSelect: () => void;
onFocus: () => void;
onPointerEnter: () => void;
disabled: boolean;
loading: boolean;
connectionStatus: WidgetConnectionStatus;
}) => {
const t = useI18n();
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
return (
<CatalogItem
height={selectGridCardHeight}
label={item.name}
status={statusLabel}
onFocus={onFocus}
onPointerEnter={onPointerEnter}
onSelect={onSelect}
disabled={disabled}
busy={loading}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{item.name}
</Text>
</Group>
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
</Tooltip>
<ConnectionStatusBadge status={connectionStatus} />
<SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
</CatalogItem>
);
};
const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
const t = useI18n();
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
return (
<Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
{t(`item.create.connectionStatus.${status}`)}
</Badge>
);
};
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
if (integrations.length === 0) {
return null;
}
const countToShow = integrations.length >= 8 ? 6 : 7;
const moreCount = integrations.length - countToShow;
return (
<Group gap={2}>
<Tooltip.Group closeDelay={100}>
<Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => (
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
</Tooltip>
))}
{moreCount > 0 && (
<Tooltip
withArrow
label={
<>
{integrations.slice(countToShow).map((integration) => (
<div key={integration}>{getIntegrationName(integration)}</div>
))}
</>
}
>
<Avatar radius="xl" size="xs">
+{moreCount}
</Avatar>
</Tooltip>
)}
</Group>
</Tooltip.Group>
</Group>
);
};
@@ -1,19 +1,16 @@
"use client"; "use client";
import { Suspense, use, useCallback, useMemo, useState } from "react"; import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core"; import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import type { FallbackProps } from "react-error-boundary"; import type { FallbackProps } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals"; import { createModal, modalSizeForm } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals"; import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps } from "@homarr/widgets/modals"; import type { WidgetEditModalProps } from "@homarr/widgets/modals";
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form"; import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form"; import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
@@ -83,46 +80,13 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal()); const { WidgetEditModal } = use(loadWidgetEditModal());
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const Component = WidgetEditModal.component; const Component = WidgetEditModal.component;
const utils = clientApi.useUtils();
const [createdIntegrations, setCreatedIntegrations] = useState<IntegrationSelectOption[]>([]);
const supportedKinds = useMemo(() => {
if (!("supportedIntegrations" in props.innerProps.definition)) return [];
return (props.innerProps.definition.supportedIntegrations ?? []).filter((k) => k !== "mock");
}, [props.innerProps.definition]);
const handleOpenNewIntegration = useCallback(
(onCreated?: (id: string) => void) => {
openIntegrationModal({
allowedKinds: supportedKinds,
onSuccess: (result) => {
if (result?.integration) {
setCreatedIntegrations((prev) => [...prev, result.integration]);
void utils.integration.all.invalidate();
onCreated?.(result.integration.id);
}
},
});
},
[openIntegrationModal, supportedKinds, utils],
);
const combinedIntegrationData = useMemo(() => {
const existing = props.innerProps.integrationData;
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
return [...existing, ...additional];
}, [props.innerProps.integrationData, createdIntegrations]);
return ( return (
<Component <Component
{...props} {...props}
innerProps={{ innerProps={{
...props.innerProps, ...props.innerProps,
integrationData: combinedIntegrationData,
integrationEditForm: LazyIntegrationEditForm, integrationEditForm: LazyIntegrationEditForm,
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
}} }}
/> />
); );
@@ -158,5 +122,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
return t("item.edit.title"); return t("item.edit.title");
}, },
size: modalSizeForm, size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false, closeOnClickOutside: false,
}); });
@@ -17,7 +17,6 @@ import {
} from "~/components/board/layout"; } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context"; import { useSectionContext } from "../section-context";
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd"; import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
import type { GridTransaction } from "./dnd"; import type { GridTransaction } from "./dnd";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime"; import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
@@ -49,16 +48,6 @@ export const FixedGridItem = ({
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext(); const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
const { commitSectionGrid } = useGridLayoutActions(); const { commitSectionGrid } = useGridLayoutActions();
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false); const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
const { isSelected, toggleSelectItem } = useBoardSelection();
const selected = item.type === "item" && isSelected(item.id);
const handleClickCapture = (event: React.MouseEvent) => {
if (!isEditorActive || item.type !== "item" || (!event.metaKey && !event.ctrlKey)) return;
event.preventDefault();
event.stopPropagation();
toggleSelectItem(item.id);
};
const instructionsId = useId(); const instructionsId = useId();
const isEditorActive = isEditMode && runtimeStatus === "ready"; const isEditorActive = isEditMode && runtimeStatus === "ready";
const placement = toPlacement(item); const placement = toPlacement(item);
@@ -189,8 +178,6 @@ export const FixedGridItem = ({
return ( return (
<Box <Box
{...semantics} {...semantics}
onClickCapture={handleClickCapture}
data-board-item-selected={selected ? "true" : undefined}
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)} className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)} style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
data-grid-item-id={isEditorActive ? undefined : item.id} data-grid-item-id={isEditorActive ? undefined : item.id}
@@ -200,7 +187,6 @@ export const FixedGridItem = ({
data-kind={item.kind} data-kind={item.kind}
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined} data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
aria-describedby={isEditorActive ? instructionsId : undefined} aria-describedby={isEditorActive ? instructionsId : undefined}
aria-selected={isEditorActive && item.type === "item" ? selected : undefined}
aria-keyshortcuts={ aria-keyshortcuts={
isEditorActive isEditorActive
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown" ? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
@@ -32,27 +32,6 @@
opacity: 0.42; opacity: 0.42;
} }
.board-grid-entry[data-board-item-selected="true"] {
z-index: 15;
opacity: 0.56;
filter: saturate(0.72) brightness(0.82);
transition:
opacity 140ms ease,
filter 140ms ease;
}
.board-grid-entry[data-board-item-selected="true"]::after {
position: absolute;
inset: 0;
z-index: 100;
content: "";
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
border: 0.125rem dashed var(--mantine-primary-color-filled);
border-radius: var(--board-item-radius, var(--mantine-radius-md));
box-shadow: inset 0 0 1.125rem color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
pointer-events: none;
}
.board-grid-drag-overlay[data-dnd-dragging] { .board-grid-drag-overlay[data-dnd-dragging] {
position: fixed !important; position: fixed !important;
z-index: 10000; z-index: 10000;
@@ -198,115 +177,138 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-resize-handle { .board-grid-resize-handle {
position: absolute; position: absolute;
z-index: 25; z-index: 20;
display: grid; display: block;
padding: 0; padding: 0;
color: var(--mantine-color-dimmed);
appearance: none; appearance: none;
background: var(--mantine-color-body); background: transparent;
border: 0.0625rem solid var(--mantine-color-default-border); border: 0;
box-shadow: var(--mantine-shadow-xs);
opacity: 0;
scale: 0.88;
place-items: center;
touch-action: none; touch-action: none;
transition:
opacity 120ms ease,
scale 160ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 120ms ease;
} }
.board-grid-resize-handle::before { body[data-board-grid-interacting] .board-grid-resize-handle {
position: absolute;
inset: -0.375rem;
content: "";
}
.board-grid-resize-handle > svg {
pointer-events: none; pointer-events: none;
} }
.board-grid-entry:has(> .board-grid-resize-handle:hover) > .board-grid-resize-handle, body[data-board-grid-interacting] .board-grid-resize-handle::after {
.board-grid-entry:has(> .board-grid-resize-handle:active) > .board-grid-resize-handle { opacity: 0;
border-color: var(--mantine-color-dimmed); }
.board-grid-resize-handle::after {
position: absolute;
inset: 50% auto auto 50%;
width: 12px;
height: 3px;
content: "";
background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
border-radius: var(--mantine-radius-xl);
opacity: 0;
transform: translate(-50%, -50%);
transition: opacity 100ms ease;
}
.board-grid-entry:hover > .board-grid-resize-handle::after,
.board-grid-resize-handle:focus-visible::after {
opacity: 1; opacity: 1;
scale: 1; }
.board-grid-resize-handle:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -8px;
} }
.board-grid-resize-handle[data-grid-resize-handle="n"], .board-grid-resize-handle[data-grid-resize-handle="n"],
.board-grid-resize-handle[data-grid-resize-handle="s"] { .board-grid-resize-handle[data-grid-resize-handle="s"] {
left: 50%; right: 44px;
width: 1.75rem; left: 44px;
height: 1rem; height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
border-radius: var(--mantine-radius-xl);
translate: -50% 0;
cursor: ns-resize; cursor: ns-resize;
} }
.board-grid-resize-handle[data-grid-resize-handle="n"] { .board-grid-resize-handle[data-grid-resize-handle="n"] {
top: -0.25rem; top: 0;
} }
.board-grid-resize-handle[data-grid-resize-handle="s"] { .board-grid-resize-handle[data-grid-resize-handle="s"] {
bottom: -0.25rem; bottom: 0;
} }
.board-grid-resize-handle[data-grid-resize-handle="e"], .board-grid-resize-handle[data-grid-resize-handle="e"],
.board-grid-resize-handle[data-grid-resize-handle="w"] { .board-grid-resize-handle[data-grid-resize-handle="w"] {
top: 50%; top: 44px;
width: 1rem; bottom: 44px;
height: 1.75rem; width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
border-radius: var(--mantine-radius-xl);
translate: 0 -50%;
cursor: ew-resize; cursor: ew-resize;
} }
.board-grid-resize-handle[data-grid-resize-handle="e"] { .board-grid-resize-handle[data-grid-resize-handle="e"] {
right: -0.25rem; right: 0;
} }
.board-grid-resize-handle[data-grid-resize-handle="w"] { .board-grid-resize-handle[data-grid-resize-handle="w"] {
left: -0.25rem; left: 0;
}
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
width: 3px;
height: 12px;
} }
.board-grid-resize-handle[data-grid-resize-handle="ne"], .board-grid-resize-handle[data-grid-resize-handle="ne"],
.board-grid-resize-handle[data-grid-resize-handle="se"], .board-grid-resize-handle[data-grid-resize-handle="se"],
.board-grid-resize-handle[data-grid-resize-handle="sw"], .board-grid-resize-handle[data-grid-resize-handle="sw"],
.board-grid-resize-handle[data-grid-resize-handle="nw"] { .board-grid-resize-handle[data-grid-resize-handle="nw"] {
width: 1.875rem; z-index: 9;
height: 1.875rem; width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
border-radius: var(--board-item-radius, var(--mantine-radius-md)); height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
} }
.board-grid-resize-handle[data-grid-resize-handle="ne"] { .board-grid-resize-handle[data-grid-resize-handle="ne"] {
top: -0.625rem; top: 0;
right: -0.625rem; right: 0;
cursor: nesw-resize; cursor: nesw-resize;
} }
.board-grid-resize-handle[data-grid-resize-handle="se"] { .board-grid-resize-handle[data-grid-resize-handle="se"] {
right: -0.625rem; right: 0;
bottom: -0.625rem; bottom: 0;
cursor: nwse-resize; cursor: nwse-resize;
} }
.board-grid-resize-handle[data-grid-resize-handle="sw"] { .board-grid-resize-handle[data-grid-resize-handle="sw"] {
bottom: -0.625rem; bottom: 0;
left: -0.625rem; left: 0;
cursor: nesw-resize; cursor: nesw-resize;
} }
.board-grid-resize-handle[data-grid-resize-handle="nw"] { .board-grid-resize-handle[data-grid-resize-handle="nw"] {
top: -0.625rem; top: 0;
left: -0.625rem; left: 0;
cursor: nwse-resize; cursor: nwse-resize;
} }
.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
transform: translate(-50%, -50%) rotate(45deg);
}
@media (hover: none), (pointer: coarse) {
.board-grid-resize-handle::after {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.board-grid-entry, .board-grid-entry,
.board-grid-placeholder, .board-grid-placeholder,
.board-grid-resize-outline, .board-grid-resize-outline,
.board-grid-resize-handle { .board-grid-resize-handle::after {
transition: none; transition: none;
} }
} }
@@ -32,7 +32,6 @@ import type {
DragStartEvent, DragStartEvent,
} from "@dnd-kit/react"; } from "@dnd-kit/react";
import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react"; import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
import { IconGripHorizontal, IconGripVertical } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -62,7 +61,6 @@ import { useBoardGridPortalHost } from "./grid-portal-host";
import type { GridInteraction } from "./grid-preview-layer"; import type { GridInteraction } from "./grid-preview-layer";
import { GridPreviewLayer } from "./grid-preview-layer"; import { GridPreviewLayer } from "./grid-preview-layer";
import { createGridResizeOutlineController } from "./grid-resize-outline"; import { createGridResizeOutlineController } from "./grid-resize-outline";
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions"; import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions"; import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -848,9 +846,6 @@ const DndGridEntryComponent = ({
}: DndGridEntryProps) => { }: DndGridEntryProps) => {
const { acquireContainer, structureRevision } = useBoardGridPortalHost(); const { acquireContainer, structureRevision } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions(); const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const { isSelected } = useBoardSelection();
const board = useRequiredBoard();
const isSelectedEntry = placement.type === "item" && isSelected(placement.id);
const mountRef = useRef<HTMLDivElement>(null); const mountRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null); const shellRef = useRef<HTMLDivElement>(null);
const placementRef = useRef(placement); const placementRef = useRef(placement);
@@ -926,12 +921,7 @@ const DndGridEntryComponent = ({
<div <div
ref={setShellRef} ref={setShellRef}
className="board-grid-entry" className="board-grid-entry"
style={ style={getLogicalItemStyle(placement) as CSSProperties}
{
...getLogicalItemStyle(placement),
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-grid-item-id={placement.id} data-grid-item-id={placement.id}
data-grid-item-type={placement.type} data-grid-item-type={placement.type}
data-grid-x={placement.x} data-grid-x={placement.x}
@@ -940,7 +930,6 @@ const DndGridEntryComponent = ({
data-grid-h={placement.h} data-grid-h={placement.h}
data-dnd-drag-source={isDragSource ? "true" : undefined} data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined} data-dnd-dropping={isDropping ? "true" : undefined}
data-board-item-selected={isSelectedEntry ? "true" : undefined}
> >
<div ref={mountRef} className="board-grid-content-mount" /> <div ref={mountRef} className="board-grid-content-mount" />
<GridResizeHandles <GridResizeHandles
@@ -1113,27 +1102,19 @@ const GridResizeHandles = ({
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled); finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
}; };
return RESIZE_DIRECTIONS.map((direction) => { return RESIZE_DIRECTIONS.map((direction) => (
const isHorizontalSide = direction === "n" || direction === "s"; <span
const isVerticalSide = direction === "e" || direction === "w"; key={direction}
const GripIcon = isHorizontalSide ? IconGripHorizontal : IconGripVertical; className="board-grid-resize-handle"
data-grid-resize-handle={direction}
return ( aria-hidden="true"
<span onPointerDown={(event) => handlePointerDown(event, direction)}
key={direction} onPointerMove={handlePointerMove}
className="board-grid-resize-handle" onPointerUp={(event) => handlePointerEnd(event, false)}
data-grid-resize-handle={direction} onPointerCancel={(event) => handlePointerEnd(event, true)}
aria-hidden="true" onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
onPointerDown={(event) => handlePointerDown(event, direction)} />
onPointerMove={handlePointerMove} ));
onPointerUp={(event) => handlePointerEnd(event, false)}
onPointerCancel={(event) => handlePointerEnd(event, true)}
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
>
<GripIcon size={isHorizontalSide || isVerticalSide ? 13 : 15} stroke={2.25} />
</span>
);
});
}; };
const getProjectedDragShape = ( const getProjectedDragShape = (
@@ -1258,7 +1239,6 @@ const getPreferredRegisteredGridTarget = (
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => { const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
if (document.body.hasAttribute("data-board-grid-interacting")) return true; if (document.body.hasAttribute("data-board-grid-interacting")) return true;
if (event.metaKey || event.ctrlKey) return true;
const target = event.target; const target = event.target;
const activationElement = source.handle ?? source.element; const activationElement = source.handle ?? source.element;
if (!(target instanceof Element) || target === activationElement) return false; if (!(target instanceof Element) || target === activationElement) return false;
@@ -10,8 +10,6 @@ import { FixedGridItem } from "../fixed-grid-item";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
announce: vi.fn(), announce: vi.fn(),
commitSectionGrid: vi.fn(), commitSectionGrid: vi.fn(),
isSelected: vi.fn(() => false),
toggleSelectItem: vi.fn(),
editMode: true, editMode: true,
maxRowCount: null as number | null, maxRowCount: null as number | null,
items: [ items: [
@@ -46,13 +44,6 @@ vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [mocks.editMode], useEditMode: () => [mocks.editMode],
})); }));
vi.mock("~/components/board/selection/board-selection-context", () => ({
useBoardSelection: () => ({
isSelected: mocks.isSelected,
toggleSelectItem: mocks.toggleSelectItem,
}),
}));
vi.mock("@homarr/boards/context", () => ({ vi.mock("@homarr/boards/context", () => ({
useCurrentLayout: () => "layout", useCurrentLayout: () => "layout",
})); }));
@@ -1,270 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Button, Group, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { usePersistBoard } from "@homarr/boards/updater";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
import { playPopSound, playTrashSound } from "../audio/board-sounds";
interface BoardSelectionContextValue {
selectedItemIds: Set<string>;
toggleSelectItem: (id: string) => void;
clearSelection: () => void;
isSelected: (id: string) => boolean;
removeSelectedItems: () => void;
moveSelectedItemsToSection: (sectionId: string) => void;
}
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { updateAndPersistBoard } = usePersistBoard(board);
const t = useI18n();
const tSelection = useScopedI18n("item.selection");
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
title: tSelection("saveErrorTitle"),
message: tSelection("saveErrorMessage"),
});
},
});
},
[tSelection, updateAndPersistBoard],
);
// Clear selection if leaving edit mode
useEffect(() => {
if (!isEditMode) {
setSelectedItemIds(new Set());
}
}, [isEditMode]);
// Clear selection on Escape key
useEffect(() => {
if (selectedItemIds.size === 0) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setSelectedItemIds(new Set());
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedItemIds.size]);
const toggleSelectItem = useCallback((id: string) => {
setSelectedItemIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
playPopSound();
}, []);
const clearSelection = useCallback(() => {
setSelectedItemIds(new Set());
}, []);
const isSelected = useCallback((id: string) => selectedItemIds.has(id), [selectedItemIds]);
const removeSelectedItems = useCallback(() => {
if (selectedItemIds.size === 0) return;
const idsToRemove = Array.from(selectedItemIds);
const count = idsToRemove.length;
const removedItems = board.items.filter((item) => idsToRemove.includes(item.id));
playTrashSound();
clearSelection();
persistBoard((previous) => ({
...previous,
items: previous.items.filter((item) => !idsToRemove.includes(item.id)),
}));
const notificationId = `batch-remove-${Date.now()}`;
showSuccessNotification({
id: notificationId,
title: t("common.success"),
message: (
<Group justify="space-between" wrap="nowrap">
<Text size="sm">{tSelection("removed", { count })}</Text>
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
persistBoard((previous) => ({
...previous,
items: [...previous.items, ...removedItems],
}));
notifications.hide(notificationId);
}}
>
{t("common.action.undo")}
</Button>
</Group>
),
autoClose: 10_000,
});
}, [board.items, clearSelection, persistBoard, selectedItemIds, t, tSelection]);
const moveSelectedItemsToSection = useCallback(
(targetSectionId: string) => {
if (selectedItemIds.size === 0) return;
const idsToMove = new Set(selectedItemIds);
let didMoveAll = false;
persistBoard((previous) => {
const targetSection = previous.sections.find((section) => section.id === targetSectionId);
const boardLayout = previous.layouts.find((layout) => layout.id === currentLayoutId);
if (!targetSection || !boardLayout) return previous;
const containerLayout =
targetSection.kind === "container"
? targetSection.layouts.find((layout) => layout.layoutId === currentLayoutId)
: undefined;
const columnCount =
targetSection.kind === "container"
? (containerLayout?.width ?? 0)
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
const rowCount = containerLayout?.height ?? 9999;
if (columnCount === 0 || rowCount === 0) return previous;
const existingContainers = previous.sections
.filter((section): section is ContainerSection => section.kind === "container")
.flatMap((section) => {
const layout = section.layouts.find(
(candidate) => candidate.layoutId === currentLayoutId && candidate.parentSectionId === targetSection.id,
);
return layout
? [
{
id: section.id,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
},
]
: [];
});
const existingItems = previous.items.flatMap((item) => {
const layout = item.layouts.find(
(candidate) => candidate.layoutId === currentLayoutId && candidate.sectionId === targetSection.id,
);
return layout
? [
{
id: item.id,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
},
]
: [];
});
const occupiedPositions = [...existingContainers, ...existingItems];
const movedLayouts = new Map<string, ItemLayout>();
for (const item of previous.items.filter((candidate) => idsToMove.has(candidate.id))) {
const currentItemLayout = item.layouts.find((layout) => layout.layoutId === currentLayoutId);
if (!currentItemLayout) continue;
const previousPositionIndex = occupiedPositions.findIndex((position) => position.id === item.id);
const previousPosition =
previousPositionIndex >= 0 ? occupiedPositions.splice(previousPositionIndex, 1).at(0) : undefined;
const { width, height } = currentItemLayout;
const emptyPosition =
width <= columnCount && height <= rowCount
? getFirstEmptyPosition(occupiedPositions, columnCount, rowCount, { width, height })
: undefined;
if (!emptyPosition) {
if (previousPosition) occupiedPositions.push(previousPosition);
continue;
}
const nextLayout: ItemLayout = {
...currentItemLayout,
sectionId: targetSectionId,
xOffset: emptyPosition.xOffset,
yOffset: emptyPosition.yOffset,
};
movedLayouts.set(item.id, nextLayout);
occupiedPositions.push({ id: item.id, ...emptyPosition, width, height });
}
if (movedLayouts.size !== idsToMove.size) return previous;
didMoveAll = true;
return {
...previous,
items: previous.items.map((item) => {
const movedLayout = movedLayouts.get(item.id);
if (!movedLayout) return item;
return {
...item,
layouts: item.layouts.map((layout) => (layout.layoutId === currentLayoutId ? movedLayout : layout)),
};
}),
};
});
if (didMoveAll) {
clearSelection();
} else {
showErrorNotification({
title: tSelection("moveUnavailableTitle"),
message: tSelection("moveUnavailableMessage"),
});
}
},
[clearSelection, currentLayoutId, persistBoard, selectedItemIds, tSelection],
);
const value = useMemo(
() => ({
selectedItemIds,
toggleSelectItem,
clearSelection,
isSelected,
removeSelectedItems,
moveSelectedItemsToSection,
}),
[clearSelection, isSelected, moveSelectedItemsToSection, removeSelectedItems, selectedItemIds, toggleSelectItem],
);
return <BoardSelectionContext.Provider value={value}>{children}</BoardSelectionContext.Provider>;
};
export const useBoardSelection = () => {
const context = useContext(BoardSelectionContext);
if (!context) {
throw new Error("useBoardSelection must be used within a BoardSelectionProvider");
}
return context;
};
@@ -1,255 +0,0 @@
"use client";
import {
ActionIcon,
Avatar,
Button,
Divider,
Group,
HoverCard,
Menu,
Paper,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useBoardSelection } from "./board-selection-context";
const MAX_AVATARS_DISPLAYED = 5;
export const BoardSelectionToolbar = () => {
const [isEditMode] = useEditMode();
const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const t = useI18n();
const tSelection = useScopedI18n("item.selection");
const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id));
const hasAppWidgets = selectedItems.some((item) => item.kind === "app");
const { data: selectableApps } = clientApi.app.selectable.useQuery(undefined, {
enabled: hasAppWidgets,
});
if (!isEditMode || selectedItemIds.size === 0) return null;
const appMap = new Map((selectableApps ?? []).map((app) => [app.id, app]));
// Sort items so apps appear first
const sortedSelectedItems = selectedItems.toSorted((itemA, itemB) => {
if (itemA.kind === "app" && itemB.kind !== "app") return -1;
if (itemA.kind !== "app" && itemB.kind === "app") return 1;
return 0;
});
const getItemDetails = (item: (typeof selectedItems)[number]) => {
let displayName = item.advancedOptions.title?.trim();
let iconUrl: string | undefined = undefined;
const IconComponent = widgetCatalogIcons[item.kind] ?? IconCheck;
if (item.kind === "app") {
const appId = (item.options as { appId?: string })?.appId;
const app = appId ? appMap.get(appId) : undefined;
if (!displayName) {
displayName = app?.name || t("widget.app.name");
}
if (app?.iconUrl) {
iconUrl = app.iconUrl;
}
}
if (!displayName) {
displayName = t(`widget.${item.kind}.name`);
}
return { displayName, iconUrl, IconComponent, kind: item.kind };
};
const visibleItems = sortedSelectedItems.slice(0, MAX_AVATARS_DISPLAYED);
const overflowCount = sortedSelectedItems.length - visibleItems.length;
const mainCanvasSection = board.sections
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
const placementOptions = [
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
...board.sections.flatMap((section) => {
if (section.kind !== "empty") return [];
const lane = getRootSectionLane(section.xOffset);
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
return [
{
value: section.id,
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
},
];
}),
...board.sections
.filter((section) => section.kind === "container")
.map((section) => ({
value: section.id,
label: section.options.title.trim() || t("section.container.untitled"),
})),
];
return (
<Paper
withBorder
shadow="xl"
radius="xl"
p="xs"
bg="light-dark(var(--mantine-color-body), var(--mantine-color-dark-7))"
style={{
position: "fixed",
bottom: 28,
left: "50%",
transform: "translateX(-50%)",
zIndex: 1000,
width: "max-content",
maxWidth: "calc(100vw - 1rem)",
boxShadow: "0 12px 40px rgba(0, 0, 0, 0.28)",
backdropFilter: "blur(14px)",
border: "1px solid var(--mantine-color-default-border)",
animation: "slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1) forwards",
}}
>
<Group gap="xs" wrap="nowrap">
{/* Avatar Group with HoverCard Popover showing all items */}
<HoverCard position="top" withArrow shadow="xl" radius="md" openDelay={100} closeDelay={200} withinPortal>
<HoverCard.Target>
<UnstyledButton
display="inline-flex"
aria-label={tSelection("summary", { count: sortedSelectedItems.length })}
style={{ alignItems: "center", borderRadius: "var(--mantine-radius-xl)" }}
>
<Avatar.Group>
{visibleItems.map((item) => {
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
return (
<Avatar
key={item.id}
src={iconUrl}
radius="xl"
size={32}
color="primaryColor"
variant="light"
styles={{ image: { objectFit: "contain", padding: 2 } }}
alt={displayName}
>
{!iconUrl && <IconComponent size={16} />}
</Avatar>
);
})}
{overflowCount > 0 && (
<Avatar radius="xl" size={32} color="gray" variant="filled">
+{overflowCount}
</Avatar>
)}
</Avatar.Group>
</UnstyledButton>
</HoverCard.Target>
<HoverCard.Dropdown p={6} style={{ width: 220 }}>
<Stack gap={4}>
<Text size="10px" fw={700} c="dimmed" tt="uppercase" px={4}>
{tSelection("selected", { count: sortedSelectedItems.length })}
</Text>
<ScrollArea.Autosize mah={176} type="auto">
<Stack gap={2}>
{sortedSelectedItems.map((item) => {
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
return (
<Group key={item.id} gap={6} wrap="nowrap" px={4} py={2}>
<Avatar
src={iconUrl}
radius="sm"
size={22}
color="primaryColor"
variant="light"
styles={{ image: { objectFit: "contain", padding: 2 } }}
alt={displayName}
>
{!iconUrl && <IconComponent size={12} />}
</Avatar>
<Text size="xs" fw={500} truncate>
{displayName}
</Text>
</Group>
);
})}
</Stack>
</ScrollArea.Autosize>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
<Text size="xs" c="dimmed" visibleFrom="sm" maw={220} lh={1.25}>
{t("app.action.select.multiSelectTip")}
</Text>
<Divider orientation="vertical" />
{/* Actions */}
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{placementOptions.length > 1 && (
<Menu position="top" withArrow shadow="md">
<Menu.Target>
<Button size="xs" variant="light" color="primaryColor" leftSection={<IconFolderShare size={15} />}>
{tSelection("move")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
{placementOptions.map((opt) => (
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
{opt.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={15} />}
onClick={removeSelectedItems}
>
{tSelection("delete")}
</Button>
<Tooltip label={tSelection("clearShortcut")} withArrow>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={clearSelection}
aria-label={tSelection("clear")}
>
<IconX size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
};
@@ -1,251 +0,0 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { LayoutMockBuilder } from "~/components/board/items/actions/test/mocks/layout-mock";
import { BoardSelectionProvider, useBoardSelection } from "./board-selection-context";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => key,
useScopedI18n: () => (key: string) => key,
}));
vi.mock("../audio/board-sounds", () => ({
playPopSound: vi.fn(),
playTrashSound: vi.fn(),
}));
let currentBoardState: Board;
let mockUpdateBoard: (updater: (prev: Board) => Board) => void;
vi.mock("@homarr/boards/context", () => ({
useRequiredBoard: () => currentBoardState,
useCurrentLayout: () => "layout-1",
}));
vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [true, vi.fn()],
}));
vi.mock("@homarr/boards/updater", () => ({
usePersistBoard: () => ({
updateAndPersistBoard: (updater: (prev: Board) => Board) => {
mockUpdateBoard(updater);
},
}),
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
host.remove();
});
const SelectionConsumer = ({ onReady }: { onReady: (ctx: ReturnType<typeof useBoardSelection>) => void }) => {
const selection = useBoardSelection();
onReady(selection);
return <div>Selection Ready</div>;
};
describe("BoardSelectionContext", () => {
it("toggles item selection and clears on clearSelection", async () => {
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
const item1 = new ItemMockBuilder({ id: "item-1" }).addLayout({ layoutId: "layout-1" }).build();
currentBoardState = new BoardMockBuilder().addLayout(layout).addItem(item1).build();
mockUpdateBoard = vi.fn();
let selectionContext!: ReturnType<typeof useBoardSelection>;
await act(async () => {
root.render(
<MantineProvider>
<BoardSelectionProvider>
<SelectionConsumer
onReady={(ctx) => {
selectionContext = ctx;
}}
/>
</BoardSelectionProvider>
</MantineProvider>,
);
});
expect(selectionContext.isSelected("item-1")).toBe(false);
await act(async () => {
selectionContext.toggleSelectItem("item-1");
});
expect(selectionContext.isSelected("item-1")).toBe(true);
await act(async () => {
selectionContext.clearSelection();
});
expect(selectionContext.isSelected("item-1")).toBe(false);
});
it("moves selected items to the first available positions in a container section", async () => {
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
const container = new ContainerSectionMockBuilder({ id: "container-1" })
.addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 4, height: 4 })
.build();
// Existing item inside the container at (0, 0) with 2x2
const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
.addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 2, height: 2 })
.build();
// Two items to move from emptyRoot
const itemA = new ItemMockBuilder({ id: "item-a" })
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 5, yOffset: 5, width: 2, height: 1 })
.build();
const itemB = new ItemMockBuilder({ id: "item-b" })
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 8, yOffset: 8, width: 2, height: 1 })
.build();
currentBoardState = new BoardMockBuilder()
.addLayout(layout)
.addSection(emptyRoot)
.addSection(container)
.addItem(existingContainerItem)
.addItem(itemA)
.addItem(itemB)
.build();
let updatedBoard: Board = currentBoardState;
mockUpdateBoard = vi.fn((updater) => {
updatedBoard = updater(currentBoardState);
currentBoardState = updatedBoard;
});
let selectionContext!: ReturnType<typeof useBoardSelection>;
await act(async () => {
root.render(
<MantineProvider>
<BoardSelectionProvider>
<SelectionConsumer
onReady={(ctx) => {
selectionContext = ctx;
}}
/>
</BoardSelectionProvider>
</MantineProvider>,
);
});
await act(async () => {
selectionContext.toggleSelectItem("item-a");
selectionContext.toggleSelectItem("item-b");
});
await act(async () => {
selectionContext.moveSelectedItemsToSection("container-1");
});
const movedA = updatedBoard.items.find((i) => i.id === "item-a")?.layouts.find((l) => l.layoutId === "layout-1");
const movedB = updatedBoard.items.find((i) => i.id === "item-b")?.layouts.find((l) => l.layoutId === "layout-1");
expect(movedA?.sectionId).toBe("container-1");
expect(movedB?.sectionId).toBe("container-1");
// Container width is 4. Existing item at (0, 0) occupies (0,0)-(1,1).
// itemA (2x1) is placed at first empty spot: (2, 0)
expect(movedA?.xOffset).toBe(2);
expect(movedA?.yOffset).toBe(0);
// itemB (2x1) is placed at next first empty spot: (2, 1)
expect(movedB?.xOffset).toBe(2);
expect(movedB?.yOffset).toBe(1);
});
it("keeps the entire selection in place when the destination cannot fit every item", async () => {
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
const container = new ContainerSectionMockBuilder({ id: "container-1" })
.addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 2, height: 1 })
.build();
const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
.addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 1, height: 1 })
.build();
const itemA = new ItemMockBuilder({ id: "item-a" })
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 0, yOffset: 0, width: 1, height: 1 })
.build();
const itemB = new ItemMockBuilder({ id: "item-b" })
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 1, yOffset: 0, width: 1, height: 1 })
.build();
currentBoardState = new BoardMockBuilder()
.addLayout(layout)
.addSection(emptyRoot)
.addSection(container)
.addItem(existingContainerItem)
.addItem(itemA)
.addItem(itemB)
.build();
let updatedBoard = currentBoardState;
mockUpdateBoard = vi.fn((updater) => {
updatedBoard = updater(currentBoardState);
currentBoardState = updatedBoard;
});
let selectionContext!: ReturnType<typeof useBoardSelection>;
await act(async () => {
root.render(
<MantineProvider>
<BoardSelectionProvider>
<SelectionConsumer
onReady={(ctx) => {
selectionContext = ctx;
}}
/>
</BoardSelectionProvider>
</MantineProvider>,
);
});
await act(async () => {
selectionContext.toggleSelectItem("item-a");
selectionContext.toggleSelectItem("item-b");
});
await act(async () => {
selectionContext.moveSelectedItemsToSection("container-1");
});
expect(updatedBoard.items.find((item) => item.id === "item-a")?.layouts[0]?.sectionId).toBe("empty-root");
expect(updatedBoard.items.find((item) => item.id === "item-b")?.layouts[0]?.sectionId).toBe("empty-root");
expect(selectionContext.isSelected("item-a")).toBe(true);
expect(selectionContext.isSelected("item-b")).toBe(true);
});
});
@@ -37,7 +37,7 @@ export const useSetupAnalytics = () => {
onError() { onError() {
showErrorNotification({ showErrorNotification({
title: t("common.error"), title: t("common.error"),
message: t("common.analyticsError"), message: t("universalCreate.analyticsError"),
}); });
}, },
}); });
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import type { RankedUniversalCreateAction } from "./universal-create-actions";
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
describe("getUniversalCreateActionDefinitions", () => {
test("only exposes actions allowed without a board context", () => {
const actions = getUniversalCreateActionDefinitions({
hasBoardContext: false,
permissions: ["board-create", "integration-create"],
});
expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
});
test("places globally-created apps in the library group", () => {
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
});
test("keeps board content actions available without global create permissions", () => {
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
});
test("exposes the complete surface to administrators on a board", () => {
const actions = getUniversalCreateActionDefinitions({
hasBoardContext: true,
permissions: ["admin", "app-create", "integration-create", "board-create"],
});
expect(actions.map(({ key }) => key)).toEqual([
"widget",
"app",
"integration",
"container",
"board",
"workshop",
"customWidget",
]);
});
});
describe("filterAndRankUniversalCreateActions", () => {
const actions = [
{ key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
{
key: "integration",
group: "library",
priority: 80,
name: "Connect service",
description: "Create an integration",
keywords: ["provider"],
},
{ key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
] satisfies RankedUniversalCreateAction[];
test("uses product priority when no search is entered", () => {
expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
"widget",
"integration",
"board",
]);
});
test("ranks a name prefix ahead of a description or keyword match", () => {
const results = filterAndRankUniversalCreateActions(actions, "create");
expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
});
test("matches keywords and removes unrelated actions", () => {
expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
});
});
@@ -0,0 +1,120 @@
import type { GroupPermissionKey } from "@homarr/definitions";
export const universalCreateActionKeys = [
"widget",
"app",
"integration",
"container",
"board",
"workshop",
"customWidget",
] as const;
export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
export interface UniversalCreateContext {
hasBoardContext: boolean;
permissions: readonly GroupPermissionKey[];
}
interface UniversalCreateActionDefinition {
key: UniversalCreateActionKey;
group: UniversalCreateActionGroup;
priority: number;
isVisible: (context: UniversalCreateContext) => boolean;
}
const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
context.permissions.includes(permission);
const definitions: readonly UniversalCreateActionDefinition[] = [
{
key: "widget",
group: "currentBoard",
priority: 100,
isVisible: ({ hasBoardContext }) => hasBoardContext,
},
{
key: "app",
group: "currentBoard",
priority: 90,
isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
},
{
key: "integration",
group: "library",
priority: 80,
isVisible: (context) => hasPermission(context, "integration-create"),
},
{
key: "container",
group: "currentBoard",
priority: 70,
isVisible: ({ hasBoardContext }) => hasBoardContext,
},
{
key: "board",
group: "boards",
priority: 60,
isVisible: (context) => hasPermission(context, "board-create"),
},
{
key: "workshop",
group: "library",
priority: 40,
isVisible: (context) => hasPermission(context, "admin"),
},
{
key: "customWidget",
group: "library",
priority: 30,
isVisible: (context) => hasPermission(context, "admin"),
},
];
export interface RankedUniversalCreateAction {
key: UniversalCreateActionKey;
group: UniversalCreateActionGroup;
priority: number;
name: string;
description: string;
keywords?: readonly string[];
}
export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
definitions
.filter((definition) => definition.isVisible(context))
.map((definition) =>
definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
);
const normalize = (value: string) => value.trim().toLocaleLowerCase();
export const filterAndRankUniversalCreateActions = <TAction extends RankedUniversalCreateAction>(
actions: readonly TAction[],
query: string,
) => {
const normalizedQuery = normalize(query);
return actions
.map((action) => {
const name = normalize(action.name);
const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
const relevance =
normalizedQuery.length === 0
? 0
: name === normalizedQuery
? 300
: name.startsWith(normalizedQuery)
? 200
: searchableText.includes(normalizedQuery)
? 100
: -1;
return { action, relevance };
})
.filter(({ relevance }) => relevance >= 0)
.toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
.map(({ action }) => action);
};
@@ -0,0 +1,21 @@
.action {
width: 100%;
border-radius: var(--mantine-radius-md);
padding: var(--mantine-spacing-sm);
text-align: start;
}
@media (hover: hover) {
.action:hover {
background: var(--mantine-color-default-hover);
}
}
.action:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.actionIcon {
flex: 0 0 auto;
}
@@ -0,0 +1,5 @@
declare const classes: {
readonly action: string;
readonly actionIcon: string;
};
export default classes;
@@ -0,0 +1,162 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
IconApps,
IconBox,
IconCode,
IconLayoutDashboard,
IconLayoutGridAdd,
IconPlugConnected,
IconSearch,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { AddBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import classes from "./universal-create-modal.module.css";
import type {
RankedUniversalCreateAction,
UniversalCreateActionGroup,
UniversalCreateActionKey,
} from "./universal-create-actions";
import { getUniversalCreateActionDefinitions } from "./universal-create-actions";
import type { SetupMetricEntryPoint } from "./setup-analytics";
import { useSetupAnalytics } from "./setup-analytics";
type BoardActionKey = "widget" | "app" | "integration" | "container";
export interface UniversalCreateModalProps {
boardActions?: Partial<Record<BoardActionKey, () => void>>;
entryPoint?: SetupMetricEntryPoint;
}
const actionIcons = {
widget: IconApps,
app: IconBox,
integration: IconPlugConnected,
container: IconLayoutGridAdd,
board: IconLayoutDashboard,
workshop: IconSearch,
customWidget: IconCode,
} satisfies Record<UniversalCreateActionKey, TablerIcon>;
export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ actions, innerProps }) => {
const { data: session } = useSession();
const router = useRouter();
const t = useScopedI18n("universalCreate");
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
const trackedOpen = useRef(false);
const boardActions = innerProps.boardActions;
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
const trackSetup = useSetupAnalytics();
const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
const definitions = getUniversalCreateActionDefinitions({
hasBoardContext: boardActions !== undefined,
permissions,
});
useEffect(() => {
if (trackedOpen.current) return;
trackedOpen.current = true;
trackSetup("surface-opened", {
entryPoint,
hasBoardContext: boardActions !== undefined,
});
}, [boardActions, entryPoint, trackSetup]);
const invokeAction = (key: UniversalCreateActionKey) => {
const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
actions.closeModal();
trackSetup("intent-selected", {
entryPoint,
intent: key,
outcome: "continued",
hasBoardContext: boardActions !== undefined,
});
if (boardAction) {
boardAction();
return;
}
switch (key) {
case "app":
router.push("/manage/apps/new");
break;
case "integration":
router.push("/manage/integrations/new");
break;
case "board":
openAddBoardModal(undefined);
break;
case "workshop":
router.push("/manage/workshop");
break;
case "customWidget":
router.push("/manage/custom-widgets/new");
break;
case "widget":
case "container":
break;
}
};
const resolvedActions = definitions.map(
(definition) =>
({
...definition,
name: t(`action.${definition.key}.label`),
description: t(`action.${definition.key}.description`),
keywords: t(`action.${definition.key}.keywords`).split(" "),
}) satisfies RankedUniversalCreateAction,
);
const orderedActions = resolvedActions.toSorted((left, right) => right.priority - left.priority);
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
return (
<Stack gap="md">
{groups.map((group) => {
const groupActions = orderedActions.filter((action) => action.group === group);
if (groupActions.length === 0) return null;
return (
<Stack key={group} gap={4}>
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
{t(`group.${group}`)}
</Text>
{groupActions.map((action) => {
const Icon = actionIcons[action.key];
return (
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
<Group wrap="nowrap" align="flex-start">
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
<Icon size={18} stroke={1.5} />
</ThemeIcon>
<Stack gap={1}>
<Text fw={600} size="sm">
{action.name}
</Text>
<Text c="dimmed" size="xs">
{action.description}
</Text>
</Stack>
</Group>
</UnstyledButton>
);
})}
</Stack>
);
})}
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("universalCreate.title"),
size: "lg",
});
@@ -0,0 +1,35 @@
"use client";
import { useMemo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { UniversalCreateModal } from "./universal-create-modal";
/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
export const UniversalCreateSpotlightResult = () => {
const t = useScopedI18n("universalCreate");
const { openModal } = useModalAction(UniversalCreateModal);
const item = useMemo(
() => ({
id: "universal-create",
name: t("title"),
description: t("trigger.description"),
icon: IconPlus,
alwaysVisible: true,
interaction: () => ({
type: "javaScript" as const,
onSelect: () => {
openModal({ entryPoint: "spotlight" });
},
}),
}),
[openModal, t],
);
useRegisterSpotlightContextResults("universal-create", [item], [item]);
return null;
};
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { HeaderButton } from "~/components/layout/header/button";
import { UniversalCreateModal } from "./universal-create-modal";
export const UniversalCreateHeaderAction = () => {
const t = useScopedI18n("universalCreate");
const { openModal } = useModalAction(UniversalCreateModal);
return (
<HeaderButton onClick={() => openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
<IconPlus stroke={1.5} />
</HeaderButton>
);
};
export const UniversalCreateQueryGate = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { openModal } = useModalAction(UniversalCreateModal);
const handled = useRef(false);
const shouldOpen = searchParams.get("create") === "true";
useEffect(() => {
if (!shouldOpen) {
handled.current = false;
return;
}
if (handled.current) return;
handled.current = true;
openModal({ entryPoint: "spotlight" });
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("create");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [openModal, pathname, router, searchParams, shouldOpen]);
return null;
};
@@ -1,10 +1,9 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { Alert, Button, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from "@mantine/core"; import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
import { IconArrowLeft, IconX } from "@tabler/icons-react"; import { IconArrowLeft, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
@@ -28,28 +27,9 @@ interface IntegrationSelectModalProps {
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => { export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const t = useI18n(); const t = useI18n();
const directKind = const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
innerProps.initialKind ?? (innerProps.allowedKinds?.length === 1 ? innerProps.allowedKinds[0] : null); const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
const isDirectForm = Boolean(directKind);
const [step, setStep] = useState<"select" | "form">(isDirectForm ? "form" : "select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(directKind ?? null);
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal); const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
const { data: integrationData, isError: isIntegrationDataError, refetch } = clientApi.integration.all.useQuery();
useEffect(() => {
if (step === "form" && !isDirectForm) {
actions.setCloseInterceptor?.(() => {
setStep("select");
setSelectedKind(null);
return false;
});
} else {
actions.setCloseInterceptor?.(null);
}
return () => {
actions.setCloseInterceptor?.(null);
};
}, [step, isDirectForm, actions]);
const handleSelect = (kind: IntegrationKind) => { const handleSelect = (kind: IntegrationKind) => {
setSelectedKind(kind); setSelectedKind(kind);
@@ -60,7 +40,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
setStep("select"); setStep("select");
setSelectedKind(null); setSelectedKind(null);
}; };
const handleFormBack = isDirectForm ? actions.closeModal : handleBack; const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
const handleSuccess = (result?: CreatedIntegrationResult) => { const handleSuccess = (result?: CreatedIntegrationResult) => {
actions.closeModal(); actions.closeModal();
@@ -72,8 +52,12 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
}; };
if (step === "form" && selectedKind) { if (step === "form" && selectedKind) {
const HeaderIcon = isDirectForm ? IconX : IconArrowLeft; let HeaderIcon = IconArrowLeft;
const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous"); let headerLabel = t("common.action.previous");
if (innerProps.initialKind) {
HeaderIcon = IconX;
headerLabel = t("common.action.close");
}
return ( return (
<ScrollArea.Autosize mah="80vh"> <ScrollArea.Autosize mah="80vh">
@@ -95,25 +79,11 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
); );
} }
if (isIntegrationDataError) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("integration.grid.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return ( return (
<IntegrationSelectGrid <IntegrationSelectGrid
onSelect={handleSelect} onSelect={handleSelect}
enableMockIntegration={innerProps.enableMockIntegration} enableMockIntegration={innerProps.enableMockIntegration}
allowedKinds={innerProps.allowedKinds} allowedKinds={innerProps.allowedKinds}
integrationData={integrationData}
/> />
); );
}).withOptions({ }).withOptions({
@@ -3,6 +3,8 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger";
import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight";
import { ClientBurger } from "./header/burger"; import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search"; import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target"; import { TourTarget } from "./header/tour-target";
@@ -36,6 +38,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
<DesktopSearchInput /> <DesktopSearchInput />
</TourTarget> </TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap"> <Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <UniversalCreateHeaderAction />}
{actions} {actions}
<MobileSearchButton /> <MobileSearchButton />
<TourTarget id="board-user-menu"> <TourTarget id="board-user-menu">
@@ -43,6 +46,8 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
</TourTarget> </TourTarget>
</Group> </Group>
</Group> </Group>
<UniversalCreateQueryGate />
<UniversalCreateSpotlightResult />
<LazySpotlight /> <LazySpotlight />
</AppShellHeader> </AppShellHeader>
); );
+2 -6
View File
@@ -78,15 +78,11 @@ describe("lazy widget application graph", () => {
await page.goto(`${baseUrl}/auth/login`); await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username); await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password); await page.locator("#password").fill(adminCredentials.password);
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname === "/", { const signedIn = page.waitForURL(baseUrl, { waitUntil: "commit", timeout: 60_000 });
waitUntil: "commit",
timeout: 60_000,
});
await page.locator("css=button[type='submit']").click(); await page.locator("css=button[type='submit']").click();
await signedIn; await signedIn;
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first(); await expect(page.locator("[data-homarr-dev-benchmark-board]")).toBeVisible({ timeout: 30_000 });
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
const clockWidget = page.locator( const clockWidget = page.locator(
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`, `[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
); );
+2 -3
View File
@@ -30,9 +30,8 @@
"db:studio": "pnpm -F db studio", "db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev", "dev": "pnpm -F nextjs dev",
"dev:benchmark": "node --experimental-strip-types scripts/benchmarks/dev-board.mts", "dev:benchmark": "node --experimental-strip-types scripts/benchmarks/dev-board.mts",
"dev:cli": "go -C tools/homarr-dev run -buildvcs=false .", "dev:cli": "go -C tools/homarr-dev run .",
"dev:cli:build": "go -C tools/homarr-dev build -buildvcs=false .", "dev:cli:install": "go -C tools/homarr-dev install .",
"dev:cli:install": "go -C tools/homarr-dev install -buildvcs=false .",
"dev:docs": "pnpm -F @homarr/docs dev", "dev:docs": "pnpm -F @homarr/docs dev",
"docker:docs": "docker compose -f apps/workshop/docker-compose.yml up --build docs", "docker:docs": "docker compose -f apps/workshop/docker-compose.yml up --build docs",
"docker:workshop": "docker compose -f apps/workshop/docker-compose.yml up --build workshop", "docker:workshop": "docker compose -f apps/workshop/docker-compose.yml up --build workshop",
+7 -57
View File
@@ -11,10 +11,6 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
const logger = createLogger({ module: "logRouter" }); const logger = createLogger({ module: "logRouter" });
type LogSubscriptionEvent =
| { type: "history"; messages: LoggerMessage[] }
| { type: "message"; message: LoggerMessage };
export const logRouter = createTRPCRouter({ export const logRouter = createTRPCRouter({
subscribe: permissionRequiredProcedure subscribe: permissionRequiredProcedure
.requiresPermission("other-view-logs") .requiresPermission("other-view-logs")
@@ -24,61 +20,15 @@ export const logRouter = createTRPCRouter({
}), }),
) )
.subscription(({ input }) => { .subscription(({ input }) => {
return observable<LogSubscriptionEvent>((emit) => { return observable<LoggerMessage>((emit) => {
let isClosed = false; const unsubscribe = loggingChannel.subscribe((data) => {
let unsubscribe: (() => void) | null = null; if (!input.levels.includes(data.level)) return;
let isHistoryLoaded = false; emit.next(data);
const bufferedMessages: LoggerMessage[] = []; });
logger.info("A tRPC client has connected to the logging procedure");
const onMessage = (message: LoggerMessage) => {
if (!input.levels.includes(message.level)) return;
if (!isHistoryLoaded) {
bufferedMessages.push(message);
return;
}
emit.next({ type: "message", message });
};
void loggingChannel
.subscribeAsync(onMessage)
.then(async (unsubscribeFromLogging) => {
if (isClosed) {
unsubscribeFromLogging();
return;
}
unsubscribe = unsubscribeFromLogging;
logger.info("A tRPC client has connected to the logging procedure");
const history = (await loggingChannel.getRecentAsync()).filter((message) =>
input.levels.includes(message.level),
);
if (isClosed) return;
emit.next({ type: "history", messages: history });
const historyIds = new Set(history.map((message) => message.id));
isHistoryLoaded = true;
for (const message of bufferedMessages) {
if (!historyIds.has(message.id)) {
emit.next({ type: "message", message });
}
}
bufferedMessages.length = 0;
})
.catch((error: unknown) => {
unsubscribe?.();
unsubscribe = null;
if (!isClosed) {
emit.error(error);
}
});
return () => { return () => {
isClosed = true; unsubscribe();
unsubscribe?.();
}; };
}); });
}), }),
-79
View File
@@ -1,79 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import type { LoggerMessage } from "@homarr/redis";
import { logRouter } from "../log";
const mocks = vi.hoisted(() => ({
getRecentAsync: vi.fn(),
subscribeAsync: vi.fn(),
}));
vi.mock("@homarr/redis", () => ({
loggingChannel: {
getRecentAsync: mocks.getRecentAsync,
subscribeAsync: mocks.subscribeAsync,
},
}));
const session = {
user: {
id: "log-viewer",
permissions: ["other-view-logs"],
colorScheme: "light",
},
expires: new Date(0).toISOString(),
} satisfies Session;
const createMessage = (id: string, timestamp: number): LoggerMessage => ({
id,
timestamp: new Date(timestamp),
level: "info",
message: id,
});
describe("log.subscribe", () => {
test("emits history before buffered live messages without duplicates", async () => {
const historyDeferred = Promise.withResolvers<LoggerMessage[]>();
let onMessage: ((message: LoggerMessage) => void) | undefined;
const unsubscribe = vi.fn();
mocks.subscribeAsync.mockImplementation(async (callback: (message: LoggerMessage) => void) => {
onMessage = callback;
return unsubscribe;
});
mocks.getRecentAsync.mockReturnValue(historyDeferred.promise);
const caller = logRouter.createCaller({
db: null as never,
deviceType: undefined,
session,
});
const subscription = await caller.subscribe({ levels: ["info"] });
const events: object[] = [];
const observer = subscription.subscribe({
next(event) {
events.push(event);
},
});
await vi.waitFor(() => expect(mocks.getRecentAsync).toHaveBeenCalledOnce());
const retainedMessage = createMessage("retained", 1);
const liveMessage = createMessage("live", 2);
onMessage?.(retainedMessage);
onMessage?.(liveMessage);
historyDeferred.resolve([retainedMessage]);
await vi.waitFor(() =>
expect(events).toEqual([
{ type: "history", messages: [retainedMessage] },
{ type: "message", message: liveMessage },
]),
);
observer.unsubscribe();
expect(unsubscribe).toHaveBeenCalledOnce();
});
});
-357
View File
@@ -1,357 +0,0 @@
import { randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { command, number, positional, string } from "@drizzle-team/brocli";
const execFileAsync = promisify(execFile);
const environmentSeparator = "\u0000";
const validEnvironmentName = /^[A-Za-z_][A-Za-z0-9_]*$/;
const validHarnessName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
const demoEnvironment: ReadonlyArray<[string, string]> = [
["DEMO_MODE", "true"],
["DEMO_READ_ONLY", "false"],
["UNSAFE_ENABLE_MOCK_INTEGRATION", "true"],
];
type HarnessRuntimeOptions = {
name: string;
env?: string;
port?: number;
};
const runtimeOptions = () => ({
name: positional("name").required().desc("Name used for the image, container, and data volume"),
env: string("env").alias("e").desc("Environment override KEY=VALUE; repeat the flag for more values"),
port: number("port").int().min(0).max(65535).desc("Host port, or 0 for an automatically assigned port"),
});
function validateHarnessName(name: string): string {
if (!validHarnessName.test(name)) {
throw new Error("Harness name must be 1-63 characters and contain only letters, numbers, '.', '_' or '-'");
}
return name;
}
function imageName(name: string): string {
return `homarr:${validateHarnessName(name)}`;
}
function containerName(name: string): string {
return `homarr-harness-${validateHarnessName(name)}`;
}
function volumeName(name: string): string {
return `homarr-harness-${validateHarnessName(name)}-data`;
}
function hasErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code;
}
function commandError(commandName: string, error: unknown): Error {
if (hasErrorCode(error, "ENOENT")) {
return new Error(`${commandName} is required but was not found on PATH`);
}
if (error instanceof Error) {
return new Error(`${commandName} failed: ${error.message}`);
}
return new Error(`${commandName} failed`);
}
async function execCommand(commandName: string, args: string[], cwd?: string): Promise<string> {
try {
const result = await execFileAsync(commandName, args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
});
return result.stdout.trim();
} catch (error) {
throw commandError(commandName, error);
}
}
async function runCommand(commandName: string, args: string[], cwd?: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(commandName, args, {
cwd,
stdio: "inherit",
});
child.once("error", (error) => reject(commandError(commandName, error)));
child.once("close", (code, signal) => {
if (code === 0) {
resolve();
return;
}
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(new Error(`${commandName} failed with ${reason}`));
});
});
}
async function repositoryRoot(): Promise<string> {
const root = await execCommand("git", ["rev-parse", "--show-toplevel"], process.cwd());
try {
await access(join(root, "Dockerfile"), constants.R_OK);
} catch {
throw new Error("Run the harness command from a Homarr checkout containing a Dockerfile");
}
return root;
}
function parseEnvironmentOverrides(raw?: string): Map<string, string> {
const values = raw?.split(environmentSeparator) ?? [];
const environment = new Map(demoEnvironment);
for (const value of values) {
const separator = value.indexOf("=");
if (separator <= 0) {
throw new Error(`Invalid environment override ${JSON.stringify(value)}; use KEY=VALUE`);
}
const name = value.slice(0, separator);
if (!validEnvironmentName.test(name)) {
throw new Error(`Invalid environment variable name ${JSON.stringify(name)}`);
}
environment.set(name, value.slice(separator + 1));
}
return environment;
}
function isEncryptionKey(value: string | undefined): value is string {
return value !== undefined && /^[a-f0-9]{64}$/i.test(value);
}
async function readEncryptionKey(path: string): Promise<string | undefined> {
try {
const value = (await readFile(path, "utf8")).trim();
if (isEncryptionKey(value)) {
return value;
}
throw new Error(`Invalid encryption key in ${path}`);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) {
return undefined;
}
throw error;
}
}
async function harnessEncryptionKey(name: string): Promise<string> {
if (isEncryptionKey(process.env.SECRET_ENCRYPTION_KEY)) {
return process.env.SECRET_ENCRYPTION_KEY;
}
const configRoot = process.env.HOMARR_HARNESS_CONFIG_DIR ?? join(homedir(), ".config", "homarr");
const keyPath = join(configRoot, "local-harness", `${validateHarnessName(name)}.key`);
const existingKey = await readEncryptionKey(keyPath);
if (existingKey) {
return existingKey;
}
const newKey = randomBytes(32).toString("hex");
await mkdir(join(configRoot, "local-harness"), { recursive: true, mode: 0o700 });
try {
await writeFile(keyPath, `${newKey}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
return newKey;
} catch (error) {
if (!hasErrorCode(error, "EEXIST")) {
throw error;
}
const racedKey = await readEncryptionKey(keyPath);
if (racedKey) {
return racedKey;
}
throw new Error(`Could not read the harness encryption key at ${keyPath}`, { cause: error });
}
}
async function harnessEnvironment(name: string, raw?: string): Promise<string[]> {
const environment = parseEnvironmentOverrides(raw);
if (!environment.has("SECRET_ENCRYPTION_KEY")) {
environment.set("SECRET_ENCRYPTION_KEY", await harnessEncryptionKey(name));
}
return Array.from(environment, ([key, value]) => `${key}=${value}`);
}
async function buildHarnessImage(name: string): Promise<void> {
const root = await repositoryRoot();
const image = imageName(name);
console.log(`Building ${image} from ${root}`);
await runCommand("docker", ["build", "--tag", image, "."], root);
console.log(`Built ${image}`);
}
async function removeExistingContainer(name: string): Promise<void> {
try {
await execCommand("docker", ["rm", "--force", containerName(name)]);
} catch {
// A missing container is the normal first-run path. Docker reports it as
// an error, so the cleanup is intentionally best-effort.
}
}
async function publishedPort(name: string): Promise<number> {
const output = await execCommand("docker", ["port", containerName(name), "7575/tcp"]);
for (const line of output.split("\n")) {
const match = line.trim().match(/:(\d+)$/);
if (match) {
return Number(match[1]);
}
}
throw new Error(`Container ${containerName(name)} has no published port for 7575/tcp`);
}
async function startHarness(options: HarnessRuntimeOptions): Promise<void> {
const name = validateHarnessName(options.name);
const image = imageName(name);
const container = containerName(name);
const requestedPort = options.port ?? 0;
const environment = await harnessEnvironment(name, options.env);
await removeExistingContainer(name);
console.log(`Starting ${container} from ${image}`);
await runCommand("docker", [
"run",
"--detach",
"--name",
container,
"--publish",
`${requestedPort}:7575`,
"--volume",
`${volumeName(name)}:/appdata`,
...environment.flatMap((value) => ["--env", value]),
image,
]);
const port = await publishedPort(name);
console.log(`Harness ${name} is running`);
console.log(` Container : ${container}`);
console.log(` Image : ${image}`);
console.log(` Port : ${port}`);
console.log(` URL : http://127.0.0.1:${port}`);
}
export const normalizeHarnessEnvironmentArgs = (argv: string[]): string[] => {
const normalized = argv.slice(0, 2);
const args = argv.slice(2);
const environmentValues: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === undefined) {
continue;
}
if (arg === "--env" || arg === "-e") {
const value = args[index + 1];
if (value === undefined) {
normalized.push(arg);
continue;
}
environmentValues.push(value);
index += 1;
continue;
}
if (arg.startsWith("--env=")) {
environmentValues.push(arg.slice("--env=".length));
continue;
}
normalized.push(arg);
}
if (environmentValues.length > 0) {
normalized.push(`--env=${environmentValues.join(environmentSeparator)}`);
}
return normalized;
};
const harnessBuild = command({
name: "build",
desc: "Build the current checkout as a named local Homarr image",
options: {
name: positional("name").required().desc("Local image tag"),
},
handler: async ({ name }) => buildHarnessImage(name),
});
const harnessRun = command({
name: "run",
aliases: ["start", "up"],
desc: "Run a named local image in writable demo mode",
options: runtimeOptions(),
handler: async (options) => startHarness(options),
});
const harnessSetup = command({
name: "setup",
aliases: ["create"],
desc: "Build the current checkout and run its writable demo harness",
options: runtimeOptions(),
handler: async (options) => {
await buildHarnessImage(options.name);
await startHarness(options);
},
});
const harnessPort = command({
name: "port",
desc: "Show the published host port and URL for a harness",
options: {
name: positional("name").required().desc("Harness name"),
},
handler: async ({ name }) => {
const port = await publishedPort(name);
console.log(`Port: ${port}`);
console.log(`URL: http://127.0.0.1:${port}`);
},
});
const harnessStop = command({
name: "stop",
aliases: ["down"],
desc: "Stop a local harness container and keep its data volume",
options: {
name: positional("name").required().desc("Harness name"),
},
handler: async ({ name }) => {
const container = containerName(name);
await execCommand("docker", ["stop", container]);
console.log(`Stopped ${container}`);
},
});
export const harnessRoot = command({
name: "harness",
aliases: ["local-harness"],
desc: "Build and run a local branch in a browser-ready demo harness",
subcommands: [harnessBuild, harnessRun, harnessSetup, harnessPort, harnessStop],
});
+10 -38
View File
@@ -1,42 +1,14 @@
import { run } from "@drizzle-team/brocli"; import { run } from "@drizzle-team/brocli";
import type { Command } from "@drizzle-team/brocli";
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness"; import { fixUsernames } from "./commands/fix-usernames";
import { integrationsRoot } from "./commands/integrations";
import { recreateAdmin } from "./commands/recreate-admin";
import { resetPassword } from "./commands/reset-password";
import { usersRoot } from "./commands/users";
const harnessCommandNames = new Set(["harness", "local-harness"]); const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
function isHarnessInvocation(argv: string[]): boolean { void run(commands, {
return argv.slice(2).some((argument) => harnessCommandNames.has(argument)); name: "homarr-cli",
} version: "1.0.0",
});
async function main(): Promise<void> {
const harnessInvocation = isHarnessInvocation(process.argv);
let commands: Command[];
if (harnessInvocation) {
commands = [harnessRoot];
} else {
const [{ fixUsernames }, { integrationsRoot }, { recreateAdmin }, { resetPassword }, { usersRoot }] =
await Promise.all([
import("./commands/fix-usernames"),
import("./commands/integrations"),
import("./commands/recreate-admin"),
import("./commands/reset-password"),
import("./commands/users"),
]);
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot];
}
let argSource = process.argv;
if (harnessInvocation) {
argSource = normalizeHarnessEnvironmentArgs(process.argv);
}
await run(commands, {
argSource,
name: "homarr-cli",
version: "1.0.0",
});
}
void main();
@@ -1,20 +1,6 @@
import z from "zod/v4";
export const logLevels = ["error", "warn", "info", "debug"] as const; export const logLevels = ["error", "warn", "info", "debug"] as const;
export type LogLevel = (typeof logLevels)[number]; export type LogLevel = (typeof logLevels)[number];
export const loggerMessageSchema = z.object({
id: z.string(),
timestamp: z.date(),
message: z.string(),
level: z.enum(logLevels),
});
export type LoggerMessage = z.infer<typeof loggerMessageSchema>;
export const LOG_HISTORY_MAX_ENTRIES = 5_000;
export const LOG_HISTORY_KEY = "list:logging:history";
export const LOG_PUBLISH_CHANNEL = "pubSub:logging";
export const logLevelConfiguration = { export const logLevelConfiguration = {
error: { error: {
prefix: "🔴", prefix: "🔴",
@@ -1,11 +1,8 @@
import { randomUUID } from "node:crypto"; import superjson from "superjson";
import { stringify } from "superjson";
import Transport from "winston-transport"; import Transport from "winston-transport";
import type { RedisClient } from "../../redis/client"; import type { RedisClient } from "../../redis/client";
import { createRedisClient } from "../../redis/client"; import { createRedisClient } from "../../redis/client";
import type { LoggerMessage } from "../constants";
import { LOG_HISTORY_KEY, LOG_HISTORY_MAX_ENTRIES, LOG_PUBLISH_CHANNEL } from "../constants";
const messageSymbol = Symbol.for("message"); const messageSymbol = Symbol.for("message");
const levelSymbol = Symbol.for("level"); const levelSymbol = Symbol.for("level");
@@ -16,41 +13,32 @@ const levelSymbol = Symbol.for("level");
// //
export class RedisTransport extends Transport { export class RedisTransport extends Transport {
private redis: RedisClient | null = null; private redis: RedisClient | null = null;
public static readonly publishChannel = "pubSub:logging";
/** /**
* Retain the log in a bounded Redis list and publish it to live subscribers. * Log the info to the Redis channel
*/ */
log(info: { [messageSymbol]: string; [levelSymbol]: LoggerMessage["level"] }, callback: () => void) { log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
setImmediate(() => { setImmediate(() => {
this.emit("logged", info); this.emit("logged", info);
}); });
const message: LoggerMessage = { // Is only initialized here because it did not work when initialized in the constructor or outside the class
id: randomUUID(), this.redis ??= createRedisClient();
timestamp: new Date(),
message: info[messageSymbol],
level: info[levelSymbol],
};
const serializedMessage = stringify(message);
callback(); this.redis
.publish(
void Promise.resolve() RedisTransport.publishChannel,
superjson.stringify({
message: info[messageSymbol],
level: info[levelSymbol],
}),
)
.then(() => { .then(() => {
// Is only initialized here because it did not work when initialized in the constructor or outside the class callback();
if (!this.redis) {
this.redis = createRedisClient();
this.redis.options.maxRetriesPerRequest = 1;
this.redis.on("error", () => undefined);
}
return this.redis
.multi()
.lpush(LOG_HISTORY_KEY, serializedMessage)
.ltrim(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1)
.publish(LOG_PUBLISH_CHANNEL, serializedMessage)
.exec();
}) })
.catch(() => undefined); .catch(() => {
// Ignore errors
});
} }
} }
@@ -336,6 +336,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/whats-up-docker" | "/docs/integrations/whats-up-docker"
| "/docs/widgets/clock" | "/docs/widgets/clock"
| "/docs/widgets/weather" | "/docs/widgets/weather"
| "/docs/widgets/today"
| "/docs/widgets/air-quality" | "/docs/widgets/air-quality"
| "/docs/widgets/countdown" | "/docs/widgets/countdown"
| "/docs/widgets/timer" | "/docs/widgets/timer"
@@ -3,6 +3,7 @@ import type { WidgetKind } from "../widget";
export const widgetDocSlugs: Record<WidgetKind, string | null> = { export const widgetDocSlugs: Record<WidgetKind, string | null> = {
clock: "clock", clock: "clock",
weather: "weather", weather: "weather",
today: "today",
airQuality: "air-quality", airQuality: "air-quality",
countdown: "countdown", countdown: "countdown",
timer: "timer", timer: "timer",
+2
View File
@@ -1,6 +1,7 @@
export const widgetKinds = [ export const widgetKinds = [
"clock", "clock",
"weather", "weather",
"today",
"airQuality", "airQuality",
"countdown", "countdown",
"timer", "timer",
@@ -62,6 +63,7 @@ export const widgetKinds = [
export type WidgetKind = (typeof widgetKinds)[number]; export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = { export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
today: { width: 2, height: 1 },
airQuality: { width: 2, height: 1 }, airQuality: { width: 2, height: 1 },
countdown: { width: 2, height: 1 }, countdown: { width: 2, height: 1 },
timer: { width: 2, height: 1 }, timer: { width: 2, height: 1 },
@@ -1,438 +0,0 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppSelectModal } from "./app-select-modal";
const AppSelectModalComponent = AppSelectModal.component;
const sampleApps = [
{
id: "app-1",
name: "Sonarr",
description: "Manage TV series downloads",
iconUrl: "https://example.com/sonarr.png",
href: "http://sonarr.local",
},
{
id: "app-2",
name: "Radarr",
description: "Manage movie collections",
iconUrl: "https://example.com/radarr.png",
href: "http://radarr.local",
},
{
id: "app-3",
name: "Plex",
description: "Stream your media collection",
iconUrl: "https://example.com/plex.png",
href: "http://plex.local",
},
];
const mockUseQuery = vi.fn(() => ({
data: sampleApps,
isPending: false,
}));
const mockOpenQuickAddAppModal = vi.fn();
vi.mock("@homarr/api/client", () => ({
clientApi: {
app: {
selectable: {
useQuery: () => mockUseQuery(),
},
},
},
}));
vi.mock("@homarr/modals", async () => {
const actual = await vi.importActual<Record<string, unknown>>("@homarr/modals");
return {
...actual,
useModalAction: () => ({
openModal: mockOpenQuickAddAppModal,
}),
};
});
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string, values?: { count?: number }) => {
const count = values?.count ?? 0;
const translations: Record<string, string> = {
"app.action.select.search": "Search apps",
"app.action.select.title": "Select application",
"app.action.select.selected": "Selected",
"app.action.select.toggle": "Click to select",
"app.action.select.noResults": "No apps found",
"app.action.select.multiSelectTip": "Hold Ctrl or Command to select multiple apps",
"app.action.select.selectedCount": `${count} selected`,
"app.action.select.appsSelected": `${count} ${count === 1 ? "app" : "apps"} selected`,
"app.action.select.customApplication": "Custom application",
"app.action.select.application": "Application",
"app.action.create.title": "Add custom app",
"app.action.create.description": "Configure custom URL and icon",
"common.action.discard": "Discard",
"common.action.add": "Add",
};
return translations[key] ?? key;
},
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
vi.clearAllMocks();
mockUseQuery.mockReturnValue({
data: sampleApps,
isPending: false,
});
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("AppSelectModal", () => {
describe("single selection mode", () => {
it("renders app cards with name, description, and icon", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Sonarr");
expect(host.textContent).toContain("Radarr");
expect(host.textContent).toContain("Plex");
expect(host.textContent).toContain("Manage TV series downloads");
});
it("selects an app and closes modal when card is clicked", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
expect(sonarrCard).toBeDefined();
await act(async () => {
sonarrCard?.click();
});
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
expect(closeModal).toHaveBeenCalledOnce();
});
});
describe("multi selection mode", () => {
it("toggles multiple app selections and submits batch on button click", async () => {
const closeModal = vi.fn();
const onSelectMany = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
const radarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Radarr"));
// Select Sonarr
await act(async () => {
sonarrCard?.click();
});
// Modal stays open, 1 app selected
expect(closeModal).not.toHaveBeenCalled();
expect(host.textContent).toContain("1 app selected");
// Select Radarr
await act(async () => {
radarrCard?.click();
});
expect(host.textContent).toContain("2 apps selected");
// Submit multi select
const submitBtn = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Add (2)"));
expect(submitBtn).toBeDefined();
await act(async () => {
submitBtn?.click();
});
expect(onSelectMany).toHaveBeenCalledWith([sampleApps[0], sampleApps[1]]);
expect(closeModal).toHaveBeenCalledOnce();
});
it("unselects an app when clicked a second time", async () => {
const closeModal = vi.fn();
const onSelectMany = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
// Select Sonarr
await act(async () => {
sonarrCard?.click();
});
expect(host.textContent).toContain("1 app selected");
// Unselect Sonarr
await act(async () => {
sonarrCard?.click();
});
expect(host.textContent).not.toContain("1 app selected");
});
it("clears selection when Discard button is clicked", async () => {
const closeModal = vi.fn();
const onSelectMany = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
await act(async () => {
sonarrCard?.click();
});
expect(host.textContent).toContain("1 app selected");
const discardBtn = Array.from(host.querySelectorAll("button")).find((btn) =>
btn.textContent?.includes("Discard"),
);
await act(async () => {
discardBtn?.click();
});
expect(host.textContent).not.toContain("app selected");
});
});
describe("search and filtering", () => {
it("filters apps in real-time by search query (case-insensitive)", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
expect(searchInput).not.toBeNull();
// Type "plex"
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(searchInput, "plex");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(host.textContent).toContain("Plex");
expect(host.textContent).not.toContain("Sonarr");
expect(host.textContent).not.toContain("Radarr");
});
it("selects the single matching app when Enter key is pressed in search input", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(searchInput, "sonarr");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await act(async () => {
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
expect(closeModal).toHaveBeenCalledOnce();
});
it("displays empty state message when search yields no results", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(searchInput, "non-matching-query");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(host.textContent).toContain("No apps found");
});
});
describe("custom app creation card", () => {
it("renders custom app creation card when withCreate is true", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Add custom app");
expect(host.textContent).toContain("Configure custom URL and icon");
});
it("omits custom app creation card when withCreate is false", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
expect(host.textContent).not.toContain("Add custom app");
});
it("opens QuickAddAppModal when custom app creation card is clicked in single select mode", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
</MantineProvider>,
),
);
const createCard = Array.from(host.querySelectorAll("button")).find((btn) =>
btn.textContent?.includes("Add custom app"),
);
expect(createCard).toBeDefined();
await act(async () => {
createCard?.click();
});
expect(mockOpenQuickAddAppModal).toHaveBeenCalledOnce();
// Trigger the onClose callback passed to QuickAddAppModal
const options = mockOpenQuickAddAppModal.mock.calls[0]?.[0] as { onClose: (app: (typeof sampleApps)[0]) => void };
const newCustomApp = {
id: "custom-app-1",
name: "Custom Home Assistant",
description: "Local instance",
iconUrl: "https://example.com/ha.png",
href: "http://ha.local",
};
await act(async () => {
options.onClose(newCustomApp);
});
expect(onSelect).toHaveBeenCalledWith(newCustomApp);
expect(closeModal).toHaveBeenCalledOnce();
});
});
});
@@ -1,25 +1,12 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
Badge, import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
Button,
Center,
Group,
Image,
Input,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { selectGridCols, SelectableCard } from "@homarr/ui"; import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal"; import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -49,15 +36,7 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]); const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
const handleSelect = (app: SelectableApp, event?: React.MouseEvent) => { const handleSelect = (app: SelectableApp) => {
const isModifierPressed = multiSelect && Boolean(event?.shiftKey || event?.ctrlKey || event?.metaKey);
if (innerProps.onSelect && !isModifierPressed && selectedAppIds.size === 0) {
innerProps.onSelect(app);
actions.closeModal();
return;
}
if (multiSelect) { if (multiSelect) {
setSelectedAppIds((current) => { setSelectedAppIds((current) => {
const next = new Set(current); const next = new Set(current);
@@ -65,6 +44,22 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
else next.add(app.id); else next.add(app.id);
return next; return next;
}); });
return;
}
innerProps.onSelect?.(app);
actions.closeModal();
};
const handleSubmit = () => {
if (multiSelect) {
innerProps.onSelectMany?.(selectedApps);
actions.closeModal();
return;
}
const [first] = filteredApps;
if (first) {
innerProps.onSelect?.(first);
actions.closeModal();
} }
}; };
@@ -81,145 +76,100 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
}); });
}; };
const handleMultiSubmit = () => {
innerProps.onSelectMany?.(selectedApps);
actions.closeModal();
};
return ( return (
<Stack gap="md"> <>
{/* Top Search Input & Multi-Select Tip */} <SelectGridLayout
<Stack gap={6}> search={search}
<Input onSearchChange={setSearch}
value={search} placeholder={`${t("app.action.select.search")}...`}
onChange={(event) => setSearch(event.currentTarget.value)} ariaLabel={t("app.action.select.search")}
leftSection={<IconSearch size={16} />} onSearchKeyDown={(event) => {
placeholder={`${t("app.action.select.search")}...`} if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
aria-label={t("app.action.select.search")} handleSelect(filteredApps[0]);
data-autofocus }
onKeyDown={(event) => { }}
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) { >
handleSelect(filteredApps[0]); {innerProps.withCreate && (
} <CatalogItem
}} height={selectGridCardHeight}
/> label={t("app.action.create.title")}
{multiSelect && ( status={t("app.action.create.action")}
<Group justify="space-between" px={2}> onSelect={handleAddNewApp}
<Text size="xs" c="dimmed"> >
{t("app.action.select.multiSelectTip")} <Stack h="100%" gap="xs">
</Text> <Group gap="sm" wrap="nowrap" align="flex-start">
{selectedAppIds.size > 0 && ( <IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Badge variant="light" color="primaryColor" size="xs"> <Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{t("app.action.select.selectedCount", { count: selectedAppIds.size })} {t("app.action.create.title")}
</Badge> </Text>
)} </Group>
</Group> <Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{t("app.action.create.description")}
</Text>
<Text size="xs" c="blue" fw={500} mt="auto">
{t("app.action.create.action")}
</Text>
</Stack>
</CatalogItem>
)} )}
</Stack>
{/* Scrollable Container with App Cards */} {filteredApps.map((app) => (
<ScrollArea.Autosize mah="70vh" offsetScrollbars> <CatalogItem
<Stack gap="md" pt="xs" pr="xs" px={4}> key={app.id}
<SimpleGrid cols={selectGridCols} spacing="sm"> height={selectGridCardHeight}
{innerProps.withCreate && ( label={app.name}
<SelectableCard status={
onClick={handleAddNewApp} multiSelect
style={{ borderStyle: "dashed" }} ? selectedAppIds.has(app.id)
icon={ ? t("app.action.select.selected")
<ThemeIcon variant="light" color="primaryColor" size={34} radius="md"> : t("app.action.select.toggle")
<IconPlus size={20} /> : t("app.action.select.action", { app: app.name })
</ThemeIcon> }
} selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
title={t("app.action.create.title")} onSelect={() => handleSelect(app)}
description={t("app.action.create.description")} >
footerLeft={ <Stack h="100%" gap="xs">
<Text size="xs" c="dimmed"> <Group gap="sm" wrap="nowrap" align="flex-start">
{t("app.action.select.customApplication")} <img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
</Text> <Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
} {app.name}
/> </Text>
)} {multiSelect && selectedAppIds.has(app.id) && (
<IconCheck size={18} color="var(--mantine-primary-color-filled)" />
)}
</Group>
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{app.description ?? ""}
</Text>
</Tooltip>
<Text size="xs" c="blue" fw={500} mt="auto">
{multiSelect
? selectedAppIds.has(app.id)
? t("app.action.select.selected")
: t("app.action.select.toggle")
: t("app.action.select.action", { app: app.name })}
</Text>
</Stack>
</CatalogItem>
))}
{filteredApps.map((app) => ( {filteredApps.length === 0 && !isPending && (
<AppCard <Center p="xl">
key={app.id} <Text c="dimmed">{t("app.action.select.noResults")}</Text>
app={app} </Center>
isSelected={selectedAppIds.has(app.id)} )}
multiSelect={multiSelect} </SelectGridLayout>
onSelect={handleSelect} {multiSelect && (
/> <Group justify="flex-end" mt="md">
))} <Button leftSection={<IconApps size={16} />} disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
</SimpleGrid> {t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
</Button>
{filteredApps.length === 0 && !isPending && ( </Group>
<Center p="xl">
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
</Center>
)}
</Stack>
</ScrollArea.Autosize>
{/* Multi-Select Action Footer */}
{multiSelect && selectedApps.length > 0 && (
<Paper withBorder p="xs" radius="md" bg="light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))">
<Group justify="space-between" align="center">
<Text size="sm" fw={600}>
{t("app.action.select.appsSelected", { count: selectedApps.length })}
</Text>
<Group gap="xs">
<Button variant="default" size="xs" onClick={() => setSelectedAppIds(new Set())}>
{t("common.action.discard")}
</Button>
<Button color="primaryColor" size="xs" onClick={handleMultiSubmit}>
{t("common.action.add")} ({selectedApps.length})
</Button>
</Group>
</Group>
</Paper>
)} )}
</Stack> </>
); );
}).withOptions({ }).withOptions({
defaultTitle: (t) => t("app.action.select.title"), defaultTitle: (t) => t("app.action.select.title"),
size: modalSizeSelect, size: modalSizeSelect,
}); });
// =========================================================================
// AppCard: Variant 3 (Dashboard Inset) with Medium Title & Large App Icon
// =========================================================================
const AppCard = ({
app,
isSelected,
multiSelect,
onSelect,
}: {
app: SelectableApp;
isSelected: boolean;
multiSelect: boolean;
onSelect: (app: SelectableApp, event?: React.MouseEvent) => void;
}) => {
const t = useI18n();
return (
<SelectableCard
onClick={(event) => onSelect(app, event)}
aria-label={app.name}
selected={isSelected}
icon={<Image src={app.iconUrl} alt={app.name} w={28} h={28} fit="contain" style={{ flexShrink: 0 }} />}
title={app.name}
topRight={
isSelected ? (
<Badge variant="dot" color="primaryColor" size="xs">
{t("app.action.select.selected")}
</Badge>
) : null
}
description={app.description}
footerLeft={
<Text size="xs" c="dimmed">
{multiSelect && isSelected ? t("app.action.select.selected") : t("app.action.select.application")}
</Text>
}
/>
);
};
+2 -8
View File
@@ -102,13 +102,6 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
content: presentationClasses.inspectorContent, content: presentationClasses.inspectorContent,
}); });
const handleClose = useCallback(() => {
const canClose = modal.reference.onCloseRequest ? modal.reference.onCloseRequest() : true;
if (canClose) {
handleCloseModal();
}
}, [handleCloseModal, modal.reference]);
return ( return (
<Modal <Modal
key={modal.id} key={modal.id}
@@ -130,7 +123,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
{...otherModalProps} {...otherModalProps}
title={translateIfNecessary(t, modal.props.defaultTitle)} title={translateIfNecessary(t, modal.props.defaultTitle)}
opened={opened} opened={opened}
onClose={handleClose} onClose={handleCloseModal}
> >
{modal.reference.content} {modal.reference.content}
</Modal> </Modal>
@@ -151,6 +144,7 @@ export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) =>
return { return {
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => { openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
// void actually is undefined // void actually is undefined
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
context.openModalInner({ modal, innerProps, options: options ?? {} }); context.openModalInner({ modal, innerProps, options: options ?? {} });
}, },
}; };
-11
View File
@@ -94,8 +94,6 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
const ModalContent = modal.modal.component; const ModalContent = modal.modal.component;
const { innerProps, ...rest } = modal.props; const { innerProps, ...rest } = modal.props;
let closeInterceptor: (() => boolean) | null = null;
const FullModal = () => { const FullModal = () => {
const context = useContext(ModalContext); const context = useContext(ModalContext);
@@ -108,9 +106,6 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
innerProps={innerProps} innerProps={innerProps}
actions={{ actions={{
closeModal: () => context.closeModal(modal.id), closeModal: () => context.closeModal(modal.id),
setCloseInterceptor: (interceptor: (() => boolean) | null) => {
closeInterceptor = interceptor;
},
}} }}
/> />
); );
@@ -119,11 +114,5 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
return { return {
modalProps: rest, modalProps: rest,
content: <FullModal />, content: <FullModal />,
onCloseRequest: () => {
if (closeInterceptor) {
return closeInterceptor();
}
return true;
},
}; };
}; };
+4 -6
View File
@@ -5,12 +5,10 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { ModalPresentation } from "./modal-presentation"; import type { ModalPresentation } from "./modal-presentation";
export interface ModalActions { export type ModalComponent<TInnerProps> = (props: {
closeModal: () => void; actions: { closeModal: () => void };
setCloseInterceptor?: (interceptor: (() => boolean) | null) => void; innerProps: TInnerProps;
} }) => ReactNode;
export type ModalComponent<TInnerProps> = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode;
export type CreateModalOptions = Pick< export type CreateModalOptions = Pick<
ModalOptions<unknown>, ModalOptions<unknown>,
+2 -2
View File
@@ -104,7 +104,7 @@ const CredentialsSetup = () => {
return ( return (
<AccountShell title={t("title")} description={t("description")}> <AccountShell title={t("title")} description={t("description")}>
<form onSubmit={form.onSubmit((values) => submitAsync(values))}> <form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
<Stack gap="md"> <Stack gap="md">
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} /> <TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
<UserCreatePasswordFields <UserCreatePasswordFields
@@ -119,7 +119,7 @@ const CredentialsSetup = () => {
{submitError} {submitError}
</Alert> </Alert>
) : null} ) : null}
<Button type="submit" size="md" loading={form.submitting} rightSection={<IconArrowRight size={18} />}> <Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
{t("create")} {t("create")}
</Button> </Button>
</Stack> </Stack>
+6 -38
View File
@@ -1,16 +1,6 @@
import { parse } from "superjson"; import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
import {
loggerMessageSchema,
LOG_HISTORY_KEY,
LOG_HISTORY_MAX_ENTRIES,
LOG_PUBLISH_CHANNEL,
} from "@homarr/core/infrastructure/logs/constants";
import { createListChannel, createSubPubChannel } from "./lib/channel"; import { createListChannel, createSubPubChannel } from "./lib/channel";
import { ChannelSubscriptionTracker } from "./lib/channel-subscription-tracker";
import { createRedisConnection } from "./lib/connection";
export { export {
handshakeAsync, handshakeAsync,
@@ -25,31 +15,9 @@ export const pingChannel = createSubPubChannel<
>("ping"); >("ping");
export const pingUrlChannel = createListChannel<string>("ping-url"); export const pingUrlChannel = createListChannel<string>("ping-url");
export type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants"; export interface LoggerMessage {
message: string;
level: LogLevel;
}
const loggingHistoryClient = createRedisConnection(); export const loggingChannel = createSubPubChannel<LoggerMessage>("logging");
const parseLoggerMessage = (message: string): LoggerMessage | null => {
try {
const result = loggerMessageSchema.safeParse(parse<unknown>(message));
return result.success ? result.data : null;
} catch {
return null;
}
};
export const loggingChannel = {
subscribeAsync: (callback: (message: LoggerMessage) => void) =>
ChannelSubscriptionTracker.subscribeAsync(LOG_PUBLISH_CHANNEL, (message) => {
const parsedMessage = parseLoggerMessage(message);
if (parsedMessage) callback(parsedMessage);
}),
getRecentAsync: async () => {
const messages =
(await loggingHistoryClient?.lrange(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1).catch(() => [])) ?? [];
return messages
.map(parseLoggerMessage)
.filter((message): message is LoggerMessage => message !== null)
.toReversed();
},
};
@@ -18,7 +18,6 @@ type SubscriptionCallback = (message: string) => MaybePromise<void>;
*/ */
export class ChannelSubscriptionTracker { export class ChannelSubscriptionTracker {
private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>(); private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>();
private static subscriptionReadiness = new Map<string, Promise<void>>();
private static redis = createRedisConnection(); private static redis = createRedisConnection();
private static listenerActive = false; private static listenerActive = false;
@@ -29,25 +28,6 @@ export class ChannelSubscriptionTracker {
* @returns a function to unsubscribe from the channel * @returns a function to unsubscribe from the channel
*/ */
public static subscribe(channelName: string, callback: SubscriptionCallback) { public static subscribe(channelName: string, callback: SubscriptionCallback) {
return this.registerSubscription(channelName, callback).unsubscribe;
}
/**
* Subscribes to a channel and resolves once Redis confirms the subscription.
*/
public static async subscribeAsync(channelName: string, callback: SubscriptionCallback) {
const subscription = this.registerSubscription(channelName, callback);
try {
await subscription.ready;
return subscription.unsubscribe;
} catch (error) {
subscription.unsubscribe();
throw error;
}
}
private static registerSubscription(channelName: string, callback: SubscriptionCallback) {
logger.debug("Adding redis channel callback", { channel: channelName }); logger.debug("Adding redis channel callback", { channel: channelName });
// We only want to activate the listener once // We only want to activate the listener once
@@ -58,18 +38,11 @@ export class ChannelSubscriptionTracker {
const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>(); const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>();
const id = randomUUID(); const id = randomUUID();
let ready = this.subscriptionReadiness.get(channelName);
// Subscribe when this channel has no active or pending Redis subscription. // If there are no subscriptions to the channel, subscribe to it
if (!ready) { if (channelSubscriptions.size === 0) {
logger.debug("Subscribing to redis channel", { channel: channelName }); logger.debug("Subscribing to redis channel", { channel: channelName });
ready = this.redis.subscribe(channelName).then(() => undefined); void this.redis.subscribe(channelName);
this.subscriptionReadiness.set(channelName, ready);
void ready.catch(() => {
if (this.subscriptionReadiness.get(channelName) === ready) {
this.subscriptionReadiness.delete(channelName);
}
});
} }
logger.debug("Adding redis channel callback", { channel: channelName, id }); logger.debug("Adding redis channel callback", { channel: channelName, id });
@@ -77,26 +50,24 @@ export class ChannelSubscriptionTracker {
this.subscriptions.set(channelName, channelSubscriptions); this.subscriptions.set(channelName, channelSubscriptions);
const unsubscribe = () => { // Return a function to unsubscribe
return () => {
logger.debug("Removing redis channel callback", { channel: channelName, id }); logger.debug("Removing redis channel callback", { channel: channelName, id });
const currentSubscriptions = this.subscriptions.get(channelName); const channelSubscriptions = this.subscriptions.get(channelName);
if (!currentSubscriptions) return; if (!channelSubscriptions) return;
currentSubscriptions.delete(id); channelSubscriptions.delete(id);
// If there are no subscriptions to the channel, unsubscribe from it // If there are no subscriptions to the channel, unsubscribe from it
if (currentSubscriptions.size >= 1) { if (channelSubscriptions.size >= 1) {
return; return;
} }
logger.debug("Unsubscribing from redis channel", { channel: channelName }); logger.debug("Unsubscribing from redis channel", { channel: channelName });
void this.redis.unsubscribe(channelName); void this.redis.unsubscribe(channelName);
this.subscriptions.delete(channelName); this.subscriptions.delete(channelName);
this.subscriptionReadiness.delete(channelName);
}; };
return { ready, unsubscribe };
} }
/** /**
+107 -60
View File
@@ -236,6 +236,61 @@
} }
} }
}, },
"universalCreate": {
"title": "Create",
"analyticsError": "Usage metrics could not be recorded. Your action was not affected.",
"trigger": {
"label": "Create something",
"description": "Add content, connect a service, or create a board from one place."
},
"search": {
"label": "Search create actions",
"placeholder": "What do you want to create?",
"noResults": "No matching actions"
},
"group": {
"currentBoard": "Current board",
"library": "Apps and services",
"boards": "Boards"
},
"action": {
"widget": {
"label": "Add widget",
"description": "Add live information or controls to this board.",
"keywords": "item dashboard content"
},
"app": {
"label": "Add or create app",
"description": "Place an app shortcut on this board or create one for your library.",
"keywords": "shortcut bookmark application"
},
"integration": {
"label": "Connect service",
"description": "Connect a service so widgets can use its data.",
"keywords": "integration provider connection"
},
"container": {
"label": "Add container",
"description": "Group related board content into a resizable section.",
"keywords": "section group layout"
},
"board": {
"label": "Create board",
"description": "Start a new dashboard and add its first widget.",
"keywords": "dashboard page"
},
"workshop": {
"label": "Browse Workshop",
"description": "Discover community-built widgets and install them.",
"keywords": "community discover install"
},
"customWidget": {
"label": "Create custom widget",
"description": "Build a widget tailored to your own workflow.",
"keywords": "code jsx developer"
}
}
},
"user": { "user": {
"title": "Users", "title": "Users",
"name": "User", "name": "User",
@@ -821,12 +876,7 @@
"title": "Select an app to add to this board", "title": "Select an app to add to this board",
"toggle": "Click to select", "toggle": "Click to select",
"selected": "Selected", "selected": "Selected",
"multiple": "Add {count}", "multiple": "Add {count}"
"multiSelectTip": "Tip: Hold Ctrl / ⌘ to select multiple apps at once",
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
"appsSelected": "{count, plural, one {# app selected} other {# apps selected}}",
"customApplication": "Custom application",
"application": "Application"
}, },
"create": { "create": {
"title": "Create new app", "title": "Create new app",
@@ -1671,11 +1721,7 @@
"grid": { "grid": {
"widgetCount": "{count} widgets", "widgetCount": "{count} widgets",
"noWidgets": "No widgets", "noWidgets": "No widgets",
"detected": "Detected via Docker", "detected": "Detected via Docker"
"connected": "{count, plural, one {# connected} other {# connected}}",
"tiedWidgets": "Tied widgets",
"more": "+{count} more",
"loadError": "Integrations could not be loaded. Try again before creating a new connection."
}, },
"category": { "category": {
"dnsHole": "DNS", "dnsHole": "DNS",
@@ -2089,7 +2135,6 @@
"success": "Success", "success": "Success",
"beta": "Beta", "beta": "Beta",
"error": "Error", "error": "Error",
"analyticsError": "Setup analytics could not be recorded.",
"action": { "action": {
"add": "Add", "add": "Add",
"apply": "Apply", "apply": "Apply",
@@ -2264,6 +2309,7 @@
"open": "Open Homarr Assistant", "open": "Open Homarr Assistant",
"close": "Close assistant", "close": "Close assistant",
"minimize": "Minimize assistant", "minimize": "Minimize assistant",
"windowControls": "Conversation window controls",
"spotlight": "Ask Homarr Assistant", "spotlight": "Ask Homarr Assistant",
"spotlightDescription": "Type and send a prompt without leaving the command menu.", "spotlightDescription": "Type and send a prompt without leaving the command menu.",
"spotlightPrompt": { "spotlightPrompt": {
@@ -2281,24 +2327,6 @@
"newConversation": "New conversation", "newConversation": "New conversation",
"conversations": "Conversations", "conversations": "Conversations",
"delete": "Delete", "delete": "Delete",
"cancel": "Cancel",
"conversationActions": "Conversation actions",
"searchConversations": "Search conversations",
"noMatchingConversations": "No matching conversations",
"moreActions": "More response actions",
"renameConversation": {
"action": "Rename",
"label": "Conversation name",
"save": "Save name",
"failedTitle": "Conversation could not be renamed",
"failedDescription": "Homarr could not rename this conversation. Try again."
},
"deleteConversation": {
"title": "Delete conversation?",
"description": "“{title}” and its messages will be permanently deleted.",
"failedTitle": "Conversation could not be deleted",
"failedDescription": "Homarr could not delete this conversation. Try again."
},
"closeHistory": "Close conversation history", "closeHistory": "Close conversation history",
"openHistory": "Open conversation history", "openHistory": "Open conversation history",
"emptyTitle": "What can I help with?", "emptyTitle": "What can I help with?",
@@ -2371,8 +2399,6 @@
"regenerate": "Regenerate response", "regenerate": "Regenerate response",
"helpful": "Helpful", "helpful": "Helpful",
"notHelpful": "Not helpful", "notHelpful": "Not helpful",
"readAloud": "Read response aloud",
"stopReading": "Stop reading response",
"feedbackError": "Your feedback could not be saved. Try again.", "feedbackError": "Your feedback could not be saved. Try again.",
"exportMarkdown": "Export as Markdown", "exportMarkdown": "Export as Markdown",
"exportConversation": { "exportConversation": {
@@ -2401,6 +2427,7 @@
}, },
"mentions": { "mentions": {
"menu": "Add Homarr context", "menu": "Add Homarr context",
"hint": "Type @ to add context",
"context": "Context", "context": "Context",
"app": "Apps", "app": "Apps",
"integration": "Integrations", "integration": "Integrations",
@@ -2415,6 +2442,7 @@
}, },
"commands": { "commands": {
"menu": "Assistant commands", "menu": "Assistant commands",
"hint": "Type / for shortcuts",
"health": "Prepare a service-health check", "health": "Prepare a service-health check",
"explore": "Prepare an overview of this Homarr instance", "explore": "Prepare an overview of this Homarr instance",
"media": "Prepare a media search", "media": "Prepare a media search",
@@ -2527,6 +2555,7 @@
"otherPlaceholder": "Type another answer…", "otherPlaceholder": "Type another answer…",
"submit": "Send answer", "submit": "Send answer",
"cancelOther": "Cancel other answer", "cancelOther": "Cancel other answer",
"automaticAffirmative": "Choosing the affirmative answer automatically",
"optionKind": { "optionKind": {
"affirmative": "Confirm", "affirmative": "Confirm",
"negative": "Decline", "negative": "Decline",
@@ -2601,7 +2630,7 @@
"autoApproval": { "autoApproval": {
"label": "Automatic approvals", "label": "Automatic approvals",
"title": "Automatic approvals", "title": "Automatic approvals",
"description": "Automatically prepare forms and approve protected changes for this conversation. Questions still wait for your answer, and your Homarr permissions still apply. This turns off when you switch conversations.", "description": "Automatically approve protected changes, use presented form details, and choose answers categorized as affirmative. Neutral selections still wait for you. Your Homarr permissions still apply. This turns off when you switch conversations.",
"enable": "Enable for this conversation", "enable": "Enable for this conversation",
"disable": "Turn off automatic approvals", "disable": "Turn off automatic approvals",
"enabled": "Automatic approvals are on", "enabled": "Automatic approvals are on",
@@ -2765,8 +2794,6 @@
"needsSetup": "Needs setup", "needsSetup": "Needs setup",
"noConnectionRequired": "No connection required" "noConnectionRequired": "No connection required"
}, },
"customWidget": "Custom widget",
"standalone": "Standalone",
"missingIntegration": { "missingIntegration": {
"title": "Connect a service first", "title": "Connect a service first",
"noPermission": "This widget needs a compatible service connection. Ask an administrator to connect one, then try again." "noPermission": "This widget needs a compatible service connection. Ask an administrator to connect one, then try again."
@@ -2783,20 +2810,6 @@
} }
} }
}, },
"selection": {
"summary": "{count, plural, one {# selected item} other {# selected items}}",
"selected": "{count, plural, one {# selected} other {# selected}}",
"removed": "{count, plural, one {# item removed} other {# items removed}}",
"move": "Move",
"moveTo": "Move selected items",
"delete": "Delete",
"clear": "Clear selection",
"clearShortcut": "Clear selection (Escape)",
"saveErrorTitle": "Unable to update items",
"saveErrorMessage": "The changes could not be saved. The board was refreshed.",
"moveUnavailableTitle": "Unable to move selected items",
"moveUnavailableMessage": "The destination does not have enough room for the entire selection."
},
"moveResize": { "moveResize": {
"title": "Move / resize item", "title": "Move / resize item",
"entryLabel": "{name}, column {column}, row {row}", "entryLabel": "{name}, column {column}, row {row}",
@@ -3436,6 +3449,50 @@
"notificationsDenied": "Notifications are blocked in this browser." "notificationsDenied": "Notifications are blocked in this browser."
} }
}, },
"today": {
"name": "Today",
"description": "Displays the current date and progress through the calendar.",
"option": {
"weekConvention": {
"label": "Week numbering",
"options": {
"locale": "Use locale rules",
"iso": "ISO 8601"
}
},
"showWeekNumber": {
"label": "Show week number"
},
"showDayOfYear": {
"label": "Show day of year"
},
"showQuarter": {
"label": "Show quarter"
},
"showYearProgress": {
"label": "Show year progress"
}
},
"weekNumber": "Week {week}",
"dayOfYear": "Day {day} of {total}",
"quarter": "Quarter {quarter}",
"daysAfterToday": "{days, plural, =0 {Last day of the year} one {# day after today} other {# days after today}}",
"weekProgressLabel": "Week progress",
"monthProgressLabel": "Month progress",
"yearProgressLabel": "Year progress",
"yearProgress": "Year progress: {progress}%",
"weekConvention": {
"locale": "Week numbers follow your locale",
"iso": "ISO 8601 week numbers"
},
"advanced": {
"week": "Week number",
"dayOfYear": "Day of year",
"quarter": "Quarter",
"quarterValue": "Q{quarter}",
"daysRemaining": "Days after today"
}
},
"minecraftServerStatus": { "minecraftServerStatus": {
"name": "Minecraft Server Status", "name": "Minecraft Server Status",
"description": "Displays the status of a Minecraft server", "description": "Displays the status of a Minecraft server",
@@ -6026,13 +6083,7 @@
"compact": { "compact": {
"title": "Assistant", "title": "Assistant",
"description": "Resize this widget to chat here, or open the full conversation.", "description": "Resize this widget to chat here, or open the full conversation.",
"open": "Open assistant", "open": "Open assistant"
"openTitle": "Assistant open in side panel",
"openDescription": "The conversation stays in one active surface.",
"returnToWidget": "Return the conversation to this widget",
"otherWidgetTitle": "Assistant active in another widget",
"otherWidgetDescription": "Only one conversation surface stays active at a time.",
"useHere": "Use the assistant in this widget"
} }
} }
}, },
@@ -8673,10 +8724,6 @@
} }
}, },
"log": { "log": {
"context": {
"widgetError": "Widget reported an error around this time",
"expiredWidgetError": "Logs around this widget error are no longer retained"
},
"level": { "level": {
"option": { "option": {
"debug": "Debug", "debug": "Debug",
-7
View File
@@ -13,13 +13,6 @@ export { CustomPasswordInput } from "./password-input/password-input";
export { IntegrationAvatar } from "./integration-avatar"; export { IntegrationAvatar } from "./integration-avatar";
export { BetaBadge } from "./beta-badge"; export { BetaBadge } from "./beta-badge";
export { CatalogItem } from "./catalog-item"; export { CatalogItem } from "./catalog-item";
export { SelectableCard } from "./selectable-card";
export type {
SelectableCardProps,
SelectableCardStylesNames,
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
export { MaskedImage } from "./masked-image"; export { MaskedImage } from "./masked-image";
export { MaskedOrNormalImage } from "./masked-or-normal-image"; export { MaskedOrNormalImage } from "./masked-or-normal-image";
export { LanguageIcon } from "./language-icon"; export { LanguageIcon } from "./language-icon";
@@ -1,7 +0,0 @@
export { SelectableCard } from "./selectable-card";
export type {
SelectableCardProps,
SelectableCardStylesNames,
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
@@ -1,79 +0,0 @@
.root {
cursor: pointer;
text-align: start;
display: flex;
flex-direction: column;
position: relative;
width: 100%;
min-height: 175px;
height: 100%;
padding: var(--mantine-spacing-sm);
border-radius: var(--selectable-card-radius, var(--mantine-radius-md));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
border: 1px solid var(--mantine-color-default-border);
box-shadow: var(--mantine-shadow-xs);
transition:
transform 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
overflow: hidden;
font-family: inherit;
color: inherit;
text-decoration: none;
}
.root:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--mantine-shadow-sm);
border-color: var(--mantine-primary-color-filled);
}
.root:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.root[data-selected] {
border-color: var(--mantine-primary-color-filled);
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
}
.root:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.header {
border-bottom: 1px solid var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
margin: calc(var(--mantine-spacing-sm) * -1);
margin-bottom: 0;
padding-left: var(--mantine-spacing-sm);
padding-right: var(--mantine-spacing-sm);
}
.body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-xs);
margin-bottom: var(--mantine-spacing-xs);
min-height: 0;
}
.footer {
border-top: 1px dashed var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
margin: calc(var(--mantine-spacing-sm) * -1);
margin-top: auto;
padding-left: var(--mantine-spacing-sm);
padding-right: var(--mantine-spacing-sm);
min-height: 40px;
display: flex;
align-items: center;
}
@@ -1,7 +0,0 @@
declare const classes: {
readonly root: string;
readonly header: string;
readonly body: string;
readonly footer: string;
};
export default classes;
@@ -1,370 +0,0 @@
// @vitest-environment jsdom
import { act, createRef } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { Badge, MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SelectableCard } from "./selectable-card";
import classes from "./selectable-card.module.css";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("SelectableCard", () => {
describe("rendering slots", () => {
it("renders as a native button with default button type and accessible slots", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
aria-label="Sonarr Card"
icon={<span data-testid="card-icon">Icon</span>}
title="Sonarr"
topRight={<Badge data-testid="card-topright">Media</Badge>}
description="Manage TV series downloads"
footerLeft={<span data-testid="card-footer-left">Left Meta</span>}
footerRight={<span data-testid="card-footer-right">Right Meta</span>}
onClick={onClick}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button).not.toBeNull();
expect(button?.type).toBe("button");
expect(button?.getAttribute("aria-label")).toBe("Sonarr Card");
expect(host.querySelector("[data-testid='card-icon']")).not.toBeNull();
expect(host.textContent).toContain("Sonarr");
expect(host.querySelector("[data-testid='card-topright']")).not.toBeNull();
expect(host.textContent).toContain("Manage TV series downloads");
expect(host.querySelector("[data-testid='card-footer-left']")).not.toBeNull();
expect(host.querySelector("[data-testid='card-footer-right']")).not.toBeNull();
await act(async () => button?.click());
expect(onClick).toHaveBeenCalledOnce();
});
it("renders only header when only header slots (icon, title, topRight) are provided", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
icon={<span data-testid="only-icon">I</span>}
title="Only Header"
topRight={<span data-testid="only-topright">TR</span>}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.querySelector(`.${classes.header}`)).not.toBeNull();
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
expect(host.querySelector("[data-testid='only-icon']")).not.toBeNull();
expect(host.querySelector("[data-testid='only-topright']")).not.toBeNull();
});
it("renders footer when only footerLeft or only footerRight is provided", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" footerLeft={<span data-testid="left-only">Left</span>} />
</MantineProvider>,
),
);
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
expect(host.querySelector("[data-testid='left-only']")).not.toBeNull();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" footerRight={<span data-testid="right-only">Right</span>} />
</MantineProvider>,
),
);
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
expect(host.querySelector("[data-testid='right-only']")).not.toBeNull();
});
it("applies Styles API classes for root, header, body, and footer", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Plex"
description="Media Server"
footerLeft={<span>Footer</span>}
classNames={{
root: "custom-root",
header: "custom-header",
body: "custom-body",
footer: "custom-footer",
}}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.classList.contains(classes.root)).toBe(true);
expect(button?.classList.contains("custom-root")).toBe(true);
const header = button?.querySelector(`.${classes.header}`);
expect(header).not.toBeNull();
expect(header?.classList.contains("custom-header")).toBe(true);
const body = button?.querySelector(`.${classes.body}`);
expect(body).not.toBeNull();
expect(body?.classList.contains("custom-body")).toBe(true);
const footer = button?.querySelector(`.${classes.footer}`);
expect(footer).not.toBeNull();
expect(footer?.classList.contains("custom-footer")).toBe(true);
});
it("prioritizes title without wrapping and constrains topRight badge with ellipsis", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Very Long Title Name That Should Not Wrap"
topRight={<Badge>Category Badge</Badge>}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
const header = button?.querySelector(`.${classes.header}`);
expect(header).not.toBeNull();
const titleText = header?.querySelector("p, span, div");
expect(titleText?.textContent).toContain("Very Long Title Name That Should Not Wrap");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title={<span data-testid="custom-title-node">Custom Title Element</span>}
topRight={<span data-testid="custom-top-right">Top Right</span>}
/>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='custom-title-node']")).not.toBeNull();
expect(host.querySelector("[data-testid='custom-top-right']")).not.toBeNull();
});
it("renders custom children in the body slot over description", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" description="This should not appear">
<div data-testid="custom-body-children">Custom Child Content</div>
</SelectableCard>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='custom-body-children']")).not.toBeNull();
expect(host.textContent).toContain("Custom Child Content");
expect(host.textContent).not.toContain("This should not appear");
});
it("renders an empty description without fallback copy", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="No Desc Item" description="" />
</MantineProvider>,
),
);
expect(host.textContent).toContain("No Desc Item");
expect(host.textContent).not.toContain("No description provided");
});
it("omits header, body, or footer when corresponding props are omitted", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.querySelector(`.${classes.header}`)).toBeNull();
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
});
});
describe("states", () => {
it("handles selected state via data-selected attribute", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Selected Item" selected />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.getAttribute("data-selected")).toBe("true");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Unselected Item" selected={false} />
</MantineProvider>,
),
);
expect(button?.hasAttribute("data-selected")).toBe(false);
});
it("handles disabled state and prevents click handlers", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Disabled Item" disabled onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.disabled).toBe(true);
await act(async () => button?.click());
expect(onClick).not.toHaveBeenCalled();
});
it("handles loading state by disabling the button and showing LoadingOverlay", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Loading Item" loading onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.disabled).toBe(true);
await act(async () => button?.click());
expect(onClick).not.toHaveBeenCalled();
});
});
describe("interactions and accessibility", () => {
it("supports focus and click activation", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Interactive Card" onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
button?.focus();
expect(document.activeElement).toBe(button);
await act(async () => {
button?.click();
});
expect(onClick).toHaveBeenCalledOnce();
});
it("forwards ref to the underlying HTMLButtonElement", async () => {
const ref = createRef<HTMLButtonElement>();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard ref={ref} title="Ref Card" />
</MantineProvider>,
),
);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
expect(ref.current?.tagName).toBe("BUTTON");
});
it("resolves radius CSS variables via varsResolver", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Radius Card" radius="lg" />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Radius Card Sm" radius="sm" />
</MantineProvider>,
),
);
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-sm)");
});
it("passes arbitrary HTML attributes down to the button element", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Custom Props Card"
data-testid="my-selectable-card"
data-custom="custom-value"
tabIndex={0}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.getAttribute("data-testid")).toBe("my-selectable-card");
expect(button?.getAttribute("data-custom")).toBe("custom-value");
expect(button?.tabIndex).toBe(0);
});
});
});
@@ -1,177 +0,0 @@
"use client";
import type { ReactNode } from "react";
import {
Box,
createVarsResolver,
factory,
Group,
LoadingOverlay,
Text,
Tooltip,
useProps,
useStyles,
} from "@mantine/core";
import type { BoxProps, ElementProps, Factory, MantineRadius, StylesApiProps } from "@mantine/core";
import classes from "./selectable-card.module.css";
export type SelectableCardStylesNames = "root" | "header" | "body" | "footer";
export type SelectableCardCssVariables = {
root: "--selectable-card-radius";
};
export interface SelectableCardProps
extends BoxProps, StylesApiProps<SelectableCardFactory>, Omit<ElementProps<"button">, "title"> {
icon?: ReactNode;
title?: ReactNode;
topRight?: ReactNode;
description?: string | null;
footerLeft?: ReactNode;
footerRight?: ReactNode;
selected?: boolean;
loading?: boolean;
disabled?: boolean;
radius?: MantineRadius;
children?: ReactNode;
}
export type SelectableCardFactory = Factory<{
props: SelectableCardProps;
ref: HTMLButtonElement;
stylesNames: SelectableCardStylesNames;
vars: SelectableCardCssVariables;
}>;
const defaultProps: Partial<SelectableCardProps> = {
type: "button",
};
const varsResolver = createVarsResolver<SelectableCardFactory>((_theme, { radius }) => ({
root: {
"--selectable-card-radius": radius ? `var(--mantine-radius-${radius})` : undefined,
},
}));
export const SelectableCard = factory<SelectableCardFactory>((_props) => {
const props = useProps("SelectableCard", defaultProps, _props);
const {
ref,
classNames,
className,
style,
styles,
unstyled,
vars,
attributes,
icon,
title,
topRight,
description,
footerLeft,
footerRight,
selected,
loading = false,
disabled = false,
children,
type = "button",
...others
} = props;
const getStyles = useStyles<SelectableCardFactory>({
name: "SelectableCard",
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
attributes,
varsResolver,
});
return (
<Box
component="button"
{...others}
type={type}
ref={ref}
disabled={disabled || loading}
data-selected={selected || undefined}
aria-pressed={selected !== undefined ? selected : undefined}
{...getStyles("root")}
>
<LoadingOverlay visible={loading} loaderProps={{ size: "sm" }} />
{/* Header Section: Inset background with Title that never elides before the category */}
{(icon || title || topRight) && (
<Box {...getStyles("header")}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
gap="xs"
style={{ width: "100%", overflow: "hidden" }}
>
<Group gap="xs" wrap="nowrap" style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}>
{icon}
{typeof title === "string" ? (
<Text
fw={700}
size="md"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</Text>
) : (
title
)}
</Group>
{topRight && (
<Box
style={{
minWidth: 0,
flexShrink: 0,
}}
>
{topRight}
</Box>
)}
</Group>
</Box>
)}
{/* Body: Custom Children or Default Description */}
{children ? (
<Box {...getStyles("body")}>{children}</Box>
) : description !== undefined ? (
<Box {...getStyles("body")}>
<Tooltip label={description} multiline w={260} disabled={!description}>
<Text size="sm" c="dimmed" lineClamp={2} lh={1.35}>
{description}
</Text>
</Tooltip>
</Box>
) : null}
{/* Footer Section: Inset Dashed Bottom Meta */}
{(footerLeft || footerRight) && (
<Box {...getStyles("footer")}>
<Group justify="space-between" align="center" wrap="nowrap" gap="xs" style={{ width: "100%" }}>
<Box style={{ minWidth: 0, flexShrink: 1 }}>{footerLeft}</Box>
<Box style={{ minWidth: 0, flexShrink: 0 }}>{footerRight}</Box>
</Group>
</Box>
)}
</Box>
);
});
SelectableCard.displayName = "@homarr/ui/SelectableCard";
SelectableCard.classes = classes;
@@ -1,279 +0,0 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationSelectGrid } from "./integration-select-grid";
vi.mock("@homarr/definitions", async (importOriginal) => {
const actual = await importOriginal<typeof import("@homarr/definitions")>();
return {
...actual,
getWidgetKindsForIntegration: (kind: IntegrationKind) => {
if (kind === "wud") return [];
return actual.getWidgetKindsForIntegration(kind);
},
};
});
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string, values?: { count?: number }) => {
const translations: Record<string, string> = {
"integration.page.list.search": "Search integrations",
"common.noResults": "No results found",
"integration.grid.noWidgets": "No widgets tied to this service",
"integration.grid.tiedWidgets": "Tied Widgets",
"integration.grid.connected": `${values?.count ?? 0} connected`,
"integration.grid.more": `+${values?.count ?? 0} more`,
"widget.mediaServer.name": "Media Server",
"widget.calendar.name": "Calendar",
"widget.downloads.name": "Downloads",
"integration.category.mediaService": "Media Service",
"integration.category.downloadClient": "Download Client",
"integration.category.calendar": "Calendar",
"integration.category.smartHomeServer": "Smart Home",
"integration.category.usenet": "Usenet",
"integration.category.torrent": "Torrent",
};
return translations[key] ?? key;
},
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("IntegrationSelectGrid", () => {
it("renders a 4-column responsive grid of selectable integration cards", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "sabNzbd"]} />
</MantineProvider>,
),
);
const cards = host.querySelectorAll("button");
expect(cards.length).toBe(4);
expect(host.textContent).toContain("Jellyfin");
expect(host.textContent).toContain("Sonarr");
expect(host.textContent).toContain("Radarr");
expect(host.textContent).toContain("SABnzbd");
});
it("renders tied widgets with icons and labels inside integration cards", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin"]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Tied Widgets");
// Jellyfin is tied to Media Server widget
expect(host.textContent).toContain("Media Server");
});
it("displays fallback message when integration has no tied widgets", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["wud"]} />
</MantineProvider>,
),
);
expect(host.querySelector("button")).not.toBeNull();
expect(host.textContent).toContain("No widgets tied to this service");
});
it("displays '{count} connected' in footerLeft when integration instances exist", async () => {
const onSelect = vi.fn();
const integrationData = [
{ kind: "jellyfin" as IntegrationKind, name: "Home Jellyfin" },
{ kind: "jellyfin" as IntegrationKind, name: "Remote Jellyfin" },
{ kind: "sonarr" as IntegrationKind, name: "Sonarr Instance" },
];
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid
onSelect={onSelect}
allowedKinds={["jellyfin", "radarr"]}
integrationData={integrationData}
/>
</MantineProvider>,
),
);
// Jellyfin has 2 connected
expect(host.textContent).toContain("2 connected");
// Radarr has 0 connected, so it doesn't show "0 connected"
expect(host.textContent).not.toContain("0 connected");
});
it("filters integrations dynamically by search input", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "plex"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
expect(searchInput).not.toBeNull();
// Type "jelly"
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "jelly");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
const cards = host.querySelectorAll("button");
expect(cards.length).toBe(1);
expect(host.textContent).toContain("Jellyfin");
expect(host.textContent).not.toContain("Sonarr");
expect(host.textContent).not.toContain("Radarr");
});
it("displays empty state when no integrations match search query", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "nonexistent-integration-query");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(host.textContent).toContain("No results found");
expect(host.querySelectorAll("button").length).toBe(0);
});
it("calls onSelect when Enter is pressed with exactly 1 search match", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "jelly");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await act(async () => {
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onSelect).toHaveBeenCalledWith("jellyfin");
});
it("calls onSelect when an integration card is clicked", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["sonarr"]} />
</MantineProvider>,
),
);
const cardButton = host.querySelector("button");
await act(async () => cardButton?.click());
expect(onSelect).toHaveBeenCalledWith("sonarr");
});
it("includes mock integration when enableMockIntegration is true", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration allowedKinds={["mock", "sonarr"]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Mock");
expect(host.textContent).toContain("Sonarr");
});
it("excludes mock integration when enableMockIntegration is false", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration={false} />
</MantineProvider>,
),
);
const buttons = Array.from(host.querySelectorAll("button"));
const mockButton = buttons.find((btn) => btn.getAttribute("aria-label")?.toLowerCase() === "mock");
expect(mockButton).toBeUndefined();
});
});
@@ -1,68 +1,27 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { import { IconPuzzle } from "@tabler/icons-react";
IconAd,
IconApps,
IconBinaryTree,
IconBookmark,
IconBrandDocker,
IconBrandMinecraft,
IconBrowser,
IconBuildingBank,
IconBusStop,
IconCalendar,
IconChartBar,
IconClock,
IconClockPlay,
IconCloud,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconGraphFilled,
IconHeartRateMonitor,
IconHourglass,
IconMessage,
IconMovie,
IconNotes,
IconPuzzle,
IconReportSearch,
IconRobot,
IconRocket,
IconRss,
IconSearch,
IconServer2,
IconTicket,
IconTopologyFull,
IconTransform,
IconVideo,
IconWall,
IconWind,
IconZoomQuestion,
} from "@tabler/icons-react";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar"; import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectableCard } from "../components/selectable-card"; import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout";
import { selectGridCols } from "../select-grid-layout";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
import type { IntegrationGridItem } from "./integration-grid-shared"; import classes from "./integration-select-grid.module.css";
export interface IntegrationSelectGridProps { export interface IntegrationSelectGridProps {
onSelect: (kind: IntegrationKind) => void; onSelect: (kind: IntegrationKind) => void;
enableMockIntegration?: boolean; enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[]; allowedKinds?: readonly IntegrationKind[];
integrationData?: { kind: IntegrationKind; name?: string }[];
} }
export const IntegrationSelectGrid = ({ export const IntegrationSelectGrid = ({
onSelect, onSelect,
enableMockIntegration = false, enableMockIntegration = false,
allowedKinds, allowedKinds,
integrationData,
}: IntegrationSelectGridProps) => { }: IntegrationSelectGridProps) => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const t = useI18n(); const t = useI18n();
@@ -73,182 +32,75 @@ export const IntegrationSelectGrid = ({
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return ( return (
<Stack gap="md"> <SelectGridLayout
{/* Top Search Input */} search={search}
<Input onSearchChange={setSearch}
value={search} placeholder={`${t("integration.page.list.search")}...`}
onChange={(event) => setSearch(event.currentTarget.value)} ariaLabel={t("integration.page.list.search")}
leftSection={<IconSearch size={16} />} >
placeholder={`${t("integration.page.list.search")}...`} {filtered.map((integration) => (
aria-label={t("integration.page.list.search")} <UnstyledButton
data-autofocus className={classes.choice}
onKeyDown={(event) => { key={integration.kind}
if (event.key === "Enter" && filtered.length === 1 && filtered[0]) { onClick={() => onSelect(integration.kind)}
onSelect(filtered[0].kind); aria-label={integration.name}
>
<Card h={selectGridCardHeight} className={classes.card} withBorder>
<Stack justify="space-between" h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
{integration.name}
</Text>
</Group>
<IntegrationGridMeta integration={integration} />
</Stack>
</Card>
</UnstyledButton>
))}
{filtered.length === 0 && (
<Center p="xl" style={{ gridColumn: "1 / -1" }}>
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
);
};
const IntegrationGridMeta = ({ integration }: { integration: ReturnType<typeof buildSortedIntegrations>[number] }) => {
const t = useI18n();
return (
<Stack gap={4} mt="auto">
<Group gap={4} wrap="wrap">
{integration.categories.slice(0, 2).map((category) => (
<Badge key={category} variant="light" size="xs">
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
</Badge>
))}
</Group>
{integration.widgets.length > 0 ? (
<Tooltip
multiline
w={200}
label={
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{t(`widget.${widgetKind}.name`)}
</Text>
))}
</Stack>
} }
}} >
/> <Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
{/* Scrollable Container with Integration Cards */} </Badge>
<ScrollArea.Autosize mah="70vh" offsetScrollbars> </Tooltip>
<Stack gap="md" pt="xs" pr="xs" px={4}> ) : (
<SimpleGrid cols={selectGridCols} spacing="sm"> <Badge variant="light" color="gray" size="sm">
{filtered.map((integration) => ( {t("integration.grid.noWidgets")}
<IntegrationCard </Badge>
key={integration.kind} )}
integration={integration}
onSelect={onSelect}
connectedCount={(integrationData ?? []).filter((i) => i.kind === integration.kind).length}
/>
))}
</SimpleGrid>
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</Stack>
</ScrollArea.Autosize>
</Stack> </Stack>
); );
}; };
// =========================================================================
// IntegrationCard: Shared Modular SelectableCard with Tied Widgets
// =========================================================================
const IntegrationCard = ({
integration,
onSelect,
connectedCount = 0,
}: {
integration: IntegrationGridItem;
onSelect: (kind: IntegrationKind) => void;
connectedCount?: number;
}) => {
const t = useI18n();
return (
<SelectableCard
onClick={() => onSelect(integration.kind)}
aria-label={integration.name}
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
title={integration.name}
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />}
footerLeft={
connectedCount > 0 ? (
<Badge variant="light" color="teal" size="xs" radius="xs">
{t("integration.grid.connected", { count: connectedCount })}
</Badge>
) : null
}
>
<Text size="10px" tt="uppercase" fw={700} c="dimmed">
{t("integration.grid.tiedWidgets")}
</Text>
<IntegrationTiedWidgets widgets={integration.widgets} />
</SelectableCard>
);
};
const widgetIconsMap: Partial<Record<WidgetKind, typeof IconPuzzle>> = {
clock: IconClock,
weather: IconCloud,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
app: IconApps,
assistant: IconRobot,
iframe: IconBrowser,
video: IconDeviceCctv,
notebook: IconNotes,
anchorNote: IconNotes,
dnsHoleSummary: IconAd,
dnsHoleControls: IconDeviceGamepad,
"smartHome-entityState": IconBinaryTree,
"smartHome-executeAutomation": IconBinaryTree,
stockPrice: IconBuildingBank,
mediaServer: IconVideo,
calendar: IconCalendar,
downloads: IconDownload,
"mediaRequests-requestList": IconZoomQuestion,
"mediaRequests-requestStats": IconChartBar,
mediaTranscoding: IconTransform,
mediaMissing: IconMovie,
minecraftServerStatus: IconBrandMinecraft,
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
healthMonitoring: IconHeartRateMonitor,
releases: IconRocket,
mediaReleases: IconTicket,
dockerContainers: IconBrandDocker,
firewall: IconWall,
notifications: IconMessage,
systemResources: IconGraphFilled,
coolify: IconCloud,
systemDisks: IconServer2,
timetable: IconBusStop,
};
const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => {
const t = useI18n();
if (widgets.length === 0) {
return (
<Text size="xs" c="dimmed" fs="italic">
{t("integration.grid.noWidgets")}
</Text>
);
}
const items = limit ? widgets.slice(0, limit) : widgets;
const moreCount = limit ? widgets.length - limit : 0;
return (
<Group gap={4} wrap="wrap">
{items.map((widgetKind) => {
const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle;
return (
<Badge
key={widgetKind}
variant="default"
radius="xs"
size="xs"
leftSection={<WidgetIcon size={12} />}
style={{ fontWeight: 500 }}
>
{t(`widget.${widgetKind}.name`)}
</Badge>
);
})}
{moreCount > 0 && (
<Badge variant="subtle" color="gray" size="xs" radius="xs">
{t("integration.grid.more", { count: moreCount })}
</Badge>
)}
</Group>
);
};
const IntegrationCategoryBadges = ({
categories,
limit,
}: {
categories: IntegrationGridItem["categories"];
limit?: number;
}) => {
const t = useI18n();
const items = limit ? categories.slice(0, limit) : categories;
return (
<Group gap={4} wrap="wrap">
{items.map((category) => (
<Badge key={category} variant="light" size="xs" color="gray" radius="xs">
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
</Badge>
))}
</Group>
);
};
+1 -3
View File
@@ -13,9 +13,7 @@
}, },
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./inputs": "./src/_inputs/index.ts", "./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./inputs/form": "./src/_inputs/form.ts",
"./inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx", "./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx", "./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts", "./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
@@ -17,12 +17,6 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common"; import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form"; import { useFormContext } from "./form";
const renderHereLink = (children: React.ReactNode) => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{children}
</Anchor>
);
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => { export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
const t = useI18n(); const t = useI18n();
const tInput = useWidgetInputTranslation(kind, property); const tInput = useWidgetInputTranslation(kind, property);
@@ -55,9 +49,13 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
} }
inputWrapperOrder={["label", "input", "description", "error"]} inputWrapperOrder={["label", "input", "description", "error"]}
description={ description={
<Text component="span" size="xs"> <Text size="xs">
{t.rich("widget.common.app.description", { {t.rich("widget.common.app.description", {
here: renderHereLink, here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
})} })}
</Text> </Text>
} }
+1 -2
View File
@@ -26,7 +26,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
const t = useI18n(); const t = useI18n();
const settings = useSettings(); const settings = useSettings();
const board = useRequiredBoard(); const board = useRequiredBoard();
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) }); const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
const app = getUsableWidgetQueryData(appQuery); const app = getUsableWidgetQueryData(appQuery);
const href = getSafeAppHref(app?.href); const href = getSafeAppHref(app?.href);
useRegisterSpotlightContextResults( useRegisterSpotlightContextResults(
@@ -50,7 +50,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, href, options.openInNewTab], [app, href, options.openInNewTab],
); );
if (!options.appId) return <WidgetEmptyState />;
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />; if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />; if (!app) return <WidgetEmptyState />;
+2
View File
@@ -15,6 +15,7 @@ import {
IconBuildingBank, IconBuildingBank,
IconBusStop, IconBusStop,
IconCalendar, IconCalendar,
IconCalendarWeek,
IconChartAreaLine, IconChartAreaLine,
IconChartBar, IconChartBar,
IconClock, IconClock,
@@ -59,6 +60,7 @@ import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = { export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock, clock: IconClock,
weather: IconCloud, weather: IconCloud,
today: IconCalendarWeek,
airQuality: IconWind, airQuality: IconWind,
countdown: IconHourglass, countdown: IconHourglass,
timer: IconClockPlay, timer: IconClockPlay,
+2 -12
View File
@@ -1,7 +1,5 @@
import { useState } from "react";
import { Anchor, Button, Stack, Text } from "@mantine/core"; import { Anchor, Button, Stack, Text } from "@mantine/core";
import { useSession } from "@homarr/auth/client";
import type { stringOrTranslation } from "@homarr/translation"; import type { stringOrTranslation } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -17,22 +15,14 @@ export interface BaseWidgetErrorProps {
export const BaseWidgetError = (props: BaseWidgetErrorProps) => { export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
const t = useI18n(); const t = useI18n();
const { data: session } = useSession();
const [errorTimestamp] = useState(Date.now);
return ( return (
<Stack h="100%" align="center" justify="center" gap="md" data-homarr-widget-error> <Stack h="100%" align="center" justify="center" gap="md" data-homarr-widget-error>
<props.icon size={40} /> <props.icon size={40} />
<Stack gap={0}> <Stack gap={0}>
<Text ta="center">{translateIfNecessary(t, props.message)}</Text> <Text ta="center">{translateIfNecessary(t, props.message)}</Text>
{props.showLogsLink && session?.user.permissions.includes("other-view-logs") && ( {props.showLogsLink && (
<Anchor <Anchor component={Link} href="/manage/tools/logs" target="_blank" ta="center" size="sm">
component={Link}
href={`/manage/tools/logs?focus=${errorTimestamp}`}
target="_blank"
ta="center"
size="sm"
>
{t("common.action.checkLogs")} {t("common.action.checkLogs")}
</Anchor> </Anchor>
)} )}
@@ -11,10 +11,6 @@ vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => key, useI18n: () => (key: string) => key,
})); }));
vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: null }),
}));
const matchMedia = (query: string) => ({ const matchMedia = (query: string) => ({
matches: false, matches: false,
media: query, media: query,
+2
View File
@@ -63,6 +63,7 @@ import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats"; import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video"; import * as video from "./video";
import * as weather from "./weather"; import * as weather from "./weather";
import * as today from "./today";
import * as airQuality from "./air-quality"; import * as airQuality from "./air-quality";
import * as countdown from "./countdown"; import * as countdown from "./countdown";
import * as timer from "./timer"; import * as timer from "./timer";
@@ -97,6 +98,7 @@ export { AssistantWidgetRendererProvider } from "./assistant/context";
export const widgetImports = { export const widgetImports = {
clock, clock,
weather, weather,
today,
airQuality, airQuality,
countdown, countdown,
timer, timer,
+2
View File
@@ -24,6 +24,7 @@ export type WidgetResources = {
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = { const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
clock: () => import("./clock") as Promise<WidgetModule>, clock: () => import("./clock") as Promise<WidgetModule>,
weather: () => import("./weather") as Promise<WidgetModule>, weather: () => import("./weather") as Promise<WidgetModule>,
today: () => import("./today") as Promise<WidgetModule>,
airQuality: () => import("./air-quality") as Promise<WidgetModule>, airQuality: () => import("./air-quality") as Promise<WidgetModule>,
countdown: () => import("./countdown") as Promise<WidgetModule>, countdown: () => import("./countdown") as Promise<WidgetModule>,
timer: () => import("./timer") as Promise<WidgetModule>, timer: () => import("./timer") as Promise<WidgetModule>,
@@ -88,6 +89,7 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = { const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
clock: () => import("./clock/component") as Promise<WidgetComponentModule>, clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
weather: () => import("./weather/component") as Promise<WidgetComponentModule>, weather: () => import("./weather/component") as Promise<WidgetComponentModule>,
today: () => import("./today/component") as Promise<WidgetComponentModule>,
airQuality: () => import("./air-quality/component") as Promise<WidgetComponentModule>, airQuality: () => import("./air-quality/component") as Promise<WidgetComponentModule>,
countdown: () => import("./countdown/component") as Promise<WidgetComponentModule>, countdown: () => import("./countdown/component") as Promise<WidgetComponentModule>,
timer: () => import("./timer/component") as Promise<WidgetComponentModule>, timer: () => import("./timer/component") as Promise<WidgetComponentModule>,
@@ -58,7 +58,6 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
appId?: string; appId?: string;
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>; integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void; onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
} }
export const getSelectedWidgetIntegrations = ( export const getSelectedWidgetIntegrations = (
@@ -218,17 +217,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
handleSubmit(event); handleSubmit(event);
}; };
const canCreateIntegration = maxIntegrations === 1 || form.values.integrationIds.length < maxIntegrations;
const handleOpenNewIntegration = innerProps.onOpenNewIntegration
? () => {
innerProps.onOpenNewIntegration?.((newId) => {
if (newId) {
form.setFieldValue("integrationIds", [...(maxIntegrations > 1 ? form.values.integrationIds : []), newId]);
}
});
}
: undefined;
const widgetFormContent = ( const widgetFormContent = (
<Stack> <Stack>
{canConfigureWidget && innerProps.integrationSupport && ( {canConfigureWidget && innerProps.integrationSupport && (
@@ -237,7 +225,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
data={innerProps.integrationData} data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1} canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired} withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")} {...form.getInputProps("integrationIds")}
/> />
)} )}
@@ -426,5 +413,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return t("item.edit.title"); return t("item.edit.title");
}, },
size: modalSizeForm, size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false, closeOnClickOutside: false,
}); });
+223
View File
@@ -0,0 +1,223 @@
"use client";
import {
Badge,
Box,
Group,
Paper,
Progress,
RingProgress,
ScrollArea,
SimpleGrid,
Stack,
Text,
VisuallyHidden,
} from "@mantine/core";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { formatLocalizedDate } from "../common/locale";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
import { getTodayLayout, getTodayStats } from "./date-stats";
const formatProgress = (value: number) => Math.round(value);
export default function TodayWidget({ options, width, height, displayMode }: WidgetComponentProps<"today">) {
const now = useWidgetNow("minute");
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.today");
if (now === null) {
return (
<Stack h="100%" align="center" justify="center">
<Text c="dimmed">—</Text>
</Stack>
);
}
const stats = getTodayStats(now, options.weekConvention, locale);
if (displayMode === "advanced") {
return <AdvancedToday now={now} locale={locale} options={options} stats={stats} />;
}
const layout = getTodayLayout(width, height);
const weekday = formatLocalizedDate(now, locale, { weekday: "long" });
const month = formatLocalizedDate(now, locale, { month: "long" });
const fullDate = formatLocalizedDate(now, locale, { dateStyle: "long" });
if (layout.tiny) {
return (
<Stack h="100%" align="center" justify="center" gap={0} px="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{weekday} · {month}
</Text>
<Text component="time" dateTime={toDateTime(now)} fz={34} fw={700} lh={1}>
{now.getDate()}
</Text>
</Stack>
);
}
return (
<Stack h="100%" justify="center" gap="xs" px="md" py="sm" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{weekday}
</Text>
<Text component="time" dateTime={toDateTime(now)} fz={layout.showFullDate ? "lg" : 30} fw={700} truncate>
{layout.showFullDate ? fullDate : `${month} ${now.getDate()}`}
</Text>
</Stack>
{options.showYearProgress && (
<Box>
<RingProgress
aria-hidden
size={54}
thickness={5}
sections={[{ value: stats.yearProgress, color: "blue" }]}
label={
<Text size="xs" ta="center" fw={700}>
{formatProgress(stats.yearProgress)}%
</Text>
}
/>
<VisuallyHidden>
<progress value={stats.yearProgress} max={100}>
{t("yearProgress", { progress: formatProgress(stats.yearProgress) })}
</progress>
</VisuallyHidden>
</Box>
)}
</Group>
{layout.showStats && (
<Group gap={6} wrap="wrap">
{options.showWeekNumber && <Badge variant="light">{t("weekNumber", { week: stats.weekNumber })}</Badge>}
{options.showDayOfYear && (
<Badge variant="light">{t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })}</Badge>
)}
{layout.showQuarter && options.showQuarter && (
<Badge variant="light">{t("quarter", { quarter: stats.quarter })}</Badge>
)}
{layout.showQuarter && (
<Text size="xs" c="dimmed">
{t("daysAfterToday", { days: stats.daysAfterToday })}
</Text>
)}
</Group>
)}
{layout.showMonthProgress && <ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />}
</Stack>
);
}
interface AdvancedTodayProps {
now: Date;
locale: string;
options: WidgetComponentProps<"today">["options"];
stats: ReturnType<typeof getTodayStats>;
}
const AdvancedToday = ({ now, locale, options, stats }: AdvancedTodayProps) => {
const t = useScopedI18n("widget.today");
const numberFormatter = new Intl.NumberFormat(locale);
const visibleMetrics = [
options.showWeekNumber && { label: t("advanced.week"), value: numberFormatter.format(stats.weekNumber) },
options.showDayOfYear && {
label: t("advanced.dayOfYear"),
value: `${numberFormatter.format(stats.dayOfYear)} / ${numberFormatter.format(stats.daysInYear)}`,
},
options.showQuarter && {
label: t("advanced.quarter"),
value: t("advanced.quarterValue", { quarter: stats.quarter }),
},
{ label: t("advanced.daysRemaining"), value: numberFormatter.format(stats.daysAfterToday) },
].filter((metric): metric is { label: string; value: string } => Boolean(metric));
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack gap="md" p="md">
<Stack gap={0}>
<Text component="time" dateTime={toDateTime(now)} fz="xl" fw={700}>
{formatLocalizedDate(now, locale, { dateStyle: "full" })}
</Text>
{options.showWeekNumber && (
<Text size="sm" c="dimmed">
{t(`weekConvention.${options.weekConvention}`)}
</Text>
)}
</Stack>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{visibleMetrics.map((metric) => (
<Paper key={metric.label} withBorder p="sm">
<Text size="xs" c="dimmed">
{metric.label}
</Text>
<Text fz="xl" fw={700}>
{metric.value}
</Text>
</Paper>
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<ProgressMetric label={t("weekProgressLabel")} value={stats.weekProgress} />
<ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />
{options.showYearProgress && <ProgressMetric label={t("yearProgressLabel")} value={stats.yearProgress} />}
</SimpleGrid>
<SimpleGrid cols={{ base: 3, sm: 4, md: 6 }} spacing="xs">
{Array.from({ length: 12 }, (_, monthIndex) => {
const date = new Date(now.getFullYear(), monthIndex, 1);
const isCurrent = monthIndex === stats.monthIndex;
return (
<Paper
key={monthIndex}
withBorder
p="xs"
bg={isCurrent ? "var(--mantine-primary-color-light)" : undefined}
>
<Text size="sm" fw={isCurrent ? 700 : 500} ta="center">
{formatLocalizedDate(date, locale, { month: "short" })}
</Text>
{isCurrent && (
<Text size="xs" c="dimmed" ta="center">
{now.getDate()}
</Text>
)}
</Paper>
);
})}
</SimpleGrid>
</Stack>
</ScrollArea>
);
};
const ProgressMetric = ({ label, value }: { label: string; value: number }) => {
const roundedValue = formatProgress(value);
return (
<Box>
<Group justify="space-between" gap="xs" mb={3}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={600}>
{roundedValue}%
</Text>
</Group>
<Progress value={value} aria-label={`${label}: ${roundedValue}%`} />
</Box>
);
};
const toDateTime = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { getTodayLayout, getTodayStats } from "./date-stats";
describe("today date statistics", () => {
it("accounts for leap years", () => {
const stats = getTodayStats(new Date(2024, 11, 31, 12), "iso");
expect(stats.dayOfYear).toBe(366);
expect(stats.daysInYear).toBe(366);
expect(stats.daysAfterToday).toBe(0);
});
it("uses the requested week convention", () => {
const date = new Date(2023, 0, 1, 12);
expect(getTodayStats(date, "iso").weekNumber).toBe(52);
expect(getTodayStats(date, "locale", "en-US").weekNumber).toBe(1);
expect(getTodayStats(date, "locale", "de-DE").weekNumber).toBe(52);
});
it("returns bounded calendar progress", () => {
const stats = getTodayStats(new Date(2026, 7, 20, 12), "iso");
expect(stats.quarter).toBe(3);
expect(stats.weekProgress).toBeGreaterThanOrEqual(0);
expect(stats.weekProgress).toBeLessThanOrEqual(100);
expect(stats.monthProgress).toBeGreaterThanOrEqual(0);
expect(stats.monthProgress).toBeLessThanOrEqual(100);
expect(stats.yearProgress).toBeGreaterThanOrEqual(0);
expect(stats.yearProgress).toBeLessThanOrEqual(100);
});
});
describe("today compact layout", () => {
it("keeps tiny widgets focused on the date", () => {
expect(getTodayLayout(140, 120)).toMatchObject({ tiny: true, showStats: false });
});
it("reveals secondary information only when both axes have room", () => {
expect(getTodayLayout(240, 120)).toMatchObject({ showStats: true, showQuarter: false });
expect(getTodayLayout(300, 180)).toEqual({
tiny: false,
showFullDate: true,
showStats: true,
showQuarter: true,
showMonthProgress: true,
});
});
});
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more