Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
193a536a2d | ||
|
|
c8df2fbe5f | ||
|
|
7c4c1ffbe7 | ||
|
|
84b000afda | ||
|
|
7710abcf26 | ||
|
|
4c475b8c0a | ||
|
|
f3014fe271 | ||
|
|
bd4f6a92c6 | ||
|
|
7e8c139590 | ||
|
|
d241bac55d | ||
|
|
58a24569ce | ||
|
|
53b518960c | ||
|
|
ddf8ba9948 |
No files matched your search
@@ -0,0 +1,60 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,4 @@
|
||||
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."
|
||||
@@ -374,21 +374,31 @@ jobs:
|
||||
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||
run: |
|
||||
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
|
||||
tags=(-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}@${ARM64_DIGEST}"
|
||||
|
||||
- name: Verify preview manifest platforms
|
||||
if: "!env.ACT"
|
||||
run: |
|
||||
image="${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||
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
|
||||
tags=("${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=("pr-6545")
|
||||
fi
|
||||
for tag in "${tags[@]}"; do
|
||||
image="${PREVIEW_IMAGE}:${tag}"
|
||||
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
|
||||
docker buildx imagetools inspect "$image"
|
||||
|
||||
cleanup:
|
||||
name: Clean up preview image
|
||||
|
||||
@@ -27,37 +27,103 @@ jobs:
|
||||
- run: pnpm test:workshop
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish:
|
||||
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: 30
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v6
|
||||
id: metadata
|
||||
with:
|
||||
images: ghcr.io/homarr-labs/workshop
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=v2,enable=${{ github.ref_name == 'v2' || github.ref_name == 'release/v2' }}
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
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:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: [publish-amd64, publish-arm64]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
WORKSHOP_IMAGE: ghcr.io/homarr-labs/workshop
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish multi-platform image
|
||||
env:
|
||||
AMD64_DIGEST: ${{ needs.publish-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.publish-arm64.outputs.digest }}
|
||||
run: |
|
||||
tags=(-t "${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}")
|
||||
if [[ "${GITHUB_REF_NAME}" == "v2" || "${GITHUB_REF_NAME}" == "release/v2" ]]; then
|
||||
tags+=(-t "${WORKSHOP_IMAGE}:v2")
|
||||
fi
|
||||
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
|
||||
tags+=(-t "${WORKSHOP_IMAGE}:latest")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${WORKSHOP_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${WORKSHOP_IMAGE}@${ARM64_DIGEST}"
|
||||
- name: Verify multi-platform image
|
||||
run: |
|
||||
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"
|
||||
@@ -25,6 +25,8 @@ packages/db/migrations/*/migrate.cjs
|
||||
|
||||
# production
|
||||
build
|
||||
tools/homarr-dev/homarr
|
||||
tools/homarr-dev/homarr-dev
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -1,43 +1,175 @@
|
||||
# Developer Docker CLI
|
||||
|
||||
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Browse development images
|
||||
## Requirements
|
||||
|
||||
- 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
|
||||
pnpm dev:cli --
|
||||
# or with global binary:
|
||||
homarr
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Build images
|
||||
### Multi-Source Sidebar
|
||||
|
||||
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
|
||||
pnpm dev:cli -- build <name>
|
||||
pnpm dev:cli -- build --pr <number> [name]
|
||||
homarr dash
|
||||
# or
|
||||
pnpm dev:cli -- dash
|
||||
```
|
||||
|
||||
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.
|
||||
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
|
||||
|
||||
## Rebuild images
|
||||
| Key | Action |
|
||||
| ------------------ | ---------------------------------------- |
|
||||
| `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
|
||||
pnpm dev:cli -- rebuild <name>
|
||||
homarr run dev
|
||||
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/
|
||||
```
|
||||
|
||||
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.
|
||||
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
|
||||
- `--demo` (`-m`): Start with mock integrations and sample data.
|
||||
- `--detach`: Start container in background.
|
||||
- `--env` (`-e`): Pass additional environment variables (repeatable: `--env KEY=VALUE`).
|
||||
|
||||
## Run images
|
||||
### Build & Rebuild
|
||||
|
||||
```bash
|
||||
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
|
||||
homarr run <name>
|
||||
homarr run --pr <number>
|
||||
# Build current checkout as homarr:<name>
|
||||
homarr build feature
|
||||
|
||||
# Build a pull request locally in a temporary checkout
|
||||
homarr build --pr 6441
|
||||
|
||||
# Rebuild an existing image from recorded provenance
|
||||
homarr rebuild feature
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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,3 +25,27 @@ You should also create a backup of the database, in case you break something.
|
||||
4. Click "Execute" in the top right of the Window. 
|
||||
5. Select /bin/bash and click Execute 
|
||||
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) 
|
||||
|
||||
## 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`.
|
||||
@@ -44,12 +44,15 @@ host gateway address.
|
||||
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.
|
||||
|
||||
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
|
||||
summary of accessible Homarr resources. Live status is fetched only when needed.
|
||||
|
||||
## Permissions and privacy
|
||||
|
||||
- Read-only actions can run automatically. Changes require approval.
|
||||
- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the
|
||||
current conversation, turn off when you switch conversations, and never answer questions for you.
|
||||
- Homarr permissions apply to every tool call and mentioned item.
|
||||
- API keys and custom headers are encrypted and never returned to the browser.
|
||||
- Conversations are stored per user and can be exported or deleted.
|
||||
|
||||
@@ -204,10 +204,13 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
||||
Spotlight. It lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
||||
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.
|
||||
While edit mode is active, **Add board content** opens a menu for adding a widget, app, integration, or Container. The
|
||||
available actions respect the current user's permissions. 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,
|
||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||
@@ -227,11 +230,7 @@ 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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.
|
||||
@@ -13,7 +13,7 @@ tags:
|
||||
The settings page applies options to the whole Homarr application.
|
||||
Some changes need a full page reload before they take effect.
|
||||
|
||||
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||
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.
|
||||
|
||||
## Analytics
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "Air Quality & UV"
|
||||
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location."
|
||||
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 { airQualityWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={airQualityWidget} categories={["Health", "Weather"]} />
|
||||
|
||||
The Air Quality & UV widget shows the current air quality index for a selected location. It adds the UV index,
|
||||
dominant pollutant, particulate matter and the highest available pollen reading as space allows. Open the advanced
|
||||
view for a 24-hour AQI and UV chart, all available pollutant and pollen readings, and seven-day daily maxima.
|
||||
|
||||
Select the European or US AQI scale, or use the automatic setting. Automatic uses the US scale for viewers whose
|
||||
locale region is the United States and the European scale everywhere else. The selected scale stays in widget options
|
||||
instead of being repeated beside every reading. Pollen values are individual measured concentrations; the widget does
|
||||
not combine them into a single pollen index.
|
||||
|
||||
The categories and colors summarize published index bands and are not medical advice. Pollutant concentrations use
|
||||
micrograms per cubic meter (`µg/m³`), and pollen uses grains per cubic meter.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={airQualityWidget.configuration} />
|
||||
|
||||
### Data source
|
||||
|
||||
Homarr uses the free Open-Meteo Air Quality API. The widget refreshes active data every 15 minutes and requests a
|
||||
seven-day forecast for European AQI, US AQI, UV, particulate matter, ozone, nitrogen dioxide, sulphur dioxide, carbon
|
||||
monoxide, and available alder, birch, grass, mugwort, olive and ragweed pollen. Identical location requests reuse the
|
||||
server response for 15 minutes. If a refresh fails, Homarr can keep a recent successful response visible for up to 24
|
||||
hours instead of replacing it with an empty state.
|
||||
|
||||
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. If you need to allowlist the
|
||||
service, use `https://air-quality-api.open-meteo.com`.
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { WidgetDefinition } from "@site/src/types";
|
||||
import { IconWind } from "@tabler/icons-react";
|
||||
|
||||
export const airQualityWidget: WidgetDefinition = {
|
||||
icon: IconWind,
|
||||
name: "Air Quality & UV",
|
||||
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location.",
|
||||
path: "../../widgets/air-quality",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Air quality location",
|
||||
description: "Location used for air quality, UV and pollen data",
|
||||
values: "Select a location through search or longitude/latitude",
|
||||
defaultValue: "Paris / 48.85341, 2.3488",
|
||||
},
|
||||
{
|
||||
name: "AQI standard",
|
||||
description: "Choose the European or US air quality scale, or select one from the viewer's locale",
|
||||
values: { type: "select", options: ["Automatic for locale", "European AQI", "US AQI"] },
|
||||
defaultValue: "Automatic for locale",
|
||||
},
|
||||
{
|
||||
name: "Show UV index",
|
||||
description: "Display the current UV index and its forecast",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show pollutant concentrations",
|
||||
description: "Display particulate matter and gaseous pollutant concentrations",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show pollen concentrations",
|
||||
description: "Display available pollen concentrations",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -15,6 +15,26 @@ import { clockWidget } from ".";
|
||||
|
||||
The clock widget displays the current date and time in a customizable format.
|
||||
|
||||
Advanced view turns it into a World Clock. The configured primary timezone stays prominent while New York, Paris, and
|
||||
Tokyo are included as secondary defaults. You can add, rename, remove, and reorder up to six cities in widget options;
|
||||
London, Sydney, and UTC are also available as quick presets.
|
||||
|
||||
The digital advanced view keeps the primary timezone prominent and presents every secondary city as a concise,
|
||||
comparable card. The primary clock includes a segmented 24-hour rail with a current-time marker, elapsed and remaining
|
||||
time, six-hour ticks, and a legend for night, dawn, day, and dusk. These phases are based on local clock time and are not
|
||||
astronomical sunrise or sunset calculations. Secondary cards stop after their localized date and timezone offsets.
|
||||
|
||||
The compact widget remains a single numeric clock. The time-format selector shows common Day.js presets with live
|
||||
examples and applies to the primary clock. Choosing a format with seconds enables second-level updates; 12-hour and
|
||||
24-hour display are selected through those same formats. The date-format selector likewise
|
||||
shows live Day.js examples and their tokens. Secondary cities always include a short localized date and a
|
||||
Yesterday, Today, or Tomorrow label relative to the primary timezone. UTC and relative offsets follow current timezone
|
||||
rules, including daylight-saving changes.
|
||||
|
||||
Weather remains optional and uses its own configured location rather than a World Clock city. Its icon is static by
|
||||
default and can be animated explicitly; reduced-motion preferences always disable animation. An unsupported saved
|
||||
timezone is identified without preventing valid clocks from rendering.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import localClock from "./img/local.png";
|
||||
@@ -27,7 +47,7 @@ import tokyoClock from "./img/tokyo-12-hour.png";
|
||||
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
|
||||
</div>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -20,18 +20,6 @@ export const clockWidget: WidgetDefinition = {
|
||||
values: { type: "string" },
|
||||
defaultValue: "-",
|
||||
},
|
||||
{
|
||||
name: "24-hour format",
|
||||
description: "Use 24-hour format instead of 12-hour format.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Display seconds",
|
||||
description: "Whether to show seconds in the clock.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Use fixed timezone",
|
||||
description: "Select custom timezone to display instead of the client timezone.",
|
||||
@@ -52,21 +40,46 @@ export const clockWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Date format",
|
||||
description: "Format of the date displayed below the clock.",
|
||||
values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.",
|
||||
description: "Choose a common Day.js date format with a live example and its tokens.",
|
||||
values: "YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, or common long and short date formats",
|
||||
defaultValue: "dddd, MMMM D",
|
||||
},
|
||||
{
|
||||
name: "Custom time format",
|
||||
description: "Format of the time displayed in the clock.",
|
||||
values: 'ISO 8601 format, e.g. "HH:mm:ss", "hh:mm A", etc.',
|
||||
defaultValue: "-",
|
||||
name: "Time format",
|
||||
description:
|
||||
"Choose a common Day.js time format with a live example. The format controls 12/24-hour display and seconds.",
|
||||
values: "HH:mm, HH:mm:ss, h:mm A, or h:mm:ss A",
|
||||
defaultValue: "HH:mm",
|
||||
},
|
||||
{
|
||||
name: "Custom date format",
|
||||
description: "Format of the date displayed below the clock.",
|
||||
values: 'ISO 8601 format, e.g. "YYYY-MM-DD", "DD/MM/YYYY", etc.',
|
||||
defaultValue: "-",
|
||||
name: "Show weather",
|
||||
description: "Show a compact weather summary from an independently configured location.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Weather location",
|
||||
description: 'Location used by the weather summary. Only shown if "Show weather" is enabled.',
|
||||
values: "A searched city or custom latitude and longitude",
|
||||
defaultValue: "Brisbane",
|
||||
},
|
||||
{
|
||||
name: "Use Fahrenheit",
|
||||
description: 'Use Fahrenheit in the weather summary. Only shown if "Show weather" is enabled.',
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Animate weather icon",
|
||||
description: "Animate the weather icon unless the device requests reduced motion.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "World clock cities",
|
||||
description: "Add, label, and order up to six IANA timezones for advanced view.",
|
||||
values: "Up to six labeled IANA timezones",
|
||||
defaultValue: "New York, Paris, and Tokyo",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: "Countdowns"
|
||||
description: "Tracks upcoming events and recurring anniversaries."
|
||||
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 { countdownWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={countdownWidget} categories={["Time"]} />
|
||||
|
||||
The Countdowns widget tracks up to 20 events without an integration. Add each event in widget options, choose its local
|
||||
date and time, and select an IANA timezone. Events can be reordered by dragging them.
|
||||
|
||||
The compact widget shows the first configured event, so dragging an event to the top selects the countdown displayed on
|
||||
the board. Advanced view uses that same event for its larger summary and preserves the configured order within its
|
||||
upcoming and completed groups. List cards keep their occurrence date at the bottom, below the optional progress bar.
|
||||
|
||||
One-time events remain completed after their target. A yearly event keeps the same local date and wall-clock time in its
|
||||
configured timezone, including when daylight-saving offsets change. A February 29 event waits until the next leap year.
|
||||
If a configured local time falls in a daylight-saving gap, the first valid time afterward is used; if it occurs twice,
|
||||
the earlier occurrence is used.
|
||||
|
||||
Progress is optional. One-time events need a start date before a percentage can be calculated. Yearly events show
|
||||
progress from the previous occurrence to the next. When enabled, seconds remain visible for every upcoming event.
|
||||
|
||||
Browser notifications are an optional local preference in advanced view and are disabled by default. Permission is
|
||||
requested only after you enable notifications, and alerts can fire once per occurrence while Homarr is open. Browser
|
||||
storage uses the board and widget identifiers to keep preferences separate and prevent duplicate alerts; clearing site
|
||||
data resets them. Widget previews do not persist alert preferences.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={countdownWidget.configuration} />
|
||||
@@ -0,0 +1,31 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconHourglass } from "@tabler/icons-react";
|
||||
|
||||
export const countdownWidget: WidgetDefinition = {
|
||||
icon: IconHourglass,
|
||||
name: "Countdowns",
|
||||
description: "Tracks upcoming events and recurring anniversaries.",
|
||||
path: "../../widgets/countdown",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Events",
|
||||
description: "Add, edit, and order one-time or yearly events with a date, time, and IANA timezone.",
|
||||
values: "Up to 20 events",
|
||||
defaultValue: "No events",
|
||||
},
|
||||
{
|
||||
name: "Show progress",
|
||||
description: "Show progress from an optional start date, or between yearly occurrences.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show seconds",
|
||||
description: "Keep seconds visible for every upcoming event, including events more than a day away.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: "Timer / Pomodoro"
|
||||
description: "Runs a simple timer or a configurable Pomodoro sequence."
|
||||
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 { timerWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={timerWidget} categories={["Productivity", "Time"]} />
|
||||
|
||||
The Timer / Pomodoro widget runs entirely in your browser and does not require an integration. The compact widget
|
||||
always keeps its start or pause button and reset button visible. A running timer uses a saved deadline, so reopening
|
||||
the board or returning to a background tab restores the correct remaining time instead of restarting the countdown.
|
||||
|
||||
Timer mode uses one configured duration. Pomodoro mode starts with a focus session, follows most completed focus
|
||||
sessions with a short break, and uses a long break after the configured number of focus sessions. Automatic break and
|
||||
focus starts are separate options and are disabled by default. When automatic starts are enabled, elapsed phases are
|
||||
reconciled from their original deadlines after the board returns to the foreground.
|
||||
|
||||
Advanced view shows the current sequence, configured durations, and local alert preferences. Browser notifications are
|
||||
requested only when enabled. Notification and sound preferences, live progress, deadlines, and cycle position are
|
||||
stored locally using the board and widget identifiers. They are separate for each browser or device, are available to
|
||||
guest viewers, and are removed by clearing site data. Widget previews are ephemeral and do not write local timer state.
|
||||
|
||||
Timer sounds are enabled by default and use the same low-volume Tiks sound system as onboarding: start and pause use
|
||||
rising and falling soft-theme cues, reset uses a short click, and phase completion uses the glass theme's resonant
|
||||
notification ding. You can disable them in advanced view. The cues respect the device's reduced-motion preference and
|
||||
supplement the visible timer state rather than replacing it.
|
||||
|
||||
When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the
|
||||
board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at
|
||||
300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border.
|
||||
The current phase stays pinned to the compact widget's top-left corner and wraps when needed. Timer text and controls
|
||||
use the dashboard theme's responsive size scale, with secondary details hidden when the widget preview is narrow or
|
||||
short.
|
||||
|
||||
Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them.
|
||||
The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited
|
||||
from the widget context menu.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={timerWidget.configuration} />
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { WidgetDefinition } from "@site/src/types";
|
||||
import { IconClockPlay } from "@tabler/icons-react";
|
||||
|
||||
export const timerWidget: WidgetDefinition = {
|
||||
icon: IconClockPlay,
|
||||
name: "Timer / Pomodoro",
|
||||
description: "Runs a simple timer or a configurable Pomodoro sequence.",
|
||||
path: "../../widgets/timer",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Mode",
|
||||
description: "Run one reusable timer or a repeating focus and break sequence",
|
||||
values: { type: "select", options: ["Timer", "Pomodoro"] },
|
||||
defaultValue: "Pomodoro",
|
||||
},
|
||||
{
|
||||
name: "Timer minutes",
|
||||
description: "Duration of the reusable timer",
|
||||
values: "1-1440 minutes",
|
||||
defaultValue: "10",
|
||||
},
|
||||
{
|
||||
name: "Focus minutes",
|
||||
description: "Duration of each Pomodoro focus session",
|
||||
values: "1-180 minutes",
|
||||
defaultValue: "25",
|
||||
},
|
||||
{
|
||||
name: "Short break minutes",
|
||||
description: "Duration of breaks between most focus sessions",
|
||||
values: "1-60 minutes",
|
||||
defaultValue: "5",
|
||||
},
|
||||
{
|
||||
name: "Long break minutes",
|
||||
description: "Duration of the break at the end of a Pomodoro cycle",
|
||||
values: "1-180 minutes",
|
||||
defaultValue: "15",
|
||||
},
|
||||
{
|
||||
name: "Focus sessions before a long break",
|
||||
description: "Number of completed focus sessions in one Pomodoro cycle",
|
||||
values: "1-12 sessions",
|
||||
defaultValue: "4",
|
||||
},
|
||||
{
|
||||
name: "Automatically start breaks",
|
||||
description: "Start the next short or long break when a focus session completes",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Automatically start focus sessions",
|
||||
description: "Start the next focus session when a break completes",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -13,23 +13,21 @@ import { weatherWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
|
||||
|
||||
The Weather Widget uses your location to display the current, highest and lowest temperature.
|
||||
In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch.
|
||||
The compact forecast shows up to the configured number of days. The advanced view combines current conditions with the
|
||||
available daily forecast, including humidity, wind, gusts, sunrise and sunset. Both views reuse the same cached Open
|
||||
Meteo response.
|
||||
The Weather Widget displays current conditions and a responsive forecast for a selected location. New widgets start
|
||||
with the city and a three-day forecast visible. As the widget gets smaller, it keeps the current condition and
|
||||
temperature readable before removing secondary details or forecast days.
|
||||
|
||||
### Screenshots
|
||||
Select a forecast day to inspect precipitation, apparent temperature, humidity, wind, UV index, sunrise, sunset and
|
||||
daylight duration with a mouse, keyboard or touch. Open the advanced view for a current-condition summary, a 24-hour
|
||||
temperature and precipitation chart, and the complete seven-day forecast. The chart has an equivalent data table for
|
||||
assistive technologies.
|
||||
|
||||
import forecastWeather from "./img/forecast.png";
|
||||
import currentWeather from "./img/current.png";
|
||||
import advancedWeather from "./img/advanced-view.png";
|
||||
The **Show humidity** option controls humidity across the compact current-conditions view, advanced summary, hourly
|
||||
details, and daily forecast details. Forecast cards use a roomier adaptive grid in advanced view, with four columns on
|
||||
the standard desktop surface and fewer columns as the surface narrows.
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<img src={forecastWeather} alt="Weather compact forecast" width={238} height={112} />
|
||||
<img src={currentWeather} alt="Weather compact current conditions" width={198} height={93} />
|
||||
<img src={advancedWeather} alt="Weather advanced view with seven-day forecast details" width={800} height={484} />
|
||||
</div>
|
||||
Weather icons are static by default. Animation can be enabled in the widget configuration, but the operating system's
|
||||
reduced-motion preference always disables it.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
@@ -43,5 +41,10 @@ import advancedWeather from "./img/advanced-view.png";
|
||||
|
||||
### Data source
|
||||
|
||||
We use the Open Meteo API to fetch the weather and locations.
|
||||
We use the Open Meteo API to fetch the weather and locations. Active weather data refreshes every ten minutes, while
|
||||
identical location requests can reuse a server response for five minutes. A manual refresh may therefore return the
|
||||
same still-current server response. If a refresh fails, Homarr keeps the last successful data visible and shows an
|
||||
error indicator.
|
||||
|
||||
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests.
|
||||
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
|
||||
@@ -28,8 +28,24 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show humidity",
|
||||
description: "Display humidity in current conditions, hourly details, and daily forecasts",
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Animate weather icons",
|
||||
description: "Animate condition icons unless the operating system requests reduced motion",
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Use imperial speed units",
|
||||
description: "Display wind speed in mph instead of km/h",
|
||||
@@ -59,7 +75,7 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Has forecast",
|
||||
@@ -67,13 +83,13 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Amount of forecast days",
|
||||
description: "Number of days to show in the forecast",
|
||||
values: "1-7",
|
||||
defaultValue: "5",
|
||||
defaultValue: "3",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
|
||||
import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
|
||||
import { CommonWidgetProps, WidgetCard } from "./card";
|
||||
import clsx from "clsx";
|
||||
|
||||
@@ -6,21 +6,27 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => {
|
||||
return (
|
||||
<WidgetCard width={1} className={clsx("gap-2", className)}>
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
<IconCloud size={20} />
|
||||
<span className="text-xl font-bold">10.8°C</span>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
<IconWind size={14} />
|
||||
<span className="text-sm">5 km/h</span>
|
||||
</div>
|
||||
<div className="flex gap-1 justify-center items-center">
|
||||
<div className="flex justify-center items-center">
|
||||
<IconArrowUpRight size={14} />
|
||||
<span className="text-sm">13.5°C</span>
|
||||
<IconSun size={22} />
|
||||
<div className="flex flex-col leading-none">
|
||||
<span className="text-xl font-bold">18.4°C</span>
|
||||
<span className="text-xs text-muted-foreground">Paris</span>
|
||||
</div>
|
||||
<div className="flex justify-center items-center">
|
||||
<IconArrowDownRight size={14} />
|
||||
<span className="text-sm">6.4°C</span>
|
||||
</div>
|
||||
<div className="flex justify-evenly items-center w-full">
|
||||
<div className="flex flex-col items-center text-xs">
|
||||
<span>Mon</span>
|
||||
<IconSun size={16} />
|
||||
<span>21°</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-xs">
|
||||
<span>Tue</span>
|
||||
<IconCloud size={16} />
|
||||
<span>19°</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-xs">
|
||||
<span>Wed</span>
|
||||
<IconCloudRain size={16} />
|
||||
<span>16°</span>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetCard>
|
||||
|
||||
+3
@@ -2106,6 +2106,9 @@
|
||||
"enum": [
|
||||
"clock",
|
||||
"weather",
|
||||
"airQuality",
|
||||
"countdown",
|
||||
"timer",
|
||||
"app",
|
||||
"iframe",
|
||||
"video",
|
||||
|
||||
@@ -23,6 +23,8 @@ import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-
|
||||
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
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";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
@@ -63,71 +65,74 @@ export const ClientBoard = () => {
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
<BoardSelectionProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
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
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
<BoardSelectionToolbar />
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</BoardSelectionProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
|
||||
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-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";
|
||||
|
||||
export default function BoardEditActions() {
|
||||
@@ -17,9 +22,13 @@ export default function BoardEditActions() {
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -29,29 +38,69 @@ const AddMenu = () => {
|
||||
useEffect(() => {
|
||||
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
||||
handledInitialAdd.current = true;
|
||||
addWidget();
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("add");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [addWidget, board.id, pathname, router, searchParams]);
|
||||
}, [board.id, openItemSelectModal, 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 (
|
||||
<HeaderButton
|
||||
aria-label={t("board.action.addContent")}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
boardActions: {
|
||||
widget: addWidget,
|
||||
app: addApp,
|
||||
integration: canConnectService ? connectService : undefined,
|
||||
container: addContainer,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
{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,6 +29,7 @@ export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) =>
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={AppearanceCustomOption}
|
||||
withinPortal
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Switch, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -12,11 +12,11 @@ import type { FormValues } from "./settings-form";
|
||||
|
||||
interface BoardSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormProps) => {
|
||||
const tBoard = useScopedI18n("management.page.settings.section.board");
|
||||
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("title")}>
|
||||
@@ -26,6 +26,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
withinPortal
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
@@ -34,6 +35,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
withinPortal
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
|
||||
@@ -22,6 +22,7 @@ export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
|
||||
label={tCulture("defaultLocale.label")}
|
||||
{...localeInputProps}
|
||||
value={localeInputProps.value as SupportedLanguage}
|
||||
withinPortal
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Select } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -11,11 +11,11 @@ import type { FormValues } from "./settings-form";
|
||||
|
||||
interface SearchSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
|
||||
}
|
||||
|
||||
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
|
||||
export const SearchSettingsForm = ({ form, selectableSearchEngines }: SearchSettingsFormProps) => {
|
||||
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||
|
||||
return (
|
||||
<SectionCard title={tSearch("title")}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
@@ -58,9 +59,11 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
|
||||
interface SettingsFormProps {
|
||||
initialData: ServerSettings;
|
||||
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
|
||||
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
|
||||
}
|
||||
|
||||
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
||||
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
|
||||
@@ -169,13 +172,13 @@ export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
<BoardSettingsForm form={form} />
|
||||
<BoardSettingsForm form={form} selectableBoards={selectableBoards} />
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
|
||||
@@ -23,12 +23,20 @@ export default async function SettingsPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const serverSettings = await api.serverSettings.getAll();
|
||||
const tSettings = await getScopedI18n("management.page.settings");
|
||||
const [serverSettings, selectableBoards, selectableSearchEngines, tSettings] = await Promise.all([
|
||||
api.serverSettings.getAll(),
|
||||
api.board.getPublicBoards(),
|
||||
api.searchEngine.getSelectable({ withIntegrations: false }),
|
||||
getScopedI18n("management.page.settings"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ManagePageLayout title={tSettings("title")}>
|
||||
<SettingsForm initialData={serverSettings} />
|
||||
<SettingsForm
|
||||
initialData={serverSettings}
|
||||
selectableBoards={selectableBoards}
|
||||
selectableSearchEngines={selectableSearchEngines}
|
||||
/>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const ClientSideTerminalComponent = dynamic(
|
||||
interface ClientSideTerminalComponentProps {
|
||||
focusTimestamp?: number;
|
||||
}
|
||||
|
||||
export const ClientSideTerminalComponent = dynamic<ClientSideTerminalComponentProps>(
|
||||
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
|
||||
{
|
||||
ssr: false,
|
||||
|
||||
@@ -29,12 +29,25 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LogsManagementPage() {
|
||||
interface LogsManagementPageProps {
|
||||
searchParams: Promise<{ focus?: string | string[] }>;
|
||||
}
|
||||
|
||||
export default async function LogsManagementPage({ searchParams }: LogsManagementPageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("other-view-logs")) {
|
||||
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 (
|
||||
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
@@ -45,7 +58,7 @@ export default async function LogsManagementPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideTerminalComponent />
|
||||
<ClientSideTerminalComponent focusTimestamp={focusTimestamp} />
|
||||
</Box>
|
||||
</LogContextProvider>
|
||||
);
|
||||
|
||||
@@ -7,25 +7,163 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
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 classes from "./terminal.module.css";
|
||||
|
||||
export const TerminalComponent = () => {
|
||||
const ALL_LOG_LEVELS = [...logLevels];
|
||||
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 { activeLevels, fontSize } = useLogContext();
|
||||
const t = useI18n();
|
||||
|
||||
const terminalRef = useRef<Terminal>(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(
|
||||
{
|
||||
levels: activeLevels,
|
||||
levels: ALL_LOG_LEVELS,
|
||||
},
|
||||
{
|
||||
onData(data) {
|
||||
terminalRef.current?.writeln(data.message);
|
||||
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
||||
onData(event) {
|
||||
if (event.type === "history") {
|
||||
messagesRef.current = event.messages.slice(-LOG_HISTORY_MAX_ENTRIES);
|
||||
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) {
|
||||
alert(err);
|
||||
@@ -44,22 +182,26 @@ export const TerminalComponent = () => {
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize,
|
||||
fontSize: initialFontSizeRef.current,
|
||||
scrollback: TERMINAL_SCROLLBACK_LINES,
|
||||
});
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
|
||||
setTimeout(() => {
|
||||
const fitTimeout = window.setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
redrawTerminal();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(fitTimeout);
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -69,5 +211,9 @@ export const TerminalComponent = () => {
|
||||
fitAddonRef.current?.fit();
|
||||
}, [fontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
redrawTerminal();
|
||||
}, [activeLevels, focusTimestamp]);
|
||||
|
||||
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
||||
};
|
||||
@@ -104,6 +104,46 @@ describe("assistant automatic approval tracker", () => {
|
||||
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 () => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
|
||||
@@ -52,6 +52,8 @@ export const AssistantAutoApprovalProvider = ({
|
||||
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
|
||||
const retryTimersRef = useRef(new Map<string, number>());
|
||||
const previousConversationIdRef = useRef(conversationId);
|
||||
const conversationMatches = previousConversationIdRef.current === conversationId;
|
||||
const effectiveEnabled = conversationMatches && enabled;
|
||||
|
||||
const clearRetryTimer = useCallback((toolCallId: string) => {
|
||||
const timer = retryTimersRef.current.get(toolCallId);
|
||||
@@ -86,7 +88,7 @@ export const AssistantAutoApprovalProvider = ({
|
||||
|
||||
const requestAction = useCallback(
|
||||
(toolCallId: string, confirm: () => void) => {
|
||||
if (!enabled || !trackerRef.current.claim(toolCallId)) return false;
|
||||
if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false;
|
||||
|
||||
try {
|
||||
confirm();
|
||||
@@ -105,7 +107,7 @@ export const AssistantAutoApprovalProvider = ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[clearRetryTimer, enabled],
|
||||
[clearRetryTimer, effectiveEnabled],
|
||||
);
|
||||
|
||||
const completeAction = useCallback(
|
||||
@@ -116,10 +118,15 @@ export const AssistantAutoApprovalProvider = ({
|
||||
[clearRetryTimer],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }),
|
||||
[completeAction, enabled, requestAction, retryRevision, setEnabled],
|
||||
);
|
||||
const value = useMemo(() => {
|
||||
return {
|
||||
enabled: effectiveEnabled,
|
||||
setEnabled,
|
||||
requestAction,
|
||||
completeAction,
|
||||
retryRevision,
|
||||
};
|
||||
}, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]);
|
||||
|
||||
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -20,11 +20,15 @@ export interface AssistantContextValue {
|
||||
isRunning: boolean;
|
||||
isRefreshing: boolean;
|
||||
unreadCount: number;
|
||||
hasVisibleWidget: boolean;
|
||||
activeWidgetId: string | null;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
sendPrompt: (prompt: string) => boolean;
|
||||
refreshCurrentView: () => Promise<void>;
|
||||
setWidgetVisible: (widgetId: string, visible: boolean) => void;
|
||||
activateWidget: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
export const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||
|
||||
@@ -34,11 +34,15 @@ const DisabledAssistant = ({ children, description }: PropsWithChildren<{ descri
|
||||
isRunning: false,
|
||||
isRefreshing: false,
|
||||
unreadCount: 0,
|
||||
hasVisibleWidget: false,
|
||||
activeWidgetId: null,
|
||||
open: () => undefined,
|
||||
close: () => undefined,
|
||||
toggle: () => undefined,
|
||||
sendPrompt: () => false,
|
||||
refreshCurrentView: () => Promise.resolve(),
|
||||
setWidgetVisible: () => undefined,
|
||||
activateWidget: () => undefined,
|
||||
}),
|
||||
[description],
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ import { AppForm } from "@homarr/forms-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
import { getAssistantAffirmativeResult, getAssistantAskUserOptionKind } from "./assistant-ask-user";
|
||||
import { getAssistantAskUserOptionKind } from "./assistant-ask-user";
|
||||
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
|
||||
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
||||
import classes from "./assistant-panel.module.css";
|
||||
@@ -67,7 +67,6 @@ export const AssistantAskUserTool = ({
|
||||
result,
|
||||
addResult,
|
||||
status,
|
||||
toolCallId,
|
||||
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
|
||||
const t = useScopedI18n("common.assistant.askUser");
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
@@ -75,17 +74,6 @@ export const AssistantAskUserTool = ({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
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(() => {
|
||||
if (showOther) otherInputRef.current?.focus();
|
||||
@@ -165,67 +153,62 @@ export const AssistantAskUserTool = ({
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs" mt="md">
|
||||
{autoConfirming ? (
|
||||
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
|
||||
) : (
|
||||
options.map((option, index) => {
|
||||
const kind = getAssistantAskUserOptionKind(option);
|
||||
const icon =
|
||||
kind === "affirmative" ? (
|
||||
<IconCheck size={17} />
|
||||
) : kind === "negative" ? (
|
||||
<IconX size={17} />
|
||||
) : (
|
||||
<IconArrowRight size={17} />
|
||||
);
|
||||
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
data-kind={kind}
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
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}`)}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
{options.map((option, index) => {
|
||||
const kind = getAssistantAskUserOptionKind(option);
|
||||
const icon =
|
||||
kind === "affirmative" ? (
|
||||
<IconCheck size={17} />
|
||||
) : kind === "negative" ? (
|
||||
<IconX size={17} />
|
||||
) : (
|
||||
<IconArrowRight size={17} />
|
||||
);
|
||||
})
|
||||
)}
|
||||
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||
|
||||
{!autoConfirming &&
|
||||
args.allowOther !== false &&
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
data-kind={kind}
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
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}`)}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
|
||||
{args.allowOther !== false &&
|
||||
(showOther ? (
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
|
||||
@@ -98,18 +98,18 @@
|
||||
container-type: size;
|
||||
position: fixed;
|
||||
z-index: 290;
|
||||
inset-inline-start: 50%;
|
||||
bottom: 1rem;
|
||||
inset-block: 1rem;
|
||||
inset-inline-end: 1rem;
|
||||
inset-inline-start: auto;
|
||||
display: flex;
|
||||
width: min(46rem, calc(100vw - 2rem));
|
||||
height: min(36rem, calc(100dvh - 5rem));
|
||||
min-height: min(22rem, calc(100dvh - 5rem));
|
||||
width: min(34rem, calc(100vw - 2rem));
|
||||
height: auto;
|
||||
min-height: min(30rem, calc(100dvh - 2rem));
|
||||
margin: 0;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
@@ -157,27 +157,28 @@
|
||||
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 {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.windowControls {
|
||||
.newConversationButton {
|
||||
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 {
|
||||
@@ -191,10 +192,6 @@
|
||||
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
@@ -210,6 +207,21 @@
|
||||
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 {
|
||||
padding: 0.5rem 0.65rem 1rem;
|
||||
}
|
||||
@@ -246,9 +258,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
.panelAction,
|
||||
.historyButton {
|
||||
.historyButton,
|
||||
.newConversationButton {
|
||||
width: 2.25rem;
|
||||
max-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
@@ -268,7 +298,11 @@
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
|
||||
.composerContext > :last-child {
|
||||
.composerContextLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panelThreadTitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -298,17 +332,70 @@
|
||||
}
|
||||
|
||||
.historyMenu {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.historyMenu :global(.mantine-ScrollArea-content) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.historyItem:hover {
|
||||
.historyItem:hover,
|
||||
.historyItem[data-active] {
|
||||
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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -341,6 +428,11 @@
|
||||
margin-block: 0.9rem;
|
||||
}
|
||||
|
||||
.message[data-static] {
|
||||
contain-intrinsic-size: auto 12rem;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
.userMessageWrap {
|
||||
width: fit-content;
|
||||
max-width: 86%;
|
||||
@@ -706,24 +798,21 @@
|
||||
|
||||
.pendingQuestionDock {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
max-height: min(22rem, 48dvh);
|
||||
flex: 0 1 auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.thread[data-pending-question] .viewport {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pendingQuestionTarget {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
max-width: 44rem;
|
||||
margin-inline: auto;
|
||||
align-items: safe center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pendingQuestionTarget .humanTool {
|
||||
@@ -1091,6 +1180,43 @@
|
||||
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 {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
@@ -1173,13 +1299,13 @@
|
||||
|
||||
.composerContext {
|
||||
display: flex;
|
||||
width: 1.75rem;
|
||||
width: auto;
|
||||
height: 1.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
padding-inline: 0.3rem 0.42rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: 50%;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
@@ -1415,10 +1541,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
padding-inline: 0.45rem;
|
||||
}
|
||||
|
||||
.composerFooter {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
@@ -1641,12 +1763,12 @@
|
||||
@keyframes assistant-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 0.75rem) scale(0.985);
|
||||
transform: translateY(0.75rem) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1656,10 +1778,11 @@
|
||||
}
|
||||
|
||||
.floatingPanel {
|
||||
bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
|
||||
width: calc(100vw - 1rem);
|
||||
height: min(44rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
|
||||
min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
@@ -1686,10 +1809,6 @@
|
||||
padding-inline: 0.1rem;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.runtimeSelectorTrigger {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -1705,6 +1824,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 30em) {
|
||||
.historyItemMenu {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
import type { MessageStatus } from "@assistant-ui/react";
|
||||
import {
|
||||
ActionBarPrimitive,
|
||||
ActionBarMorePrimitive,
|
||||
AuiIf,
|
||||
AttachmentPrimitive,
|
||||
BranchPickerPrimitive,
|
||||
ChainOfThoughtByIndicesProvider,
|
||||
@@ -66,6 +68,7 @@ import {
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
@@ -91,6 +94,7 @@ import {
|
||||
IconChevronUp,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconDotsVertical,
|
||||
IconFile,
|
||||
IconFileExport,
|
||||
IconLink,
|
||||
@@ -112,6 +116,8 @@ import {
|
||||
IconThumbUp,
|
||||
IconTool,
|
||||
IconTrash,
|
||||
IconVolume,
|
||||
IconVolumeOff,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
@@ -124,6 +130,7 @@ import { useTimeAgo } from "@homarr/common";
|
||||
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
||||
import type { AssistantProvider } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
@@ -184,6 +191,7 @@ interface AssistantPanelProps extends AssistantConversationControls {
|
||||
onClose: () => void;
|
||||
onDismissActivity: () => void;
|
||||
activityDismissed: boolean;
|
||||
hasVisibleWidget: boolean;
|
||||
isRunning: boolean;
|
||||
unreadCount: number;
|
||||
latestAssistantText: string;
|
||||
@@ -1107,63 +1115,43 @@ const downloadAssistantMarkdown = (markdown: string, filename: string) => {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
||||
};
|
||||
|
||||
const AssistantMessageExportAction = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const message = useAuiState((state) => state.message);
|
||||
|
||||
return (
|
||||
<Tooltip label={t("exportMarkdown")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
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`);
|
||||
}}
|
||||
>
|
||||
<IconFileExport size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessageActions = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
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 (
|
||||
<Box className={classes.messageActions}>
|
||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.messageActionBar}>
|
||||
<Group gap={2} wrap="wrap">
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
autohideFloat="single-branch"
|
||||
className={classes.messageActionBar}
|
||||
>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<ProviderMessageInfo />
|
||||
<Tooltip label={t("copy")}>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
|
||||
<IconCopy size={14} />
|
||||
<AuiIf condition={(state) => !state.message.isCopied}>
|
||||
<IconCopy size={14} />
|
||||
</AuiIf>
|
||||
<AuiIf condition={(state) => state.message.isCopied}>
|
||||
<IconCheck size={14} />
|
||||
</AuiIf>
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Copy>
|
||||
</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")}>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
|
||||
@@ -1171,6 +1159,50 @@ const AssistantMessageActions = () => {
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</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>
|
||||
</ActionBarPrimitive.Root>
|
||||
<BranchPicker />
|
||||
@@ -1181,7 +1213,7 @@ const AssistantMessageActions = () => {
|
||||
const UserMessageActions = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
|
||||
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className={classes.userActions}>
|
||||
<Group gap={2}>
|
||||
<Tooltip label={t("copyMessage")}>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
@@ -1225,7 +1257,7 @@ const UserMessage = () => {
|
||||
const isEditing = useAuiState((state) => state.composer.isEditing);
|
||||
if (isEditing) return <EditComposer />;
|
||||
return (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`} data-static>
|
||||
<Box className={classes.userMessage}>
|
||||
<MessagePrimitive.Quote>
|
||||
{(quote) => (
|
||||
@@ -1732,12 +1764,17 @@ const ConversationContext = () => {
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpened((value) => !value)}
|
||||
>
|
||||
<RingProgress
|
||||
size={24}
|
||||
thickness={3}
|
||||
roundCaps
|
||||
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
|
||||
/>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<RingProgress
|
||||
size={22}
|
||||
thickness={3}
|
||||
roundCaps
|
||||
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>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown className={classes.conversationContextPopover}>
|
||||
@@ -1902,60 +1939,80 @@ const WebSearchActivity = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessage = () => (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
|
||||
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
||||
{({ part, children }) => {
|
||||
switch (part.type) {
|
||||
case "group-agent-trace": {
|
||||
const startIndex = part.indices[0];
|
||||
const endIndex = part.indices.at(-1);
|
||||
if (startIndex === undefined || endIndex === undefined) return null;
|
||||
return (
|
||||
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
||||
<AssistantChainOfThought>{children}</AssistantChainOfThought>
|
||||
</ChainOfThoughtByIndicesProvider>
|
||||
);
|
||||
const AssistantMessage = () => {
|
||||
const isComplete = useAuiState((state) => state.message.status?.type === "complete");
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className={`${classes.message} ${classes.assistantMessage}`}
|
||||
data-static={isComplete || undefined}
|
||||
>
|
||||
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
||||
{({ part, children }) => {
|
||||
switch (part.type) {
|
||||
case "group-agent-trace": {
|
||||
const startIndex = part.indices[0];
|
||||
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":
|
||||
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;
|
||||
}
|
||||
}}
|
||||
</MessagePrimitive.GroupedParts>
|
||||
<RuntimeError />
|
||||
<WebSearchActivity />
|
||||
<AssistantMessageActions />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
}}
|
||||
</MessagePrimitive.GroupedParts>
|
||||
<RuntimeError />
|
||||
<WebSearchActivity />
|
||||
<AssistantMessageActions />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const assistantThreadMessageComponents = { UserMessage, AssistantMessage };
|
||||
|
||||
const HistorySelectContext = createContext<() => void>(() => undefined);
|
||||
|
||||
const ThreadListItem = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const onSelect = useContext(HistorySelectContext);
|
||||
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const title = useAuiState((state) => state.threadListItem.title);
|
||||
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 () => {
|
||||
if (!remoteId || exporting) return;
|
||||
@@ -1977,23 +2034,109 @@ 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 (
|
||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ThreadListItemPrimitive.Trigger asChild>
|
||||
<Button
|
||||
<ThreadListItemPrimitive.Trigger asChild>
|
||||
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}>
|
||||
<Text component="span" size="sm" fw={500} truncate>
|
||||
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
{actionsOpened && (
|
||||
<Group className={classes.historyItemActions} gap={1} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
justify="flex-start"
|
||||
flex={1}
|
||||
style={{ minWidth: 0 }}
|
||||
onClick={onSelect}
|
||||
size="sm"
|
||||
title={t("renameConversation.action")}
|
||||
aria-label={t("renameConversation.action")}
|
||||
onClick={() => {
|
||||
setNextTitle(title ?? "");
|
||||
setRenameOpened(true);
|
||||
}}
|
||||
>
|
||||
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
||||
</Button>
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
<Tooltip label={t("exportConversation.action")}>
|
||||
<IconPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
@@ -2001,24 +2144,49 @@ const ThreadListItem = () => {
|
||||
loading={exporting}
|
||||
loaderProps={{ type: "bars" }}
|
||||
disabled={!remoteId}
|
||||
title={t("exportConversation.action")}
|
||||
aria-label={t("exportConversation.action")}
|
||||
onClick={() => void exportConversation()}
|
||||
>
|
||||
<IconFileExport size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ThreadListItemPrimitive.Delete asChild>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
title={t("delete")}
|
||||
aria-label={t("delete")}
|
||||
onClick={deleteConversation}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Delete>
|
||||
</Group>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
||||
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 (
|
||||
<HistorySelectContext.Provider value={onSelect}>
|
||||
<Stack className={classes.historyMenu} gap="xs" p="xs">
|
||||
@@ -2035,9 +2203,28 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
||||
{t("conversations")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" offsetScrollbars>
|
||||
<TextInput
|
||||
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}>
|
||||
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
|
||||
<ThreadListPrimitive.Items>
|
||||
{({ 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>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
@@ -2052,7 +2239,6 @@ const ConversationHistory = () => {
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
onDismiss={() => setOpened(false)}
|
||||
position="bottom-end"
|
||||
width="min(22rem, calc(100vw - 1rem))"
|
||||
shadow="md"
|
||||
@@ -2191,7 +2377,7 @@ const EmptyThread = () => {
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box className={classes.suggestions}>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send={false} clearComposer asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className={classes.suggestion}
|
||||
@@ -2200,17 +2386,17 @@ const EmptyThread = () => {
|
||||
{t("suggestions.health.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send={false} clearComposer asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
|
||||
{t("suggestions.explore.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send={false} clearComposer asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
|
||||
{t("suggestions.media.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send={false} clearComposer asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
|
||||
{t("suggestions.style.label")}
|
||||
</Button>
|
||||
@@ -2792,14 +2978,6 @@ const Composer = (props: ComposerProps) => {
|
||||
<HomarrProviderQuota />
|
||||
<ConversationContext />
|
||||
</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>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
@@ -2979,9 +3157,30 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
|
||||
pendingAction: AssistantPendingAction | undefined;
|
||||
onExpand?: () => void;
|
||||
onMinimize?: () => void;
|
||||
onWindowClose?: () => void;
|
||||
onDismiss?: () => 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 = ({
|
||||
isRunning,
|
||||
pendingAction,
|
||||
@@ -2996,7 +3195,7 @@ export const AssistantConversationSurface = ({
|
||||
onReasoningChange,
|
||||
onExpand,
|
||||
onMinimize,
|
||||
onWindowClose,
|
||||
onDismiss,
|
||||
}: AssistantConversationSurfaceProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const reducedMotion = useReducedMotion();
|
||||
@@ -3011,52 +3210,23 @@ export const AssistantConversationSurface = ({
|
||||
return (
|
||||
<AssistantDirectiveEntitiesProvider>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
{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>
|
||||
)}
|
||||
<AssistantPanelIdentity />
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
<ConversationHistory />
|
||||
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
||||
<AutoApprovalControl />
|
||||
<Tooltip label={t("newConversation")}>
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
<Button
|
||||
className={classes.newConversationButton}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<IconPlus size={17} />}
|
||||
aria-label={t("newConversation")}
|
||||
>
|
||||
<IconPlus size={17} />
|
||||
</ActionIcon>
|
||||
{t("newConversation")}
|
||||
</Button>
|
||||
</ThreadListPrimitive.New>
|
||||
</Tooltip>
|
||||
{onExpand && (
|
||||
@@ -3072,6 +3242,32 @@ export const AssistantConversationSurface = ({
|
||||
</ActionIcon>
|
||||
</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>
|
||||
<AssistantQuestionPortalProvider target={questionPortalTarget}>
|
||||
@@ -3083,7 +3279,7 @@ export const AssistantConversationSurface = ({
|
||||
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
|
||||
<Box className={classes.messages}>
|
||||
<EmptyThread />
|
||||
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
||||
<ThreadPrimitive.Messages components={assistantThreadMessageComponents} />
|
||||
</Box>
|
||||
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
|
||||
<SelectionToolbarPrimitive.Quote asChild>
|
||||
@@ -3130,6 +3326,7 @@ export const AssistantPanel = ({
|
||||
onClose,
|
||||
onDismissActivity,
|
||||
activityDismissed,
|
||||
hasVisibleWidget,
|
||||
isRunning,
|
||||
unreadCount,
|
||||
latestAssistantText,
|
||||
@@ -3147,8 +3344,18 @@ export const AssistantPanel = ({
|
||||
onReasoningChange,
|
||||
}: AssistantPanelProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
const previousFocusRef = useRef<HTMLElement | null>(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) => {
|
||||
if (
|
||||
@@ -3160,7 +3367,7 @@ export const AssistantPanel = ({
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
minimize();
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
@@ -3182,7 +3389,7 @@ export const AssistantPanel = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{!opened && (
|
||||
{!opened && !hasVisibleWidget && (
|
||||
<AssistantActivityBar
|
||||
onOpen={onOpen}
|
||||
onDismissActivity={onDismissActivity}
|
||||
@@ -3210,8 +3417,9 @@ export const AssistantPanel = ({
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
autoFocusComposer
|
||||
onMinimize={onClose}
|
||||
onWindowClose={() => {
|
||||
onMinimize={minimize}
|
||||
onDismiss={() => {
|
||||
stopSpeechWithoutSurface();
|
||||
onDismissActivity();
|
||||
onClose();
|
||||
}}
|
||||
|
||||
@@ -7,11 +7,19 @@ import type {
|
||||
AttachmentAdapter,
|
||||
MessageFormatAdapter,
|
||||
RemoteThreadListAdapter,
|
||||
SpeechSynthesisAdapter,
|
||||
ThreadHistoryAdapter,
|
||||
ThreadMessage,
|
||||
Toolkit,
|
||||
} from "@assistant-ui/react";
|
||||
import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react";
|
||||
import {
|
||||
defineToolkit,
|
||||
useAui,
|
||||
useAuiEvent,
|
||||
useAuiState,
|
||||
useRemoteThreadListRuntime,
|
||||
WebSpeechSynthesisAdapter,
|
||||
} from "@assistant-ui/react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
@@ -41,7 +49,8 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi
|
||||
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
||||
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
|
||||
import {
|
||||
AssistantComposerSurfaceProvider,
|
||||
AssistantComposerRuntimeProvider,
|
||||
AssistantComposerSurfaceBoundary,
|
||||
AssistantRunFocusPreserver,
|
||||
AssistantRuntimeProviderWithTools,
|
||||
} from "./assistant-runtime-provider";
|
||||
@@ -475,6 +484,12 @@ const AssistantThreadRuntime = () => {
|
||||
),
|
||||
[selectedModel],
|
||||
);
|
||||
const [speech, setSpeech] = useState<SpeechSynthesisAdapter>();
|
||||
useEffect(() => {
|
||||
if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") {
|
||||
setSpeech(new WebSpeechSynthesisAdapter());
|
||||
}
|
||||
}, []);
|
||||
const feedback = useMemo(
|
||||
() => ({
|
||||
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
|
||||
@@ -512,6 +527,7 @@ const AssistantThreadRuntime = () => {
|
||||
history,
|
||||
attachments,
|
||||
feedback,
|
||||
speech,
|
||||
},
|
||||
});
|
||||
transport.setRuntime(runtime);
|
||||
@@ -667,6 +683,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [activityDismissed, setActivityDismissed] = useState(true);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [visibleWidgetIds, setVisibleWidgetIds] = useState<Set<string>>(() => new Set());
|
||||
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
|
||||
const aui = useAui();
|
||||
const preferences = useAssistantPreferences();
|
||||
@@ -714,6 +731,21 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
setOpened(true);
|
||||
}, [markRead]);
|
||||
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(() => {
|
||||
if (opened) close();
|
||||
else open();
|
||||
@@ -835,6 +867,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
|
||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
|
||||
const activeWidgetId = visibleWidgetIds.values().next().value ?? null;
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
enabled: true,
|
||||
@@ -843,46 +876,67 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
isRunning: assistantIsRunning,
|
||||
isRefreshing,
|
||||
unreadCount,
|
||||
hasVisibleWidget: visibleWidgetIds.size > 0,
|
||||
activeWidgetId,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
sendPrompt,
|
||||
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 (
|
||||
<AssistantAutoApprovalProvider conversationId={conversationId}>
|
||||
<AssistantContext.Provider value={value}>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
<AssistantComposerSurfaceProvider surfaceId="floating-panel">
|
||||
<AssistantPanel
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
onClose={close}
|
||||
onDismissActivity={() => {
|
||||
markRead();
|
||||
setActivityDismissed(true);
|
||||
}}
|
||||
activityDismissed={activityDismissed}
|
||||
isRunning={assistantIsRunning}
|
||||
unreadCount={unreadCount}
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestAssistantPartType={latestAssistantPartType}
|
||||
latestUserText={queuedPrompt ?? latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
pendingAction={pendingAction}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={refreshCurrentView}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
/>
|
||||
</AssistantComposerSurfaceProvider>
|
||||
<AssistantComposerRuntimeProvider>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
<AssistantComposerSurfaceBoundary surfaceId="assistant-panel">
|
||||
<AssistantPanel
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
onClose={close}
|
||||
onDismissActivity={() => {
|
||||
markRead();
|
||||
setActivityDismissed(true);
|
||||
}}
|
||||
activityDismissed={activityDismissed}
|
||||
hasVisibleWidget={visibleWidgetIds.size > 0}
|
||||
isRunning={assistantIsRunning}
|
||||
unreadCount={unreadCount}
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestAssistantPartType={latestAssistantPartType}
|
||||
latestUserText={queuedPrompt ?? latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
pendingAction={pendingAction}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={refreshCurrentView}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
/>
|
||||
</AssistantComposerSurfaceBoundary>
|
||||
</AssistantComposerRuntimeProvider>
|
||||
</AssistantContext.Provider>
|
||||
</AssistantAutoApprovalProvider>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,8 @@ import { z } from "zod/v4";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
|
||||
import {
|
||||
AssistantComposerSurfaceProvider,
|
||||
AssistantComposerRuntimeProvider,
|
||||
AssistantComposerSurfaceBoundary,
|
||||
AssistantRunFocusPreserver,
|
||||
AssistantRuntimeProviderWithTools,
|
||||
assistantSurfaceComposerCacheLimit,
|
||||
@@ -148,14 +149,22 @@ const SurfaceTestAssistant = ({
|
||||
{ runtime, toolkit },
|
||||
createElement(AssistantRunFocusPreserver),
|
||||
createElement(
|
||||
AssistantComposerSurfaceProvider,
|
||||
{ surfaceId: "surface-a" },
|
||||
createElement(SurfaceProbe, { id: "a", onReady }),
|
||||
AssistantComposerRuntimeProvider,
|
||||
null,
|
||||
createElement(
|
||||
AssistantComposerSurfaceBoundary,
|
||||
{ surfaceId: "surface-a" },
|
||||
createElement(SurfaceProbe, { id: "a", onReady }),
|
||||
),
|
||||
),
|
||||
createElement(
|
||||
AssistantComposerSurfaceProvider,
|
||||
{ surfaceId: "surface-b" },
|
||||
createElement(SurfaceProbe, { id: "b", onReady }),
|
||||
AssistantComposerRuntimeProvider,
|
||||
null,
|
||||
createElement(
|
||||
AssistantComposerSurfaceBoundary,
|
||||
{ surfaceId: "surface-b" },
|
||||
createElement(SurfaceProbe, { id: "b", onReady }),
|
||||
),
|
||||
),
|
||||
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
|
||||
);
|
||||
|
||||
@@ -157,9 +157,6 @@ const createSurfaceComposerHost = (
|
||||
get attachments() {
|
||||
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
|
||||
},
|
||||
get dictation() {
|
||||
return getThreadAdapters(threadCore)?.dictation;
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy(threadCore as ThreadCoreWithAdapters, {
|
||||
@@ -294,18 +291,21 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
|
||||
subscribe(callback) {
|
||||
let activeCore = sharedBinding.getState();
|
||||
let activeConversationId = getConversationId();
|
||||
let activeComposer = getComposer(activeConversationId, activeCore);
|
||||
retainConversation(activeConversationId);
|
||||
let unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
||||
let unsubscribeComposer = activeComposer.subscribe(callback);
|
||||
const handleSharedUpdate = () => {
|
||||
const nextCore = sharedBinding.getState();
|
||||
const nextConversationId = getConversationId();
|
||||
if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
|
||||
if (activeCore.speech !== undefined) activeCore.stopSpeaking();
|
||||
unsubscribeComposer();
|
||||
releaseConversation(activeConversationId);
|
||||
activeCore = nextCore;
|
||||
activeConversationId = nextConversationId;
|
||||
retainConversation(activeConversationId);
|
||||
unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
||||
activeComposer = getComposer(activeConversationId, activeCore);
|
||||
unsubscribeComposer = activeComposer.subscribe(callback);
|
||||
}
|
||||
callback();
|
||||
};
|
||||
@@ -360,6 +360,13 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
|
||||
registerModelContextProvider: () => () => {},
|
||||
dispose() {
|
||||
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);
|
||||
},
|
||||
};
|
||||
@@ -373,7 +380,7 @@ const AssistantComposerSurfaceContext = createContext<AssistantComposerSurfaceVa
|
||||
|
||||
export const useAssistantComposerSurface = () => {
|
||||
const value = useContext(AssistantComposerSurfaceContext);
|
||||
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceProvider");
|
||||
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceBoundary");
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -437,21 +444,23 @@ const AssistantComposerSurfaceEvents = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
|
||||
export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
const instanceId = useId();
|
||||
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const contextValue = useMemo(() => ({ id: surfaceId }), [surfaceId]);
|
||||
const contextValue = useMemo(() => ({ id: resolvedSurfaceId }), [resolvedSurfaceId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return undefined;
|
||||
surfaceElements.set(surfaceId, element);
|
||||
surfaceElements.set(resolvedSurfaceId, element);
|
||||
|
||||
return () => {
|
||||
if (surfaceElements.get(surfaceId) === element) surfaceElements.delete(surfaceId);
|
||||
if (surfaceElements.get(resolvedSurfaceId) === element) surfaceElements.delete(resolvedSurfaceId);
|
||||
};
|
||||
}, [surfaceId]);
|
||||
}, [resolvedSurfaceId]);
|
||||
|
||||
const handlePasteCapture = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const target = event.target;
|
||||
@@ -507,7 +516,6 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil
|
||||
}}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
<AssistantComposerSurfaceEvents />
|
||||
{children}
|
||||
</div>
|
||||
</AssistantComposerSurfaceContext.Provider>
|
||||
@@ -566,18 +574,12 @@ export const AssistantRunFocusPreserver = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
interface AssistantComposerSurfaceProviderProps extends PropsWithChildren {
|
||||
surfaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives one rendered assistant surface a local composer while preserving the shared conversation.
|
||||
* Every component below this boundary can keep using the normal assistant-ui primitives and hooks.
|
||||
* Gives every rendered assistant surface one shared local composer while preserving the shared
|
||||
* conversation. Individual surfaces should add AssistantComposerSurfaceBoundary around their UI.
|
||||
*/
|
||||
export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => {
|
||||
export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren) => {
|
||||
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.
|
||||
const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.();
|
||||
const surfaceRuntime = useMemo(() => {
|
||||
@@ -588,7 +590,8 @@ export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: Assist
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
|
||||
<AssistantComposerSurfaceBoundary surfaceId={resolvedSurfaceId}>{children}</AssistantComposerSurfaceBoundary>
|
||||
<AssistantComposerSurfaceEvents />
|
||||
{children}
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
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 { IconArrowsMaximize, IconRobot } from "@tabler/icons-react";
|
||||
import { IconArrowsMaximize, IconArrowsMinimize, IconRobot } from "@tabler/icons-react";
|
||||
import { useAui, useAuiState } from "@assistant-ui/react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -13,23 +14,47 @@ import classes from "./assistant-panel.module.css";
|
||||
import { AssistantConversationSurface } from "./assistant-panel";
|
||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
|
||||
import { AssistantComposerSurfaceProvider } from "./assistant-runtime-provider";
|
||||
import { AssistantComposerSurfaceBoundary } from "./assistant-runtime-provider";
|
||||
|
||||
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
||||
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) {
|
||||
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AssistantComposerSurfaceProvider surfaceId={`board-widget-${props.itemId ?? "preview"}`}>
|
||||
<EnabledAssistantBoardWidget {...props} />
|
||||
</AssistantComposerSurfaceProvider>
|
||||
<AssistantComposerSurfaceBoundary surfaceId={`assistant-widget-${widgetId}`}>
|
||||
<Box ref={widgetRef} w="100%" h="100%">
|
||||
<EnabledAssistantBoardWidget {...props} assistantWidgetId={widgetId} />
|
||||
</Box>
|
||||
</AssistantComposerSurfaceBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
|
||||
const EnabledAssistantBoardWidget = ({
|
||||
width,
|
||||
height,
|
||||
isEditMode,
|
||||
assistantWidgetId,
|
||||
}: WidgetComponentProps<"assistant"> & { assistantWidgetId: string }) => {
|
||||
const board = useRequiredBoard();
|
||||
const assistant = useHomarrAssistant();
|
||||
const preferences = useAssistantPreferences();
|
||||
@@ -45,6 +70,14 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon
|
||||
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) {
|
||||
return <CompactAssistantWidget onOpen={assistant.open} />;
|
||||
}
|
||||
@@ -75,8 +108,30 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon
|
||||
);
|
||||
};
|
||||
|
||||
const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
|
||||
const CompactAssistantWidget = ({
|
||||
onOpen,
|
||||
location = "compact",
|
||||
}: {
|
||||
onOpen: () => void;
|
||||
location?: "compact" | "panel" | "widget";
|
||||
}) => {
|
||||
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 (
|
||||
<Box className={classes.widgetPanel}>
|
||||
@@ -87,23 +142,16 @@ const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
|
||||
</ThemeIcon>
|
||||
<Stack className={classes.compactCopy} gap={1}>
|
||||
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
|
||||
{t("widget.assistant.compact.title")}
|
||||
{title}
|
||||
</Text>
|
||||
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
|
||||
{t("widget.assistant.compact.description")}
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Tooltip label={t("widget.assistant.compact.open")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size={44}
|
||||
radius="md"
|
||||
onClick={onOpen}
|
||||
aria-label={t("widget.assistant.compact.open")}
|
||||
>
|
||||
<IconArrowsMaximize size={18} />
|
||||
<Tooltip label={actionLabel}>
|
||||
<ActionIcon variant="light" color="red" size={44} radius="md" onClick={onOpen} aria-label={actionLabel}>
|
||||
{actionIcon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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,6 +11,19 @@
|
||||
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 {
|
||||
display: contents;
|
||||
}
|
||||
@@ -39,3 +52,9 @@
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settingsButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -227,7 +227,9 @@ const LoadedBoardItemContent = ({
|
||||
(host: HTMLDivElement | null) => {
|
||||
if (!host || !surfacePortalTarget || isAdvanced) return;
|
||||
cancelPreviewEntrance();
|
||||
host.append(surfacePortalTarget);
|
||||
if (host.childNodes.length !== 1 || host.firstChild !== surfacePortalTarget) {
|
||||
host.replaceChildren(surfacePortalTarget);
|
||||
}
|
||||
},
|
||||
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-ite
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
@@ -105,9 +106,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={12}
|
||||
right={8}
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 30 }}
|
||||
className={itemContentClasses.settingsButton}
|
||||
data-menu-open={isMenuOpen || undefined}
|
||||
data-board-widget-settings
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import {
|
||||
getWidgetConnectionStatus,
|
||||
resolveMatchingIntegrationsAsync,
|
||||
@@ -8,95 +10,254 @@ import {
|
||||
} from "./item-select-data";
|
||||
|
||||
describe("getWidgetConnectionStatus", () => {
|
||||
const availableKinds = new Set(["sonarr" as const]);
|
||||
const availableKinds = new Set<IntegrationKind>(["sonarr", "plex"]);
|
||||
|
||||
it("reports widgets with a configured compatible integration as ready", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("ready");
|
||||
describe("ready status", () => {
|
||||
it("returns 'ready' when at least one supported integration is in availableKinds", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).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");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports required integrations that are not configured as needing setup", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("needsSetup");
|
||||
describe("needsSetup status", () => {
|
||||
it("returns 'needsSetup' when supported integrations are non-empty, none are available, and connectionOptional is false", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["radarr", "sabNzbd"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).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");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ supportedIntegrations: [], connectionOptional: false },
|
||||
{ supportedIntegrations: ["radarr" as const], connectionOptional: true },
|
||||
])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => {
|
||||
expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe(
|
||||
"noConnectionRequired",
|
||||
);
|
||||
describe("noConnectionRequired status", () => {
|
||||
it("returns 'noConnectionRequired' when supportedIntegrations is empty regardless of connectionOptional", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: [],
|
||||
availableKinds,
|
||||
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("widget selection lock", () => {
|
||||
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
||||
describe("selection concurrency locks", () => {
|
||||
it("acquires lock on unlocked ref and sets current to true", () => {
|
||||
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 };
|
||||
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
expect(tryLockSelection(lock)).toBe(false);
|
||||
|
||||
unlockSelection(lock);
|
||||
expect(lock.current).toBe(false);
|
||||
|
||||
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", () => {
|
||||
it("waits for the shared integration query before a cold integration-backed selection", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [
|
||||
{ id: "matching", kind: "sonarr" as const },
|
||||
{ id: "other", kind: "radarr" as const },
|
||||
]);
|
||||
it("returns empty array immediately when widget does not support integrations without calling ensureDataAsync", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [{ id: "1", kind: "sonarr" as IntegrationKind }]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: false,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
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(result).toEqual([]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("excludes matching integrations the user cannot use", async () => {
|
||||
it("uses currentData synchronously when available without calling ensureDataAsync", async () => {
|
||||
const currentData = [
|
||||
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
|
||||
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
|
||||
{ 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(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData,
|
||||
ensureDataAsync: vi.fn(),
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([currentData[0]]);
|
||||
).rejects.toThrow("Network timeout");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
.action {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.card:hover .action,
|
||||
.card:focus-within .action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// @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 { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
@@ -6,16 +6,18 @@ import {
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Input,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
import { IconApi, IconBuildingStore, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -23,8 +25,8 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import {
|
||||
getIconUrl,
|
||||
getBoardLaneColumnCount,
|
||||
getIconUrl,
|
||||
getIntegrationName,
|
||||
getRootSectionLane,
|
||||
widgetIntegrationSupport,
|
||||
@@ -36,14 +38,14 @@ import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import { SelectableCard } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||
import {
|
||||
@@ -64,6 +66,16 @@ interface ItemSelectModalContentProps {
|
||||
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 = ({
|
||||
actions,
|
||||
integrationData,
|
||||
@@ -108,15 +120,14 @@ const ItemSelectModalContent = ({
|
||||
[board.sections, currentLayout, mainCanvasSection, t],
|
||||
);
|
||||
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 selectionLock = useRef(false);
|
||||
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: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const settings = useSettings();
|
||||
@@ -128,7 +139,7 @@ const ItemSelectModalContent = ({
|
||||
[integrationData],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
const items = useMemo<WidgetItemData[]>(
|
||||
() =>
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
@@ -188,7 +199,7 @@ const ItemSelectModalContent = ({
|
||||
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
|
||||
createdItem.layouts[0]?.sectionId;
|
||||
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
|
||||
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
|
||||
const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination;
|
||||
const notificationId = `board-item-created:${itemId}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
@@ -247,7 +258,7 @@ const ItemSelectModalContent = ({
|
||||
kind: "customApi",
|
||||
integrationIds: [],
|
||||
options: configuredOptions,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||
@@ -310,7 +321,7 @@ const ItemSelectModalContent = ({
|
||||
kind,
|
||||
options,
|
||||
integrationIds: newIntegrationIds,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
|
||||
@@ -377,125 +388,233 @@ const ItemSelectModalContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap="md" align="stretch">
|
||||
<Box style={{ flex: "1 1 0%", minWidth: 0 }}>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
ariaLabel={t("item.create.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
description={t("item.create.destination.description")}
|
||||
data={placementOptions}
|
||||
value={targetSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{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),
|
||||
})}
|
||||
<Stack gap="md">
|
||||
{/* Top Search & Destination Selector */}
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
|
||||
<Box style={{ flex: "1 1 240px", minWidth: 200 }}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
aria-label={t("item.create.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
loadingSelection === null &&
|
||||
filteredItems.length === 1 &&
|
||||
filteredItems[0]
|
||||
) {
|
||||
void handleAdd(filteredItems[0].kind);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{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>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
{/* 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>
|
||||
}
|
||||
/>
|
||||
{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 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>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
</Tooltip.Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemSelectModalFrame = ({
|
||||
actions,
|
||||
innerProps,
|
||||
@@ -541,109 +660,3 @@ export const ItemSelectModal = createModal<{
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
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,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type * as WidgetModalsModule 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 { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
||||
|
||||
@@ -80,13 +83,46 @@ interface LazyWidgetEditModalContentProps {
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
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 (
|
||||
<Component
|
||||
{...props}
|
||||
innerProps={{
|
||||
...props.innerProps,
|
||||
integrationData: combinedIntegrationData,
|
||||
integrationEditForm: LazyIntegrationEditForm,
|
||||
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -122,6 +158,5 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
|
||||
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
|
||||
import type { GridTransaction } from "./dnd";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
@@ -48,6 +49,16 @@ export const FixedGridItem = ({
|
||||
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
|
||||
const { commitSectionGrid } = useGridLayoutActions();
|
||||
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 isEditorActive = isEditMode && runtimeStatus === "ready";
|
||||
const placement = toPlacement(item);
|
||||
@@ -178,6 +189,8 @@ export const FixedGridItem = ({
|
||||
return (
|
||||
<Box
|
||||
{...semantics}
|
||||
onClickCapture={handleClickCapture}
|
||||
data-board-item-selected={selected ? "true" : undefined}
|
||||
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
|
||||
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
|
||||
data-grid-item-id={isEditorActive ? undefined : item.id}
|
||||
@@ -187,6 +200,7 @@ export const FixedGridItem = ({
|
||||
data-kind={item.kind}
|
||||
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
|
||||
aria-describedby={isEditorActive ? instructionsId : undefined}
|
||||
aria-selected={isEditorActive && item.type === "item" ? selected : undefined}
|
||||
aria-keyshortcuts={
|
||||
isEditorActive
|
||||
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
|
||||
|
||||
@@ -32,6 +32,27 @@
|
||||
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] {
|
||||
position: fixed !important;
|
||||
z-index: 10000;
|
||||
@@ -177,138 +198,115 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: block;
|
||||
z-index: 25;
|
||||
display: grid;
|
||||
padding: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
background: var(--mantine-color-body);
|
||||
border: 0.0625rem solid var(--mantine-color-default-border);
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
opacity: 0;
|
||||
scale: 0.88;
|
||||
place-items: center;
|
||||
touch-action: none;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
scale 160ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle {
|
||||
.board-grid-resize-handle::before {
|
||||
position: absolute;
|
||||
inset: -0.375rem;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.board-grid-resize-handle > svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.board-grid-entry:has(> .board-grid-resize-handle:hover) > .board-grid-resize-handle,
|
||||
.board-grid-entry:has(> .board-grid-resize-handle:active) > .board-grid-resize-handle {
|
||||
border-color: var(--mantine-color-dimmed);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -8px;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
right: 44px;
|
||||
left: 44px;
|
||||
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
left: 50%;
|
||||
width: 1.75rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
translate: -50% 0;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||
top: 0;
|
||||
top: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
bottom: 0;
|
||||
bottom: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
top: 44px;
|
||||
bottom: 44px;
|
||||
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
top: 50%;
|
||||
width: 1rem;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
translate: 0 -50%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||
right: 0;
|
||||
right: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
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;
|
||||
left: -0.25rem;
|
||||
}
|
||||
|
||||
.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="sw"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
z-index: 9;
|
||||
width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: var(--board-item-radius, var(--mantine-radius-md));
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
top: 0;
|
||||
right: 0;
|
||||
top: -0.625rem;
|
||||
right: -0.625rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
right: -0.625rem;
|
||||
bottom: -0.625rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
bottom: -0.625rem;
|
||||
left: -0.625rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: 0;
|
||||
left: 0;
|
||||
top: -0.625rem;
|
||||
left: -0.625rem;
|
||||
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) {
|
||||
.board-grid-entry,
|
||||
.board-grid-placeholder,
|
||||
.board-grid-resize-outline,
|
||||
.board-grid-resize-handle::after {
|
||||
.board-grid-resize-handle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
DragStartEvent,
|
||||
} 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 { useI18n } from "@homarr/translation/client";
|
||||
@@ -61,6 +62,7 @@ import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import type { GridInteraction } from "./grid-preview-layer";
|
||||
import { GridPreviewLayer } from "./grid-preview-layer";
|
||||
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 { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
|
||||
@@ -846,6 +848,9 @@ const DndGridEntryComponent = ({
|
||||
}: DndGridEntryProps) => {
|
||||
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
|
||||
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 shellRef = useRef<HTMLDivElement>(null);
|
||||
const placementRef = useRef(placement);
|
||||
@@ -921,7 +926,12 @@ const DndGridEntryComponent = ({
|
||||
<div
|
||||
ref={setShellRef}
|
||||
className="board-grid-entry"
|
||||
style={getLogicalItemStyle(placement) as CSSProperties}
|
||||
style={
|
||||
{
|
||||
...getLogicalItemStyle(placement),
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
data-grid-item-id={placement.id}
|
||||
data-grid-item-type={placement.type}
|
||||
data-grid-x={placement.x}
|
||||
@@ -930,6 +940,7 @@ const DndGridEntryComponent = ({
|
||||
data-grid-h={placement.h}
|
||||
data-dnd-drag-source={isDragSource ? "true" : undefined}
|
||||
data-dnd-dropping={isDropping ? "true" : undefined}
|
||||
data-board-item-selected={isSelectedEntry ? "true" : undefined}
|
||||
>
|
||||
<div ref={mountRef} className="board-grid-content-mount" />
|
||||
<GridResizeHandles
|
||||
@@ -1102,19 +1113,27 @@ const GridResizeHandles = ({
|
||||
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
|
||||
};
|
||||
|
||||
return RESIZE_DIRECTIONS.map((direction) => (
|
||||
<span
|
||||
key={direction}
|
||||
className="board-grid-resize-handle"
|
||||
data-grid-resize-handle={direction}
|
||||
aria-hidden="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)}
|
||||
/>
|
||||
));
|
||||
return RESIZE_DIRECTIONS.map((direction) => {
|
||||
const isHorizontalSide = direction === "n" || direction === "s";
|
||||
const isVerticalSide = direction === "e" || direction === "w";
|
||||
const GripIcon = isHorizontalSide ? IconGripHorizontal : IconGripVertical;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={direction}
|
||||
className="board-grid-resize-handle"
|
||||
data-grid-resize-handle={direction}
|
||||
aria-hidden="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 = (
|
||||
@@ -1239,6 +1258,7 @@ const getPreferredRegisteredGridTarget = (
|
||||
|
||||
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
|
||||
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
|
||||
if (event.metaKey || event.ctrlKey) return true;
|
||||
const target = event.target;
|
||||
const activationElement = source.handle ?? source.element;
|
||||
if (!(target instanceof Element) || target === activationElement) return false;
|
||||
|
||||
@@ -10,6 +10,8 @@ import { FixedGridItem } from "../fixed-grid-item";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
announce: vi.fn(),
|
||||
commitSectionGrid: vi.fn(),
|
||||
isSelected: vi.fn(() => false),
|
||||
toggleSelectItem: vi.fn(),
|
||||
editMode: true,
|
||||
maxRowCount: null as number | null,
|
||||
items: [
|
||||
@@ -44,6 +46,13 @@ vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [mocks.editMode],
|
||||
}));
|
||||
|
||||
vi.mock("~/components/board/selection/board-selection-context", () => ({
|
||||
useBoardSelection: () => ({
|
||||
isSelected: mocks.isSelected,
|
||||
toggleSelectItem: mocks.toggleSelectItem,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useCurrentLayout: () => "layout",
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"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;
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
// @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() {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: t("universalCreate.analyticsError"),
|
||||
message: t("common.analyticsError"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
declare const classes: {
|
||||
readonly action: string;
|
||||
readonly actionIcon: string;
|
||||
};
|
||||
export default classes;
|
||||
@@ -1,162 +0,0 @@
|
||||
"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",
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
"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;
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
"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,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconArrowLeft, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
@@ -27,9 +28,28 @@ interface IntegrationSelectModalProps {
|
||||
|
||||
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
|
||||
const directKind =
|
||||
innerProps.initialKind ?? (innerProps.allowedKinds?.length === 1 ? innerProps.allowedKinds[0] : 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 { 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) => {
|
||||
setSelectedKind(kind);
|
||||
@@ -40,7 +60,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
setStep("select");
|
||||
setSelectedKind(null);
|
||||
};
|
||||
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
|
||||
const handleFormBack = isDirectForm ? actions.closeModal : handleBack;
|
||||
|
||||
const handleSuccess = (result?: CreatedIntegrationResult) => {
|
||||
actions.closeModal();
|
||||
@@ -52,12 +72,8 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
};
|
||||
|
||||
if (step === "form" && selectedKind) {
|
||||
let HeaderIcon = IconArrowLeft;
|
||||
let headerLabel = t("common.action.previous");
|
||||
if (innerProps.initialKind) {
|
||||
HeaderIcon = IconX;
|
||||
headerLabel = t("common.action.close");
|
||||
}
|
||||
const HeaderIcon = isDirectForm ? IconX : IconArrowLeft;
|
||||
const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous");
|
||||
|
||||
return (
|
||||
<ScrollArea.Autosize mah="80vh">
|
||||
@@ -79,11 +95,25 @@ 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 (
|
||||
<IntegrationSelectGrid
|
||||
onSelect={handleSelect}
|
||||
enableMockIntegration={innerProps.enableMockIntegration}
|
||||
allowedKinds={innerProps.allowedKinds}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
);
|
||||
}).withOptions({
|
||||
|
||||
@@ -3,8 +3,6 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
||||
|
||||
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 { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
@@ -38,7 +36,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
<DesktopSearchInput />
|
||||
</TourTarget>
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{hasNavigation && <UniversalCreateHeaderAction />}
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
@@ -46,8 +43,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<UniversalCreateQueryGate />
|
||||
<UniversalCreateSpotlightResult />
|
||||
<LazySpotlight />
|
||||
</AppShellHeader>
|
||||
);
|
||||
|
||||
@@ -78,11 +78,15 @@ describe("lazy widget application graph", () => {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
const signedIn = page.waitForURL(baseUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname === "/", {
|
||||
waitUntil: "commit",
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await signedIn;
|
||||
|
||||
await expect(page.locator("[data-homarr-dev-benchmark-board]")).toBeVisible({ timeout: 30_000 });
|
||||
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
||||
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
||||
const clockWidget = page.locator(
|
||||
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
||||
);
|
||||
|
||||
+3
-2
@@ -30,8 +30,9 @@
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"dev:benchmark": "node --experimental-strip-types scripts/benchmarks/dev-board.mts",
|
||||
"dev:cli": "go -C tools/homarr-dev run .",
|
||||
"dev:cli:install": "go -C tools/homarr-dev install .",
|
||||
"dev:cli": "go -C tools/homarr-dev run -buildvcs=false .",
|
||||
"dev:cli:build": "go -C tools/homarr-dev build -buildvcs=false .",
|
||||
"dev:cli:install": "go -C tools/homarr-dev install -buildvcs=false .",
|
||||
"dev:docs": "pnpm -F @homarr/docs dev",
|
||||
"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",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { iconsRouter } from "./router/icons";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { airQualityRouter } from "./router/widgets/air-quality";
|
||||
import { bazarrRouter } from "./router/widgets/bazarr";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
@@ -35,6 +36,7 @@ export const mcpRouter = createTRPCRouter({
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
airQuality: airQualityRouter,
|
||||
bazarr: bazarrRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
|
||||
@@ -11,6 +11,10 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
const logger = createLogger({ module: "logRouter" });
|
||||
|
||||
type LogSubscriptionEvent =
|
||||
| { type: "history"; messages: LoggerMessage[] }
|
||||
| { type: "message"; message: LoggerMessage };
|
||||
|
||||
export const logRouter = createTRPCRouter({
|
||||
subscribe: permissionRequiredProcedure
|
||||
.requiresPermission("other-view-logs")
|
||||
@@ -20,15 +24,61 @@ export const logRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.subscription(({ input }) => {
|
||||
return observable<LoggerMessage>((emit) => {
|
||||
const unsubscribe = loggingChannel.subscribe((data) => {
|
||||
if (!input.levels.includes(data.level)) return;
|
||||
emit.next(data);
|
||||
});
|
||||
logger.info("A tRPC client has connected to the logging procedure");
|
||||
return observable<LogSubscriptionEvent>((emit) => {
|
||||
let isClosed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let isHistoryLoaded = false;
|
||||
const bufferedMessages: LoggerMessage[] = [];
|
||||
|
||||
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 () => {
|
||||
unsubscribe();
|
||||
isClosed = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { airQualityRequestHandler } from "@homarr/request-handler/air-quality";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const atLocationInput = z.object({
|
||||
longitude: z.number().min(-180).max(180),
|
||||
latitude: z.number().min(-90).max(90),
|
||||
});
|
||||
|
||||
export const airQualityRouter = createTRPCRouter({
|
||||
atLocation: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get current and forecast air quality, UV, pollutant, and pollen data for geographic coordinates. REQUIRED: latitude (-90 to 90) and longitude (-180 to 180).",
|
||||
},
|
||||
})
|
||||
.input(atLocationInput)
|
||||
.query(async ({ input }) => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||
|
||||
const handler = airQualityRequestHandler.handler(input);
|
||||
return await handler.getDataAsync().then((result) => result.data);
|
||||
}),
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export const widgetRouter = createTRPCRouter({
|
||||
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
|
||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
||||
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
|
||||
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
|
||||
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
|
||||
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
|
||||
|
||||
@@ -6,8 +6,8 @@ import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const atLocationInput = z.object({
|
||||
longitude: z.number(),
|
||||
latitude: z.number(),
|
||||
longitude: z.number().min(-180).max(180),
|
||||
latitude: z.number().min(-90).max(90),
|
||||
});
|
||||
|
||||
export const weatherRouter = createTRPCRouter({
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
const MCP_TOOL_ALLOWLIST = {
|
||||
query: [
|
||||
"airQuality_atLocation",
|
||||
"apiKeys_getAll",
|
||||
"app_all",
|
||||
"app_byId",
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
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],
|
||||
});
|
||||
+38
-10
@@ -1,14 +1,42 @@
|
||||
import { run } from "@drizzle-team/brocli";
|
||||
import type { Command } from "@drizzle-team/brocli";
|
||||
|
||||
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";
|
||||
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness";
|
||||
|
||||
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
|
||||
const harnessCommandNames = new Set(["harness", "local-harness"]);
|
||||
|
||||
void run(commands, {
|
||||
name: "homarr-cli",
|
||||
version: "1.0.0",
|
||||
});
|
||||
function isHarnessInvocation(argv: string[]): boolean {
|
||||
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
|
||||
}
|
||||
|
||||
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,6 +1,20 @@
|
||||
import z from "zod/v4";
|
||||
|
||||
export const logLevels = ["error", "warn", "info", "debug"] as const;
|
||||
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 = {
|
||||
error: {
|
||||
prefix: "🔴",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import superjson from "superjson";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { stringify } from "superjson";
|
||||
import Transport from "winston-transport";
|
||||
|
||||
import type { RedisClient } 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 levelSymbol = Symbol.for("level");
|
||||
@@ -13,32 +16,41 @@ const levelSymbol = Symbol.for("level");
|
||||
//
|
||||
export class RedisTransport extends Transport {
|
||||
private redis: RedisClient | null = null;
|
||||
public static readonly publishChannel = "pubSub:logging";
|
||||
|
||||
/**
|
||||
* Log the info to the Redis channel
|
||||
* Retain the log in a bounded Redis list and publish it to live subscribers.
|
||||
*/
|
||||
log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
|
||||
log(info: { [messageSymbol]: string; [levelSymbol]: LoggerMessage["level"] }, callback: () => void) {
|
||||
setImmediate(() => {
|
||||
this.emit("logged", info);
|
||||
});
|
||||
|
||||
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
||||
this.redis ??= createRedisClient();
|
||||
const message: LoggerMessage = {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
message: info[messageSymbol],
|
||||
level: info[levelSymbol],
|
||||
};
|
||||
const serializedMessage = stringify(message);
|
||||
|
||||
this.redis
|
||||
.publish(
|
||||
RedisTransport.publishChannel,
|
||||
superjson.stringify({
|
||||
message: info[messageSymbol],
|
||||
level: info[levelSymbol],
|
||||
}),
|
||||
)
|
||||
callback();
|
||||
|
||||
void Promise.resolve()
|
||||
.then(() => {
|
||||
callback();
|
||||
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
||||
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(() => {
|
||||
// Ignore errors
|
||||
});
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,9 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/air-quality"
|
||||
| "/docs/widgets/countdown"
|
||||
| "/docs/widgets/timer"
|
||||
| "/docs/widgets/app"
|
||||
| "/docs/widgets/iframe"
|
||||
| "/docs/widgets/video"
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { WidgetKind } from "../widget";
|
||||
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
clock: "clock",
|
||||
weather: "weather",
|
||||
airQuality: "air-quality",
|
||||
countdown: "countdown",
|
||||
timer: "timer",
|
||||
app: "app",
|
||||
iframe: "iframe",
|
||||
video: "video",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const widgetKinds = [
|
||||
"clock",
|
||||
"weather",
|
||||
"airQuality",
|
||||
"countdown",
|
||||
"timer",
|
||||
"app",
|
||||
"iframe",
|
||||
"video",
|
||||
@@ -59,6 +62,9 @@ export const widgetKinds = [
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
|
||||
airQuality: { width: 2, height: 1 },
|
||||
countdown: { width: 2, height: 1 },
|
||||
timer: { width: 2, height: 1 },
|
||||
uptimeKuma: { width: 2, height: 3 },
|
||||
audioStats: { width: 2, height: 2 },
|
||||
paperlessNgx: { width: 2, height: 2 },
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
// @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,12 +1,25 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
|
||||
import {
|
||||
Badge,
|
||||
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 { clientApi } from "@homarr/api/client";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import { selectGridCols, SelectableCard } from "@homarr/ui";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
@@ -36,7 +49,15 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
|
||||
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
|
||||
|
||||
const handleSelect = (app: SelectableApp) => {
|
||||
const handleSelect = (app: SelectableApp, event?: React.MouseEvent) => {
|
||||
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) {
|
||||
setSelectedAppIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -44,22 +65,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
else next.add(app.id);
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,100 +81,145 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
});
|
||||
};
|
||||
|
||||
const handleMultiSubmit = () => {
|
||||
innerProps.onSelectMany?.(selectedApps);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
ariaLabel={t("app.action.select.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{innerProps.withCreate && (
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={t("app.action.create.title")}
|
||||
status={t("app.action.create.action")}
|
||||
onSelect={handleAddNewApp}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<CatalogItem
|
||||
key={app.id}
|
||||
height={selectGridCardHeight}
|
||||
label={app.name}
|
||||
status={
|
||||
multiSelect
|
||||
? selectedAppIds.has(app.id)
|
||||
? t("app.action.select.selected")
|
||||
: t("app.action.select.toggle")
|
||||
: t("app.action.select.action", { app: app.name })
|
||||
<Stack gap="md">
|
||||
{/* Top Search Input & Multi-Select Tip */}
|
||||
<Stack gap={6}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
aria-label={t("app.action.select.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
|
||||
onSelect={() => handleSelect(app)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<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.length === 0 && !isPending && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
}}
|
||||
/>
|
||||
{multiSelect && (
|
||||
<Group justify="space-between" px={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("app.action.select.multiSelectTip")}
|
||||
</Text>
|
||||
{selectedAppIds.size > 0 && (
|
||||
<Badge variant="light" color="primaryColor" size="xs">
|
||||
{t("app.action.select.selectedCount", { count: selectedAppIds.size })}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
{multiSelect && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button leftSection={<IconApps size={16} />} disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
|
||||
{t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Scrollable Container with App Cards */}
|
||||
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
|
||||
<Stack gap="md" pt="xs" pr="xs" px={4}>
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{innerProps.withCreate && (
|
||||
<SelectableCard
|
||||
onClick={handleAddNewApp}
|
||||
style={{ borderStyle: "dashed" }}
|
||||
icon={
|
||||
<ThemeIcon variant="light" color="primaryColor" size={34} radius="md">
|
||||
<IconPlus size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
title={t("app.action.create.title")}
|
||||
description={t("app.action.create.description")}
|
||||
footerLeft={
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("app.action.select.customApplication")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
isSelected={selectedAppIds.has(app.id)}
|
||||
multiSelect={multiSelect}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<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({
|
||||
defaultTitle: (t) => t("app.action.select.title"),
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -102,6 +102,13 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
content: presentationClasses.inspectorContent,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
const canClose = modal.reference.onCloseRequest ? modal.reference.onCloseRequest() : true;
|
||||
if (canClose) {
|
||||
handleCloseModal();
|
||||
}
|
||||
}, [handleCloseModal, modal.reference]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
key={modal.id}
|
||||
@@ -123,7 +130,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
{...otherModalProps}
|
||||
title={translateIfNecessary(t, modal.props.defaultTitle)}
|
||||
opened={opened}
|
||||
onClose={handleCloseModal}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{modal.reference.content}
|
||||
</Modal>
|
||||
@@ -144,7 +151,6 @@ export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) =>
|
||||
return {
|
||||
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
|
||||
// void actually is undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
context.openModalInner({ modal, innerProps, options: options ?? {} });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -94,6 +94,8 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
const ModalContent = modal.modal.component;
|
||||
|
||||
const { innerProps, ...rest } = modal.props;
|
||||
let closeInterceptor: (() => boolean) | null = null;
|
||||
|
||||
const FullModal = () => {
|
||||
const context = useContext(ModalContext);
|
||||
|
||||
@@ -106,6 +108,9 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
innerProps={innerProps}
|
||||
actions={{
|
||||
closeModal: () => context.closeModal(modal.id),
|
||||
setCloseInterceptor: (interceptor: (() => boolean) | null) => {
|
||||
closeInterceptor = interceptor;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -114,5 +119,11 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
return {
|
||||
modalProps: rest,
|
||||
content: <FullModal />,
|
||||
onCloseRequest: () => {
|
||||
if (closeInterceptor) {
|
||||
return closeInterceptor();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -5,10 +5,12 @@ import type { stringOrTranslation } from "@homarr/translation";
|
||||
|
||||
import type { ModalPresentation } from "./modal-presentation";
|
||||
|
||||
export type ModalComponent<TInnerProps> = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: TInnerProps;
|
||||
}) => ReactNode;
|
||||
export interface ModalActions {
|
||||
closeModal: () => void;
|
||||
setCloseInterceptor?: (interceptor: (() => boolean) | null) => void;
|
||||
}
|
||||
|
||||
export type ModalComponent<TInnerProps> = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode;
|
||||
|
||||
export type CreateModalOptions = Pick<
|
||||
ModalOptions<unknown>,
|
||||
|
||||
@@ -104,7 +104,7 @@ const CredentialsSetup = () => {
|
||||
|
||||
return (
|
||||
<AccountShell title={t("title")} description={t("description")}>
|
||||
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
|
||||
<form onSubmit={form.onSubmit((values) => submitAsync(values))}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
|
||||
<UserCreatePasswordFields
|
||||
@@ -119,7 +119,7 @@ const CredentialsSetup = () => {
|
||||
{submitError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
|
||||
<Button type="submit" size="md" loading={form.submitting} rightSection={<IconArrowRight size={18} />}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||
import { parse } from "superjson";
|
||||
|
||||
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 { ChannelSubscriptionTracker } from "./lib/channel-subscription-tracker";
|
||||
import { createRedisConnection } from "./lib/connection";
|
||||
|
||||
export {
|
||||
handshakeAsync,
|
||||
@@ -15,9 +25,31 @@ export const pingChannel = createSubPubChannel<
|
||||
>("ping");
|
||||
export const pingUrlChannel = createListChannel<string>("ping-url");
|
||||
|
||||
export interface LoggerMessage {
|
||||
message: string;
|
||||
level: LogLevel;
|
||||
}
|
||||
export type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
|
||||
|
||||
export const loggingChannel = createSubPubChannel<LoggerMessage>("logging");
|
||||
const loggingHistoryClient = createRedisConnection();
|
||||
|
||||
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,6 +18,7 @@ type SubscriptionCallback = (message: string) => MaybePromise<void>;
|
||||
*/
|
||||
export class ChannelSubscriptionTracker {
|
||||
private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>();
|
||||
private static subscriptionReadiness = new Map<string, Promise<void>>();
|
||||
private static redis = createRedisConnection();
|
||||
private static listenerActive = false;
|
||||
|
||||
@@ -28,6 +29,25 @@ export class ChannelSubscriptionTracker {
|
||||
* @returns a function to unsubscribe from the channel
|
||||
*/
|
||||
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 });
|
||||
|
||||
// We only want to activate the listener once
|
||||
@@ -38,11 +58,18 @@ export class ChannelSubscriptionTracker {
|
||||
|
||||
const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>();
|
||||
const id = randomUUID();
|
||||
let ready = this.subscriptionReadiness.get(channelName);
|
||||
|
||||
// If there are no subscriptions to the channel, subscribe to it
|
||||
if (channelSubscriptions.size === 0) {
|
||||
// Subscribe when this channel has no active or pending Redis subscription.
|
||||
if (!ready) {
|
||||
logger.debug("Subscribing to redis channel", { channel: channelName });
|
||||
void this.redis.subscribe(channelName);
|
||||
ready = this.redis.subscribe(channelName).then(() => undefined);
|
||||
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 });
|
||||
@@ -50,24 +77,26 @@ export class ChannelSubscriptionTracker {
|
||||
|
||||
this.subscriptions.set(channelName, channelSubscriptions);
|
||||
|
||||
// Return a function to unsubscribe
|
||||
return () => {
|
||||
const unsubscribe = () => {
|
||||
logger.debug("Removing redis channel callback", { channel: channelName, id });
|
||||
|
||||
const channelSubscriptions = this.subscriptions.get(channelName);
|
||||
if (!channelSubscriptions) return;
|
||||
const currentSubscriptions = this.subscriptions.get(channelName);
|
||||
if (!currentSubscriptions) return;
|
||||
|
||||
channelSubscriptions.delete(id);
|
||||
currentSubscriptions.delete(id);
|
||||
|
||||
// If there are no subscriptions to the channel, unsubscribe from it
|
||||
if (channelSubscriptions.size >= 1) {
|
||||
if (currentSubscriptions.size >= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Unsubscribing from redis channel", { channel: channelName });
|
||||
void this.redis.unsubscribe(channelName);
|
||||
this.subscriptions.delete(channelName);
|
||||
this.subscriptionReadiness.delete(channelName);
|
||||
};
|
||||
|
||||
return { ready, unsubscribe };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { airQualityRequestHandler } from "./air-quality";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const makeAirQualityResponse = () => {
|
||||
const times = ["2026-07-01T00:00", "2026-07-01T01:00", "2026-07-02T00:00"];
|
||||
const values = [10, 20, 30];
|
||||
const fields = [
|
||||
"european_aqi",
|
||||
"us_aqi",
|
||||
"uv_index",
|
||||
"pm10",
|
||||
"pm2_5",
|
||||
"carbon_monoxide",
|
||||
"nitrogen_dioxide",
|
||||
"sulphur_dioxide",
|
||||
"ozone",
|
||||
"european_aqi_pm2_5",
|
||||
"european_aqi_pm10",
|
||||
"european_aqi_nitrogen_dioxide",
|
||||
"european_aqi_ozone",
|
||||
"european_aqi_sulphur_dioxide",
|
||||
"us_aqi_pm2_5",
|
||||
"us_aqi_pm10",
|
||||
"us_aqi_nitrogen_dioxide",
|
||||
"us_aqi_carbon_monoxide",
|
||||
"us_aqi_ozone",
|
||||
"us_aqi_sulphur_dioxide",
|
||||
"alder_pollen",
|
||||
"birch_pollen",
|
||||
"grass_pollen",
|
||||
"mugwort_pollen",
|
||||
"olive_pollen",
|
||||
"ragweed_pollen",
|
||||
] as const;
|
||||
|
||||
return {
|
||||
timezone: "Europe/Berlin",
|
||||
utc_offset_seconds: 7200,
|
||||
current: {
|
||||
time: "2026-07-01T12:00",
|
||||
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? null : 12])),
|
||||
},
|
||||
hourly: {
|
||||
time: times,
|
||||
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? [null, null, null] : values])),
|
||||
},
|
||||
fields,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
airQualityRequestHandler.invalidateCache();
|
||||
});
|
||||
|
||||
describe("air quality request handler", () => {
|
||||
it("requests and normalizes the full useful forecast field set", async () => {
|
||||
const fixture = makeAirQualityResponse();
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(fixture)));
|
||||
|
||||
const first = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
const second = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
|
||||
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
|
||||
expect(first.data.current.pollutants.pm2_5).toBe(12);
|
||||
expect(first.data.current.pollen.ragweed).toBeNull();
|
||||
expect(first.data.hourly[0]?.observedAt).toBe("2026-06-30T22:00:00.000Z");
|
||||
expect(first.data.daily).toEqual([
|
||||
{ date: "2026-07-01", europeanAqiMax: 20, usAqiMax: 20, uvIndexMax: 20, pm2_5Max: 20, pm10Max: 20 },
|
||||
{ date: "2026-07-02", europeanAqiMax: 30, usAqiMax: 30, uvIndexMax: 30, pm2_5Max: 30, pm10Max: 30 },
|
||||
]);
|
||||
expect(second.data).toStrictEqual(first.data);
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
||||
expect(requestUrl.origin).toBe("https://air-quality-api.open-meteo.com");
|
||||
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
|
||||
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
|
||||
expect(requestUrl.searchParams.get("current")).toContain("european_aqi_pm2_5");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("us_aqi_carbon_monoxide");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("ragweed_pollen");
|
||||
});
|
||||
|
||||
it("rejects unsuccessful Open-Meteo responses", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
|
||||
|
||||
await expect(
|
||||
airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const FORECAST_DAYS = 7;
|
||||
|
||||
const requestedVariables = [
|
||||
"european_aqi",
|
||||
"us_aqi",
|
||||
"uv_index",
|
||||
"pm10",
|
||||
"pm2_5",
|
||||
"carbon_monoxide",
|
||||
"nitrogen_dioxide",
|
||||
"sulphur_dioxide",
|
||||
"ozone",
|
||||
"european_aqi_pm2_5",
|
||||
"european_aqi_pm10",
|
||||
"european_aqi_nitrogen_dioxide",
|
||||
"european_aqi_ozone",
|
||||
"european_aqi_sulphur_dioxide",
|
||||
"us_aqi_pm2_5",
|
||||
"us_aqi_pm10",
|
||||
"us_aqi_nitrogen_dioxide",
|
||||
"us_aqi_carbon_monoxide",
|
||||
"us_aqi_ozone",
|
||||
"us_aqi_sulphur_dioxide",
|
||||
"alder_pollen",
|
||||
"birch_pollen",
|
||||
"grass_pollen",
|
||||
"mugwort_pollen",
|
||||
"olive_pollen",
|
||||
"ragweed_pollen",
|
||||
] as const;
|
||||
|
||||
export const airQualityRequestHandler = createWidgetRequestHandler({
|
||||
cacheTtlMs: 15 * 60 * 1000,
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 24 * 60 * 60 * 1000,
|
||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||
const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
|
||||
url.searchParams.set("latitude", input.latitude.toString());
|
||||
url.searchParams.set("longitude", input.longitude.toString());
|
||||
url.searchParams.set("current", requestedVariables.join(","));
|
||||
url.searchParams.set("hourly", requestedVariables.join(","));
|
||||
url.searchParams.set("forecast_days", FORECAST_DAYS.toString());
|
||||
url.searchParams.set("timezone", "auto");
|
||||
|
||||
const response = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
});
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const json: unknown = await response.json();
|
||||
const airQuality = await atLocationOutput.parseAsync(json);
|
||||
const hourly = airQuality.hourly.time.map((value, index) => {
|
||||
return normalizePoint(airQuality.hourly, index, toUtcInstant(value, airQuality.timezone));
|
||||
});
|
||||
|
||||
return {
|
||||
timezone: airQuality.timezone,
|
||||
utcOffsetSeconds: airQuality.utc_offset_seconds,
|
||||
current: normalizePoint(
|
||||
airQuality.current,
|
||||
undefined,
|
||||
toUtcInstant(airQuality.current.time, airQuality.timezone),
|
||||
),
|
||||
hourly,
|
||||
daily: getDailyMaximums(airQuality.hourly),
|
||||
} satisfies AirQuality;
|
||||
},
|
||||
});
|
||||
|
||||
const nullableNumber = z.number().nullable();
|
||||
const nullableNumberArray = z.array(nullableNumber);
|
||||
|
||||
const currentValues = z.object({
|
||||
time: z.string(),
|
||||
european_aqi: nullableNumber,
|
||||
us_aqi: nullableNumber,
|
||||
uv_index: nullableNumber,
|
||||
pm10: nullableNumber,
|
||||
pm2_5: nullableNumber,
|
||||
carbon_monoxide: nullableNumber,
|
||||
nitrogen_dioxide: nullableNumber,
|
||||
sulphur_dioxide: nullableNumber,
|
||||
ozone: nullableNumber,
|
||||
european_aqi_pm2_5: nullableNumber,
|
||||
european_aqi_pm10: nullableNumber,
|
||||
european_aqi_nitrogen_dioxide: nullableNumber,
|
||||
european_aqi_ozone: nullableNumber,
|
||||
european_aqi_sulphur_dioxide: nullableNumber,
|
||||
us_aqi_pm2_5: nullableNumber,
|
||||
us_aqi_pm10: nullableNumber,
|
||||
us_aqi_nitrogen_dioxide: nullableNumber,
|
||||
us_aqi_carbon_monoxide: nullableNumber,
|
||||
us_aqi_ozone: nullableNumber,
|
||||
us_aqi_sulphur_dioxide: nullableNumber,
|
||||
alder_pollen: nullableNumber,
|
||||
birch_pollen: nullableNumber,
|
||||
grass_pollen: nullableNumber,
|
||||
mugwort_pollen: nullableNumber,
|
||||
olive_pollen: nullableNumber,
|
||||
ragweed_pollen: nullableNumber,
|
||||
});
|
||||
|
||||
const hourlyValues = z.object({
|
||||
time: z.array(z.string()),
|
||||
european_aqi: nullableNumberArray,
|
||||
us_aqi: nullableNumberArray,
|
||||
uv_index: nullableNumberArray,
|
||||
pm10: nullableNumberArray,
|
||||
pm2_5: nullableNumberArray,
|
||||
carbon_monoxide: nullableNumberArray,
|
||||
nitrogen_dioxide: nullableNumberArray,
|
||||
sulphur_dioxide: nullableNumberArray,
|
||||
ozone: nullableNumberArray,
|
||||
european_aqi_pm2_5: nullableNumberArray,
|
||||
european_aqi_pm10: nullableNumberArray,
|
||||
european_aqi_nitrogen_dioxide: nullableNumberArray,
|
||||
european_aqi_ozone: nullableNumberArray,
|
||||
european_aqi_sulphur_dioxide: nullableNumberArray,
|
||||
us_aqi_pm2_5: nullableNumberArray,
|
||||
us_aqi_pm10: nullableNumberArray,
|
||||
us_aqi_nitrogen_dioxide: nullableNumberArray,
|
||||
us_aqi_carbon_monoxide: nullableNumberArray,
|
||||
us_aqi_ozone: nullableNumberArray,
|
||||
us_aqi_sulphur_dioxide: nullableNumberArray,
|
||||
alder_pollen: nullableNumberArray,
|
||||
birch_pollen: nullableNumberArray,
|
||||
grass_pollen: nullableNumberArray,
|
||||
mugwort_pollen: nullableNumberArray,
|
||||
olive_pollen: nullableNumberArray,
|
||||
ragweed_pollen: nullableNumberArray,
|
||||
});
|
||||
|
||||
const atLocationOutput = z.object({
|
||||
timezone: z.string().min(1),
|
||||
utc_offset_seconds: z.number(),
|
||||
current: currentValues,
|
||||
hourly: hourlyValues,
|
||||
});
|
||||
|
||||
type CurrentValues = z.infer<typeof currentValues>;
|
||||
type HourlyValues = z.infer<typeof hourlyValues>;
|
||||
|
||||
const getValue = (values: CurrentValues | HourlyValues, field: keyof Omit<CurrentValues, "time">, index?: number) => {
|
||||
const value = values[field];
|
||||
if (Array.isArray(value)) return value[index ?? 0] ?? null;
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizePoint = (
|
||||
values: CurrentValues | HourlyValues,
|
||||
index: number | undefined,
|
||||
observedAt: string,
|
||||
): AirQualityPoint => ({
|
||||
observedAt,
|
||||
europeanAqi: getValue(values, "european_aqi", index),
|
||||
usAqi: getValue(values, "us_aqi", index),
|
||||
uvIndex: getValue(values, "uv_index", index),
|
||||
pollutants: {
|
||||
pm2_5: getValue(values, "pm2_5", index),
|
||||
pm10: getValue(values, "pm10", index),
|
||||
ozone: getValue(values, "ozone", index),
|
||||
nitrogenDioxide: getValue(values, "nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "sulphur_dioxide", index),
|
||||
carbonMonoxide: getValue(values, "carbon_monoxide", index),
|
||||
},
|
||||
europeanSubIndices: {
|
||||
pm2_5: getValue(values, "european_aqi_pm2_5", index),
|
||||
pm10: getValue(values, "european_aqi_pm10", index),
|
||||
ozone: getValue(values, "european_aqi_ozone", index),
|
||||
nitrogenDioxide: getValue(values, "european_aqi_nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "european_aqi_sulphur_dioxide", index),
|
||||
carbonMonoxide: null,
|
||||
},
|
||||
usSubIndices: {
|
||||
pm2_5: getValue(values, "us_aqi_pm2_5", index),
|
||||
pm10: getValue(values, "us_aqi_pm10", index),
|
||||
ozone: getValue(values, "us_aqi_ozone", index),
|
||||
nitrogenDioxide: getValue(values, "us_aqi_nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "us_aqi_sulphur_dioxide", index),
|
||||
carbonMonoxide: getValue(values, "us_aqi_carbon_monoxide", index),
|
||||
},
|
||||
pollen: {
|
||||
alder: getValue(values, "alder_pollen", index),
|
||||
birch: getValue(values, "birch_pollen", index),
|
||||
grass: getValue(values, "grass_pollen", index),
|
||||
mugwort: getValue(values, "mugwort_pollen", index),
|
||||
olive: getValue(values, "olive_pollen", index),
|
||||
ragweed: getValue(values, "ragweed_pollen", index),
|
||||
},
|
||||
});
|
||||
|
||||
const getDailyMaximums = (hourly: HourlyValues): AirQuality["daily"] => {
|
||||
const grouped = new Map<string, AirQuality["daily"][number]>();
|
||||
|
||||
hourly.time.forEach((time, index) => {
|
||||
const date = time.slice(0, 10);
|
||||
const existing = grouped.get(date) ?? {
|
||||
date,
|
||||
europeanAqiMax: null,
|
||||
usAqiMax: null,
|
||||
uvIndexMax: null,
|
||||
pm2_5Max: null,
|
||||
pm10Max: null,
|
||||
};
|
||||
|
||||
existing.europeanAqiMax = maxNullable(existing.europeanAqiMax, hourly.european_aqi[index]);
|
||||
existing.usAqiMax = maxNullable(existing.usAqiMax, hourly.us_aqi[index]);
|
||||
existing.uvIndexMax = maxNullable(existing.uvIndexMax, hourly.uv_index[index]);
|
||||
existing.pm2_5Max = maxNullable(existing.pm2_5Max, hourly.pm2_5[index]);
|
||||
existing.pm10Max = maxNullable(existing.pm10Max, hourly.pm10[index]);
|
||||
grouped.set(date, existing);
|
||||
});
|
||||
|
||||
return Array.from(grouped.values()).slice(0, FORECAST_DAYS);
|
||||
};
|
||||
|
||||
const maxNullable = (current: number | null, candidate: number | null | undefined): number | null => {
|
||||
if (candidate === null || candidate === undefined) return current;
|
||||
if (current === null) return candidate;
|
||||
return Math.max(current, candidate);
|
||||
};
|
||||
|
||||
const toUtcInstant = (value: string, timeZone: string): string => {
|
||||
const parsed = dayjs.tz(value, timeZone);
|
||||
if (!parsed.isValid()) throw new Error(`Invalid air quality timestamp: ${value}`);
|
||||
return parsed.utc().toISOString();
|
||||
};
|
||||
|
||||
export interface AirQualityPollutants {
|
||||
pm2_5: number | null;
|
||||
pm10: number | null;
|
||||
ozone: number | null;
|
||||
nitrogenDioxide: number | null;
|
||||
sulphurDioxide: number | null;
|
||||
carbonMonoxide: number | null;
|
||||
}
|
||||
|
||||
export interface AirQualityPoint {
|
||||
observedAt: string;
|
||||
europeanAqi: number | null;
|
||||
usAqi: number | null;
|
||||
uvIndex: number | null;
|
||||
pollutants: AirQualityPollutants;
|
||||
europeanSubIndices: AirQualityPollutants;
|
||||
usSubIndices: AirQualityPollutants;
|
||||
pollen: {
|
||||
alder: number | null;
|
||||
birch: number | null;
|
||||
grass: number | null;
|
||||
mugwort: number | null;
|
||||
olive: number | null;
|
||||
ragweed: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AirQuality {
|
||||
timezone: string;
|
||||
utcOffsetSeconds: number;
|
||||
current: AirQualityPoint;
|
||||
hourly: AirQualityPoint[];
|
||||
daily: {
|
||||
date: string;
|
||||
europeanAqiMax: number | null;
|
||||
usAqiMax: number | null;
|
||||
uvIndexMax: number | null;
|
||||
pm2_5Max: number | null;
|
||||
pm10Max: number | null;
|
||||
}[];
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import { createRequestHandler } from "./request-handler";
|
||||
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
}
|
||||
|
||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
|
||||
const inner = createRequestHandler<TData, TInput>(options);
|
||||
return inner;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { weatherRequestHandler } from "./weather";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const makeWeatherResponse = () => {
|
||||
const hours = Array.from({ length: 25 }, (_, index) => `2026-07-01T${String(index % 24).padStart(2, "0")}:00`);
|
||||
const days = [
|
||||
"2026-06-30",
|
||||
...Array.from({ length: 7 }, (_, index) => `2026-07-${String(index + 1).padStart(2, "0")}`),
|
||||
];
|
||||
const hourlyValues = hours.map<number | null>((_, index) => index);
|
||||
const dailyValues = days.map<number | null>((_, index) => index);
|
||||
const sunrise = days.map<string | null>((day) => `${day}T05:00`);
|
||||
|
||||
return {
|
||||
timezone: "Europe/Berlin",
|
||||
utc_offset_seconds: 7200,
|
||||
current: {
|
||||
time: "2026-07-01T12:00",
|
||||
temperature_2m: 24,
|
||||
relative_humidity_2m: 55,
|
||||
apparent_temperature: 25,
|
||||
is_day: 1,
|
||||
precipitation: 0,
|
||||
weather_code: 1,
|
||||
wind_speed_10m: 12,
|
||||
wind_gusts_10m: 20,
|
||||
},
|
||||
hourly: {
|
||||
time: hours,
|
||||
temperature_2m: hourlyValues,
|
||||
apparent_temperature: hourlyValues,
|
||||
precipitation_probability: hourlyValues,
|
||||
precipitation: hourlyValues,
|
||||
weather_code: hourlyValues,
|
||||
relative_humidity_2m: hourlyValues,
|
||||
wind_speed_10m: hourlyValues,
|
||||
wind_gusts_10m: hourlyValues,
|
||||
},
|
||||
daily: {
|
||||
time: days,
|
||||
weather_code: dailyValues,
|
||||
temperature_2m_max: dailyValues,
|
||||
temperature_2m_min: dailyValues,
|
||||
apparent_temperature_max: dailyValues,
|
||||
apparent_temperature_min: dailyValues,
|
||||
precipitation_probability_max: dailyValues,
|
||||
precipitation_sum: dailyValues,
|
||||
relative_humidity_2m_mean: dailyValues,
|
||||
wind_speed_10m_max: dailyValues,
|
||||
wind_gusts_10m_max: dailyValues,
|
||||
uv_index_max: dailyValues,
|
||||
sunrise,
|
||||
sunset: days.map((day) => `${day}T21:00`),
|
||||
daylight_duration: dailyValues,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
weatherRequestHandler.invalidateCache();
|
||||
});
|
||||
|
||||
describe("weather request handler", () => {
|
||||
it("requests bounded useful forecasts and normalizes location times", async () => {
|
||||
const response = makeWeatherResponse();
|
||||
response.hourly.temperature_2m = [null];
|
||||
Reflect.deleteProperty(response.hourly, "wind_gusts_10m");
|
||||
response.daily.sunrise[1] = null;
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(response)));
|
||||
|
||||
const first = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
const second = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
|
||||
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
|
||||
expect(first.data.hourly).toHaveLength(24);
|
||||
expect(first.data.daily).toHaveLength(7);
|
||||
expect(first.data.daily[0]?.date).toBe("2026-07-01");
|
||||
expect(first.data.hourly[0]?.temperature).toBeNull();
|
||||
expect(first.data.hourly[1]?.temperature).toBeNull();
|
||||
expect(first.data.hourly[0]?.windGusts).toBeNull();
|
||||
expect(first.data.daily[0]?.sunriseAt).toBeNull();
|
||||
expect(first.data.daily[1]?.sunriseAt).toBe("2026-07-02T03:00:00.000Z");
|
||||
expect(second.data).toStrictEqual(first.data);
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
||||
expect(requestUrl.searchParams.get("forecast_hours")).toBe("24");
|
||||
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
|
||||
expect(requestUrl.searchParams.has("past_days")).toBe(false);
|
||||
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
|
||||
expect(requestUrl.searchParams.get("current")).toContain("apparent_temperature");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("precipitation_probability");
|
||||
expect(requestUrl.searchParams.get("daily")).toContain("uv_index_max");
|
||||
});
|
||||
|
||||
it("rejects unsuccessful Open-Meteo responses", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
|
||||
|
||||
await expect(weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,73 +1,255 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const HOURLY_FORECAST_LENGTH = 24;
|
||||
const DAILY_FORECAST_LENGTH = 7;
|
||||
|
||||
const requestWeatherAsync = async (input: { latitude: number; longitude: number }): Promise<Weather> => {
|
||||
const url = new URL("https://api.open-meteo.com/v1/forecast");
|
||||
url.searchParams.set("latitude", input.latitude.toString());
|
||||
url.searchParams.set("longitude", input.longitude.toString());
|
||||
url.searchParams.set(
|
||||
"current",
|
||||
[
|
||||
"temperature_2m",
|
||||
"relative_humidity_2m",
|
||||
"apparent_temperature",
|
||||
"is_day",
|
||||
"precipitation",
|
||||
"weather_code",
|
||||
"wind_speed_10m",
|
||||
"wind_gusts_10m",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set(
|
||||
"hourly",
|
||||
[
|
||||
"temperature_2m",
|
||||
"apparent_temperature",
|
||||
"precipitation_probability",
|
||||
"precipitation",
|
||||
"weather_code",
|
||||
"relative_humidity_2m",
|
||||
"wind_speed_10m",
|
||||
"wind_gusts_10m",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set(
|
||||
"daily",
|
||||
[
|
||||
"weather_code",
|
||||
"temperature_2m_max",
|
||||
"temperature_2m_min",
|
||||
"apparent_temperature_max",
|
||||
"apparent_temperature_min",
|
||||
"precipitation_probability_max",
|
||||
"precipitation_sum",
|
||||
"relative_humidity_2m_mean",
|
||||
"wind_speed_10m_max",
|
||||
"wind_gusts_10m_max",
|
||||
"uv_index_max",
|
||||
"sunrise",
|
||||
"sunset",
|
||||
"daylight_duration",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set("timezone", "auto");
|
||||
url.searchParams.set("forecast_hours", HOURLY_FORECAST_LENGTH.toString());
|
||||
url.searchParams.set("forecast_days", DAILY_FORECAST_LENGTH.toString());
|
||||
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
});
|
||||
if (!res.ok) throw new ResponseError(res);
|
||||
|
||||
const json: unknown = await res.json();
|
||||
const weather = await atLocationOutput.parseAsync(json);
|
||||
const currentLocalDate = weather.current.time.slice(0, 10);
|
||||
const matchedCurrentDayIndex = weather.daily.time.indexOf(currentLocalDate);
|
||||
const currentDayIndex = matchedCurrentDayIndex < 0 ? 0 : matchedCurrentDayIndex;
|
||||
const dailyIndexes = weather.daily.time
|
||||
.map((_, index) => index)
|
||||
.slice(currentDayIndex, currentDayIndex + DAILY_FORECAST_LENGTH);
|
||||
return {
|
||||
timezone: weather.timezone,
|
||||
utcOffsetSeconds: weather.utc_offset_seconds,
|
||||
current: {
|
||||
observedAt: toUtcInstant(weather.current.time, weather.timezone),
|
||||
temperature: weather.current.temperature_2m,
|
||||
apparentTemperature: weather.current.apparent_temperature,
|
||||
relativeHumidity: weather.current.relative_humidity_2m,
|
||||
precipitation: weather.current.precipitation,
|
||||
weatherCode: weather.current.weather_code,
|
||||
weathercode: weather.current.weather_code,
|
||||
windSpeed: weather.current.wind_speed_10m,
|
||||
windspeed: weather.current.wind_speed_10m,
|
||||
windGusts: weather.current.wind_gusts_10m,
|
||||
isDay: weather.current.is_day === 1,
|
||||
},
|
||||
hourly: weather.hourly.time.slice(0, HOURLY_FORECAST_LENGTH).map((value, index) => {
|
||||
return {
|
||||
observedAt: toUtcInstant(value, weather.timezone),
|
||||
temperature: weather.hourly.temperature_2m[index] ?? null,
|
||||
apparentTemperature: weather.hourly.apparent_temperature[index] ?? null,
|
||||
precipitationProbability: weather.hourly.precipitation_probability[index] ?? null,
|
||||
precipitation: weather.hourly.precipitation[index] ?? null,
|
||||
weatherCode: weather.hourly.weather_code[index] ?? null,
|
||||
relativeHumidity: weather.hourly.relative_humidity_2m[index] ?? null,
|
||||
windSpeed: weather.hourly.wind_speed_10m[index] ?? null,
|
||||
windGusts: weather.hourly.wind_gusts_10m[index] ?? null,
|
||||
};
|
||||
}),
|
||||
daily: dailyIndexes.map((index) => {
|
||||
const value = weather.daily.time[index] ?? currentLocalDate;
|
||||
return {
|
||||
date: value,
|
||||
time: value,
|
||||
weatherCode: weather.daily.weather_code[index] ?? 404,
|
||||
maxTemperature: weather.daily.temperature_2m_max[index] ?? null,
|
||||
maxTemp: weather.daily.temperature_2m_max[index] ?? undefined,
|
||||
minTemperature: weather.daily.temperature_2m_min[index] ?? null,
|
||||
minTemp: weather.daily.temperature_2m_min[index] ?? undefined,
|
||||
maxApparentTemperature: weather.daily.apparent_temperature_max[index] ?? null,
|
||||
minApparentTemperature: weather.daily.apparent_temperature_min[index] ?? null,
|
||||
precipitationProbability: weather.daily.precipitation_probability_max[index] ?? null,
|
||||
precipitation: weather.daily.precipitation_sum[index] ?? null,
|
||||
averageHumidity: weather.daily.relative_humidity_2m_mean[index] ?? null,
|
||||
humidity: weather.daily.relative_humidity_2m_mean[index] ?? undefined,
|
||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index] ?? null,
|
||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index] ?? null,
|
||||
uvIndex: weather.daily.uv_index_max[index] ?? null,
|
||||
sunriseAt: toOptionalUtcInstant(weather.daily.sunrise[index], weather.timezone),
|
||||
sunrise: weather.daily.sunrise[index] ?? undefined,
|
||||
sunsetAt: toOptionalUtcInstant(weather.daily.sunset[index], weather.timezone),
|
||||
sunset: weather.daily.sunset[index] ?? undefined,
|
||||
daylightDuration: weather.daily.daylight_duration[index] ?? null,
|
||||
};
|
||||
}),
|
||||
} satisfies Weather;
|
||||
};
|
||||
|
||||
export const weatherRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean¤t_weather=true&timezone=auto`,
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
const json: unknown = await res.json();
|
||||
const weather = await atLocationOutput.parseAsync(json);
|
||||
return {
|
||||
current: weather.current_weather,
|
||||
daily: weather.daily.time.map((value, index) => {
|
||||
return {
|
||||
time: value,
|
||||
weatherCode: weather.daily.weathercode[index] ?? 404,
|
||||
maxTemp: weather.daily.temperature_2m_max[index],
|
||||
minTemp: weather.daily.temperature_2m_min[index],
|
||||
sunrise: weather.daily.sunrise[index],
|
||||
sunset: weather.daily.sunset[index],
|
||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
||||
humidity: weather.daily.relative_humidity_2m_mean[index],
|
||||
};
|
||||
}),
|
||||
} satisfies Weather;
|
||||
},
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
requestAsync: requestWeatherAsync,
|
||||
});
|
||||
|
||||
const nullableNumberArray = z.array(z.number().nullable()).default([]);
|
||||
const nullableStringArray = z.array(z.string().nullable()).default([]);
|
||||
|
||||
const atLocationOutput = z.object({
|
||||
current_weather: z.object({
|
||||
weathercode: z.number(),
|
||||
temperature: z.number(),
|
||||
windspeed: z.number(),
|
||||
timezone: z.string().min(1),
|
||||
utc_offset_seconds: z.number(),
|
||||
current: z.object({
|
||||
time: z.string(),
|
||||
temperature_2m: z.number(),
|
||||
relative_humidity_2m: z.number(),
|
||||
apparent_temperature: z.number(),
|
||||
is_day: z.number(),
|
||||
precipitation: z.number(),
|
||||
weather_code: z.number(),
|
||||
wind_speed_10m: z.number(),
|
||||
wind_gusts_10m: z.number(),
|
||||
}),
|
||||
hourly: z.object({
|
||||
time: z.array(z.string()),
|
||||
temperature_2m: nullableNumberArray,
|
||||
apparent_temperature: nullableNumberArray,
|
||||
precipitation_probability: nullableNumberArray,
|
||||
precipitation: nullableNumberArray,
|
||||
weather_code: nullableNumberArray,
|
||||
relative_humidity_2m: nullableNumberArray,
|
||||
wind_speed_10m: nullableNumberArray,
|
||||
wind_gusts_10m: nullableNumberArray,
|
||||
}),
|
||||
daily: z.object({
|
||||
time: z.array(z.string()),
|
||||
weathercode: z.array(z.number()),
|
||||
temperature_2m_max: z.array(z.number()),
|
||||
temperature_2m_min: z.array(z.number()),
|
||||
sunrise: z.array(z.string()),
|
||||
sunset: z.array(z.string()),
|
||||
wind_speed_10m_max: z.array(z.number()),
|
||||
wind_gusts_10m_max: z.array(z.number()),
|
||||
relative_humidity_2m_mean: z.array(z.number()),
|
||||
weather_code: nullableNumberArray,
|
||||
temperature_2m_max: nullableNumberArray,
|
||||
temperature_2m_min: nullableNumberArray,
|
||||
apparent_temperature_max: nullableNumberArray,
|
||||
apparent_temperature_min: nullableNumberArray,
|
||||
precipitation_probability_max: nullableNumberArray,
|
||||
precipitation_sum: nullableNumberArray,
|
||||
relative_humidity_2m_mean: nullableNumberArray,
|
||||
wind_speed_10m_max: nullableNumberArray,
|
||||
wind_gusts_10m_max: nullableNumberArray,
|
||||
uv_index_max: nullableNumberArray,
|
||||
sunrise: nullableStringArray,
|
||||
sunset: nullableStringArray,
|
||||
daylight_duration: nullableNumberArray,
|
||||
}),
|
||||
});
|
||||
|
||||
const toUtcInstant = (value: string, timeZone: string): string => {
|
||||
const parsed = dayjs.tz(value, timeZone);
|
||||
if (!parsed.isValid()) throw new Error(`Invalid weather timestamp: ${value}`);
|
||||
return parsed.utc().toISOString();
|
||||
};
|
||||
|
||||
const toOptionalUtcInstant = (value: string | null | undefined, timeZone: string): string | null => {
|
||||
if (!value) return null;
|
||||
return toUtcInstant(value, timeZone);
|
||||
};
|
||||
|
||||
export interface Weather {
|
||||
timezone: string;
|
||||
utcOffsetSeconds: number;
|
||||
current: {
|
||||
weathercode: number;
|
||||
temperature: number;
|
||||
apparentTemperature: number;
|
||||
relativeHumidity: number;
|
||||
precipitation: number;
|
||||
weatherCode: number;
|
||||
weathercode: number;
|
||||
windSpeed: number;
|
||||
windspeed: number;
|
||||
windGusts: number;
|
||||
isDay: boolean;
|
||||
observedAt: string;
|
||||
};
|
||||
hourly: {
|
||||
observedAt: string;
|
||||
temperature: number | null;
|
||||
apparentTemperature: number | null;
|
||||
precipitationProbability: number | null;
|
||||
precipitation: number | null;
|
||||
weatherCode: number | null;
|
||||
relativeHumidity: number | null;
|
||||
windSpeed: number | null;
|
||||
windGusts: number | null;
|
||||
}[];
|
||||
daily: {
|
||||
date: string;
|
||||
time: string;
|
||||
weatherCode: number;
|
||||
maxTemperature: number | null;
|
||||
maxTemp: number | undefined;
|
||||
minTemperature: number | null;
|
||||
minTemp: number | undefined;
|
||||
sunrise: string | undefined;
|
||||
sunset: string | undefined;
|
||||
maxWindSpeed: number | undefined;
|
||||
maxWindGusts: number | undefined;
|
||||
maxApparentTemperature: number | null;
|
||||
minApparentTemperature: number | null;
|
||||
precipitationProbability: number | null;
|
||||
precipitation: number | null;
|
||||
averageHumidity: number | null;
|
||||
humidity: number | undefined;
|
||||
maxWindSpeed: number | null;
|
||||
maxWindGusts: number | null;
|
||||
uvIndex: number | null;
|
||||
sunriseAt: string | null;
|
||||
sunrise: string | undefined;
|
||||
sunsetAt: string | null;
|
||||
sunset: string | undefined;
|
||||
daylightDuration: number | null;
|
||||
}[];
|
||||
}
|
||||
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user