Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
193a536a2d | ||
|
|
c8df2fbe5f | ||
|
|
7c4c1ffbe7 | ||
|
|
84b000afda | ||
|
|
7710abcf26 | ||
|
|
4c475b8c0a | ||
|
|
f3014fe271 | ||
|
|
bd4f6a92c6 | ||
|
|
7e8c139590 | ||
|
|
d241bac55d | ||
|
|
58a24569ce | ||
|
|
53b518960c | ||
|
|
ddf8ba9948 | ||
|
|
a2219b72f6 | ||
|
|
7150cdbbab | ||
|
|
ff14613f95 | ||
|
|
78b3dc1922 | ||
|
|
2e97b337d2 | ||
|
|
369a2660b1 | ||
|
|
60aec370d6 | ||
|
|
6a76b9eafe | ||
|
|
76ba13fbad | ||
|
|
150d38be62 | ||
|
|
a4bbed0cb8 | ||
|
|
90fa9e8741 | ||
|
|
4b20d6ecd0 | ||
|
|
6cac34e968 | ||
|
|
c6f77cec2e | ||
|
|
acc97da84b | ||
|
|
6b10ac2ab5 | ||
|
|
c7563f2c39 | ||
|
|
96676182f4 | ||
|
|
3769d88ebb | ||
|
|
d9c35744f6 | ||
|
|
33991f4e22 | ||
|
|
df300919fc |
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
|
||||
@@ -92,3 +94,6 @@ homarr-backup*.zip
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
/scripts/benchmarks/
|
||||
|
||||
# PR description screenshots belong on the pull request, not in the repository
|
||||
.screenshots/
|
||||
@@ -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`.
|
||||
@@ -72,6 +72,34 @@ API key authentication enables programmatic access for:
|
||||
- Custom dashboards and monitoring tools
|
||||
- Container orchestration systems that need to sync app definitions
|
||||
|
||||
## Permissions
|
||||
|
||||
API keys act as the user that created them, so every request is subject to that user's group
|
||||
permissions. A call that the user is not allowed to make returns `FORBIDDEN`, and list endpoints
|
||||
return only the records the user may see rather than failing.
|
||||
|
||||
### App endpoints require app management access
|
||||
|
||||
`GET /api/apps`, `GET /api/apps/paginated` and `GET /api/apps/search` (tRPC `app.all`,
|
||||
`app.getPaginated`, `app.search`) require the **modify all apps** permission. They previously
|
||||
returned the full app catalogue — including internal URLs — to any authenticated user.
|
||||
|
||||
If a script that used to work now returns `FORBIDDEN`, grant **modify all apps** to a group the key
|
||||
owner belongs to, or switch to `GET /api/apps/selectable`, which returns the reduced fields used by
|
||||
board pickers.
|
||||
|
||||
### Integration endpoints are filtered per key
|
||||
|
||||
`integration.all`, `integration.allThatSupportSearch`, `integration.allOfGivenCategory` and
|
||||
`integration.search` filter server side. Without a global integration permission, a key only
|
||||
receives the integrations its owner has been granted access to, so responses may contain fewer
|
||||
records than before rather than returning an error. `integration.search` applies its `limit` after
|
||||
filtering, so a scoped key still receives a full page of results.
|
||||
|
||||
Each returned integration carries a `permissions` object — check `hasUseAccess` before reading data
|
||||
and `hasInteractAccess` before performing actions; `false` means the key owner lacks that level for
|
||||
that integration, not that the call failed.
|
||||
|
||||
## Board Automation
|
||||
|
||||
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
|
||||
|
||||
@@ -51,6 +51,17 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
|
||||
|
||||
## List of apps
|
||||
|
||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app
|
||||
directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||
|
||||
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
|
||||
### Accessing the page
|
||||
|
||||
The `/manage/apps` page is reachable when you can manage all apps (`app-modify-all`) or create apps (`app-create`):
|
||||
|
||||
- **Manage all (`app-modify-all`)**: the full list of apps is shown and searchable, and each row can be edited.
|
||||
- **Create only (`app-create`)**: the page shows an empty "create an app" state instead of the list, so the names and
|
||||
URLs of existing apps are never disclosed. The create action is still available.
|
||||
- **Neither**: the page is not reachable and returns a not-found page.
|
||||
|
||||
Creating an app requires `app-create`, editing requires `app-modify-all`, and deleting requires `app-full-all`;
|
||||
board permissions are not required.
|
||||
@@ -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 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
|
||||
|
||||
|
||||
@@ -17,6 +17,24 @@ To create an integration, choose **New integration** and select the supported ap
|
||||
|
||||
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
|
||||
|
||||
### Accessing the page
|
||||
|
||||
The `/manage/integrations` page is reachable when at least one of the following applies:
|
||||
|
||||
- you can manage all integrations (global `integration-full-all`),
|
||||
- you can create integrations (`integration-create`), or
|
||||
- you have been granted full access to at least one specific integration.
|
||||
|
||||
What the page shows depends on the case:
|
||||
|
||||
- **Manage all (`integration-full-all`)**: the full list of integrations is shown.
|
||||
- **Create only (`integration-create`)**: the page shows an empty "create an integration" state instead of the list, with the create action still available.
|
||||
- **Delegated (full access on one or more)**: only the integrations you have full access to are listed, above a note making clear that the view is scoped to what you can manage.
|
||||
- **None**: the page is not reachable and returns a not-found page.
|
||||
|
||||
Where you have a mix of access, the list combines the integrations you can manage. Integration lists are filtered
|
||||
server side, so a user never receives data about integrations they lack access to.
|
||||
|
||||
Next, you'll need to fill out the required fields to configure the integration:
|
||||
|
||||

|
||||
@@ -63,9 +81,9 @@ When you connect a service from a board, Homarr keeps you in the same setup flow
|
||||
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
|
||||
widget returns to its settings with the new integration already selected.
|
||||
|
||||
Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a
|
||||
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful
|
||||
changes apply to every app and widget that uses the integration.
|
||||
Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration**
|
||||
tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page.
|
||||
Successful changes apply to every app and widget that uses the integration.
|
||||
|
||||
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
|
||||
create one.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -163,19 +163,77 @@ Members can be removed from the group using the remove button at the end:
|
||||
#### Permissions tab
|
||||
|
||||
In this tab, you manage what permissions the members of this group will have.
|
||||
Currently, the following permissions are available:
|
||||
Permissions are shown as a matrix with one row per category (boards, apps, integrations, and so on). Each row has a
|
||||
segmented control that lets you pick a single **access level**, plus a checkbox for each **capability** the category
|
||||
has.
|
||||
|
||||
- Boards
|
||||
- **Create boards**: Allow members to create boards
|
||||
- **View all boards**: Allow members to view all boards
|
||||
- **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone)
|
||||
- **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone)
|
||||
- Integrations
|
||||
- **Create integrations**: Allow members to create integrations
|
||||
- **Use all integrations**: Allows members to add any integrations to their boards
|
||||
- **Interact with any integration**: Allow members to interact with any integration
|
||||
- **Full integration access**: Allow members to manage, use and interact with any integration
|
||||
- **Administrator**: Members with this permission have full access to all features and settings
|
||||
Levels in a category are cumulative: selecting a level grants that level and every level below it in the same
|
||||
category. Choosing **No access** grants no access level for that category.
|
||||
|
||||
##### Capabilities are separate from the access level
|
||||
|
||||
A capability is something you can grant on its own, independently of how much of everyone else's data the members
|
||||
can see. Creating is the usual example: you can let members create their own boards without letting them view or
|
||||
modify anyone else's boards. Apps additionally have **use all apps**, which lets members put any app on a board
|
||||
without being able to edit apps.
|
||||
|
||||
Some access levels already include a capability through permission inheritance (marked in the table below); the
|
||||
checkbox is then ticked, disabled, and explains which level includes it.
|
||||
|
||||
##### Administration
|
||||
|
||||
Selecting the **Administrator** level grants full access to everything. While it is selected, every other category
|
||||
is disabled and explains that administrators already have that access. Setting the administrator row back to
|
||||
**No access** re-enables the other categories with the selections they had before.
|
||||
|
||||
##### Access levels
|
||||
|
||||
The access levels below are cumulative, in ascending order, with the category's capabilities listed separately:
|
||||
|
||||
| Category | Access levels (ascending) | Capabilities | Already included at |
|
||||
| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------- |
|
||||
| Boards | view all boards → modify all boards → full board access | create boards | full board access |
|
||||
| Apps | modify all apps → full app access | use all apps, create apps | create apps: modify all apps and full app access; use all apps: full app access |
|
||||
| Integrations | use all integrations → interact with any integration → full integration access | create integrations | full integration access |
|
||||
| Search engines | modify all search engines → full search engine access | create search engines | modify all search engines, full search engine access |
|
||||
| Medias | view all medias → full media access | upload medias | full media access |
|
||||
| Other | view logs | — | — |
|
||||
| Administrator | administrator | — | — |
|
||||
|
||||
Apps are the one category where "use" is not a level. Being able to modify every app does **not** imply being able
|
||||
to put any app on a board — only full app access grants both — so the two are kept independent rather than stacked,
|
||||
and picking **Modify** never silently grants use of every app.
|
||||
|
||||
The levels grant the following:
|
||||
|
||||
- **Boards**: view all boards; modify all boards (not access control and danger zone); full board access (view, modify and delete every board, including access control and the danger zone).
|
||||
- **Apps**: modify all apps; full app access (manage, use and delete any app).
|
||||
- **Integrations**: use all integrations; interact with any integration; full integration access (manage, use and interact with any integration).
|
||||
- **Search engines**: modify all search engines; full search engine access (manage and delete any search engine).
|
||||
- **Medias**: view all medias; full media access (manage and delete any media).
|
||||
- **Other**: view logs.
|
||||
|
||||
##### Presets
|
||||
|
||||
Above the rows you can apply a preset to fill in a common configuration in one click:
|
||||
|
||||
- **Viewer**: read-only - view all boards and medias, use any app on a board, and use existing integrations.
|
||||
- **Editor**: day-to-day management - modify all boards and apps, create boards and integrations, interact with any integration, modify search engines, and upload medias, without deleting resources or managing access.
|
||||
- **Admin**: grants the administrator permission (full access to everything).
|
||||
|
||||
When the current selection does not match any preset, a **Custom** badge is shown.
|
||||
|
||||
##### Also grants
|
||||
|
||||
Some levels include additional permissions automatically. When a level implies extra permissions (for example,
|
||||
modifying all apps also allows creating apps), a dimmed **Also grants: …** hint is shown below the category row,
|
||||
listing the implied permissions.
|
||||
|
||||
##### Effective permissions
|
||||
|
||||
Below the rows, the **Effective permissions** panel can be expanded to review everything members of this group will
|
||||
be able to do - the full, de-duplicated set of permissions, including everything granted indirectly through the
|
||||
levels - grouped by category, with a count for each.
|
||||
|
||||
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
|
||||
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||
|
||||
The media server widget shows active sessions across your media servers.
|
||||
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
|
||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
|
||||
|
||||
/**
|
||||
* Session-aware wrappers around the shared management access rules in `@homarr/definitions`, so the
|
||||
* navigation and the pages resolve access exactly the same way the API does.
|
||||
*/
|
||||
|
||||
export const getAppsSectionAccess = (session: Session | null) =>
|
||||
getAppManagementAccess(session?.user.permissions ?? []);
|
||||
|
||||
/**
|
||||
* Pure variant for callers that already fetched the integrations the user can see, so the list is
|
||||
* only queried once per request.
|
||||
*/
|
||||
export const getIntegrationsSectionAccess = (
|
||||
session: Session | null,
|
||||
visibleIntegrations: { permissions: { hasFullAccess: boolean } }[],
|
||||
) =>
|
||||
getIntegrationManagementAccess(
|
||||
session?.user.permissions ?? [],
|
||||
visibleIntegrations.some((integration) => integration.permissions.hasFullAccess),
|
||||
);
|
||||
|
||||
export const getIntegrationsSectionAccessAsync = async (session: Session | null) => {
|
||||
const permissions = session?.user.permissions ?? [];
|
||||
|
||||
// Only pay for the lookup when the answer is not already known from the global permissions.
|
||||
const needsDelegatedLookup =
|
||||
session !== null && !permissions.includes("integration-full-all") && !permissions.includes("integration-create");
|
||||
|
||||
return getIntegrationsSectionAccess(session, needsDelegatedLookup ? await api.integration.all() : []);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -16,6 +16,7 @@ import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getAppsSectionAccess } from "../_access";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
@@ -41,16 +42,27 @@ interface AppsPageProps {
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/auth/login");
|
||||
const { canManageAll, canCreate, canAccess } = getAppsSectionAccess(session);
|
||||
if (!canAccess) notFound();
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||
// Without app-modify-all the user may add apps but not browse the ones they cannot manage,
|
||||
// so the list stays empty instead of leaking every app name and URL.
|
||||
const { items: apps, totalCount } = canManageAll
|
||||
? await api.app.getPaginated(searchParams)
|
||||
: { items: [], totalCount: 0 };
|
||||
const t = await getScopedI18n("app");
|
||||
const canCreate = session.user.permissions.includes("app-create");
|
||||
const canModify = session.user.permissions.includes("app-modify-all");
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
const hasSearch = Boolean(searchParams.search?.trim());
|
||||
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
|
||||
|
||||
const emptyState = hasSearch ? (
|
||||
const emptyState = !canManageAll ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.createOnlyTitle")}
|
||||
description={t("page.list.noResults.createOnlyDescription")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new" }}
|
||||
/>
|
||||
) : hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
@@ -82,12 +94,14 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
) : undefined
|
||||
}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
canManageAll ? (
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
@@ -100,7 +114,6 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
<AppItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
canModify={canModify}
|
||||
canDelete={canDelete}
|
||||
editLabel={t("page.list.action.edit", { name: app.name })}
|
||||
/>
|
||||
@@ -113,12 +126,11 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
|
||||
interface AppItemProps {
|
||||
app: RouterOutputs["app"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
editLabel: string;
|
||||
}
|
||||
|
||||
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
|
||||
const descriptionLines = app.description?.split("\n");
|
||||
const safeHref = getSafeAppHref(app.href);
|
||||
|
||||
@@ -157,23 +169,19 @@ const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canModify || canDelete ? (
|
||||
<ActionIconGroup>
|
||||
{canModify && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
+159
-123
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useImperativeHandle, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
||||
@@ -27,8 +27,14 @@ import type { AnyMappedTestConnectionError } from "../../_components/test-connec
|
||||
|
||||
interface EditIntegrationFormProps {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
embedded?: boolean;
|
||||
hideButtons?: boolean;
|
||||
onSuccess?: () => void;
|
||||
formRef?: React.Ref<EditIntegrationFormHandle>;
|
||||
}
|
||||
|
||||
export interface EditIntegrationFormHandle {
|
||||
submit: () => Promise<boolean>;
|
||||
isDirty: () => boolean;
|
||||
}
|
||||
|
||||
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
@@ -44,7 +50,12 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
}),
|
||||
);
|
||||
|
||||
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
|
||||
export const EditIntegrationForm = ({
|
||||
integration,
|
||||
hideButtons = false,
|
||||
onSuccess,
|
||||
formRef,
|
||||
}: EditIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
||||
@@ -82,147 +93,172 @@ export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }
|
||||
|
||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||
|
||||
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
|
||||
setError(null);
|
||||
let url: string;
|
||||
try {
|
||||
url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
const handleSubmitAsync = useCallback(
|
||||
async ({ app, ...values }: FormType) => {
|
||||
setError(null);
|
||||
let url: string;
|
||||
try {
|
||||
url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await mutateAsync({
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
appId: app?.id ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await mutateAsync({
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
appId: app?.id ?? null,
|
||||
});
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
requestAnimationFrame(() => errorRef.current?.focus());
|
||||
return false;
|
||||
}
|
||||
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
requestAnimationFrame(() => errorRef.current?.focus());
|
||||
return;
|
||||
}
|
||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
||||
onSuccess?.();
|
||||
if (!hideButtons) {
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget],
|
||||
);
|
||||
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
||||
onSuccess?.();
|
||||
if (!embedded) {
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
}
|
||||
};
|
||||
useImperativeHandle(
|
||||
formRef,
|
||||
() => ({
|
||||
submit: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
form.onSubmit(
|
||||
async (values) => {
|
||||
try {
|
||||
resolve(await handleSubmitAsync(values));
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
() => resolve(false),
|
||||
)();
|
||||
}),
|
||||
isDirty: () => form.isDirty(),
|
||||
}),
|
||||
[form, handleSubmitAsync],
|
||||
);
|
||||
|
||||
const isInitialSecretKinds =
|
||||
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
|
||||
form.values.secrets.length === initialSecretsKinds.length;
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
const formFields = (
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
|
||||
{hasUrlSecret ? null : (
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
)}
|
||||
{hasUrlSecret ? null : (
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
)}
|
||||
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{allSecretKinds.length > 1 && (
|
||||
<SecretKindsSegmentedControl
|
||||
defaultKinds={initialSecretsKinds}
|
||||
secretKinds={allSecretKinds}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
{!isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map((secret, index) => (
|
||||
<SecretCard
|
||||
key={secret.kind}
|
||||
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
|
||||
onCancel={() =>
|
||||
new Promise((resolve) => {
|
||||
// When nothing changed, just close the secret card
|
||||
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
|
||||
return resolve(true);
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => resolve(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<IntegrationSecretInput
|
||||
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
||||
key={secret.kind}
|
||||
kind={secret.kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
</SecretCard>
|
||||
))}
|
||||
{isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map(({ kind }, index) => (
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{allSecretKinds.length > 1 && (
|
||||
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
|
||||
)}
|
||||
{!isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map((secret, index) => (
|
||||
<SecretCard
|
||||
key={secret.kind}
|
||||
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
|
||||
onCancel={() =>
|
||||
new Promise((resolve) => {
|
||||
// When nothing changed, just close the secret card
|
||||
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
|
||||
return resolve(true);
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => resolve(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<IntegrationSecretInput
|
||||
withAsterisk
|
||||
key={kind}
|
||||
kind={kind}
|
||||
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
||||
key={secret.kind}
|
||||
kind={secret.kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
))}
|
||||
{form.values.secrets.length === 0 && (
|
||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||
|
||||
{error !== null && (
|
||||
<div ref={errorRef} role="alert" tabIndex={-1}>
|
||||
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Group justify="end" align="center">
|
||||
{!embedded && (
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
</SecretCard>
|
||||
))}
|
||||
{isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map(({ kind }, index) => (
|
||||
<IntegrationSecretInput
|
||||
withAsterisk
|
||||
key={kind}
|
||||
kind={kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
))}
|
||||
{form.values.secrets.length === 0 && (
|
||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||
|
||||
{error !== null && (
|
||||
<div ref={errorRef} role="alert" tabIndex={-1}>
|
||||
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideButtons && (
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (hideButtons) {
|
||||
return formFields;
|
||||
}
|
||||
|
||||
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Stack, Text } from "@mantine/core";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getIntegrationsSectionAccess } from "../_access";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
|
||||
const searchParamsSchema = z.object({ search: z.string().optional() });
|
||||
@@ -29,11 +30,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const integrations = await api.integration.all();
|
||||
const {
|
||||
canManageAll: hasGlobalFullAccess,
|
||||
canCreate,
|
||||
canAccess,
|
||||
} = getIntegrationsSectionAccess(session, integrations);
|
||||
if (!canAccess) notFound();
|
||||
|
||||
const t = await getScopedI18n("integration");
|
||||
const canCreate = session.user.permissions.includes("integration-create");
|
||||
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
|
||||
// Without integration-full-all only the integrations that were explicitly delegated to the user
|
||||
// are manageable, so the list never shows an integration they cannot open.
|
||||
const manageableIntegrations = integrations.filter(
|
||||
(integration) => hasGlobalFullAccess || integration.permissions.hasFullAccess,
|
||||
);
|
||||
|
||||
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
|
||||
const filteredIntegrations = integrations
|
||||
const filteredIntegrations = manageableIntegrations
|
||||
.filter((integration) => {
|
||||
if (!query) return true;
|
||||
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
|
||||
@@ -53,7 +65,14 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={filteredIntegrations.length}
|
||||
emptyState={
|
||||
hasSearch ? (
|
||||
manageableIntegrations.length === 0 && !hasGlobalFullAccess ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.createOnlyTitle")}
|
||||
description={t("page.list.noResults.createOnlyDescription")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/integrations/new" }}
|
||||
/>
|
||||
) : hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
@@ -93,24 +112,33 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{filteredIntegrations.map((integration) => (
|
||||
<IntegrationItem
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
|
||||
/>
|
||||
<IntegrationItem key={integration.id} integration={integration} />
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
);
|
||||
|
||||
return filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||
const pageContent =
|
||||
filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||
|
||||
if (hasGlobalFullAccess) {
|
||||
return pageContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("page.list.scopedNote")}
|
||||
</Text>
|
||||
{pageContent}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntegrationItemProps {
|
||||
integration: RouterOutputs["integration"]["all"][number];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => {
|
||||
const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
const kindName = getIntegrationName(integration.kind);
|
||||
const safeUrl = getSafeApplicationUrl(integration.url);
|
||||
@@ -143,21 +171,19 @@ const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps)
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canManage ? (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} />
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} />
|
||||
</ActionIconGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
|
||||
import { env as nextEnv } from "~/env";
|
||||
import { getAppsSectionAccess, getIntegrationsSectionAccessAsync } from "./_access";
|
||||
|
||||
const logger = createLogger({ module: "manageLayout" });
|
||||
|
||||
@@ -65,6 +66,8 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
const appsAccess = getAppsSectionAccess(session);
|
||||
const integrationsAccess = await getIntegrationsSectionAccessAsync(session);
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
@@ -82,7 +85,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
hidden: !appsAccess.canAccess,
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -92,7 +95,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
hidden: !integrationsAccess.canAccess,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { Anchor, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getScopedI18n("management.notFound");
|
||||
@@ -9,6 +10,9 @@ export default async function NotFound() {
|
||||
<Stack align="center">
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<Text>{t("text")}</Text>
|
||||
<Anchor component={Link} href="/manage">
|
||||
{t("backToHome")}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,12 @@ export default async function ManagementPage() {
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{statistics.map((statistic) => (
|
||||
<Card component={Link} href={statistic.path} key={statistic.path}>
|
||||
<Card
|
||||
component={Link}
|
||||
href={statistic.path}
|
||||
key={statistic.path}
|
||||
aria-label={`${statistic.count} ${t(`statistic.${statistic.titleKey}`)}`}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="xl" fw={700}>
|
||||
@@ -42,7 +47,7 @@ export default async function ManagementPage() {
|
||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
+357
-25
@@ -1,17 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core";
|
||||
import { useCallback, useId } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Transition,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconShieldExclamation } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { groupPermissionKeys } from "@homarr/definitions";
|
||||
import { objectEntries, objectKeys } from "@homarr/common";
|
||||
import type { GroupPermissionKey, PermissionMatrixCategory, PermissionMatrixState } from "@homarr/definitions";
|
||||
import {
|
||||
getPermissionsWithChildren,
|
||||
groupPermissionKeys,
|
||||
isCapabilityImpliedByLevel,
|
||||
matrixStateToPermissions,
|
||||
permissionMatrix,
|
||||
permissionsToMatrixState,
|
||||
} from "@homarr/definitions";
|
||||
import { createFormContext } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export type PermissionLabels = Partial<Record<GroupPermissionKey, string>>;
|
||||
|
||||
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
|
||||
|
||||
interface PermissionFormProps {
|
||||
@@ -19,27 +45,17 @@ interface PermissionFormProps {
|
||||
}
|
||||
|
||||
export const PermissionForm = ({ children, initialPermissions }: PropsWithChildren<PermissionFormProps>) => {
|
||||
// Mantine compares each field against the values snapshot, so toggling a control back to its
|
||||
// saved value clears the dirty state on its own - no manual bookkeeping needed here.
|
||||
const form = useForm({
|
||||
initialValues: groupPermissionKeys.reduce((acc, key) => {
|
||||
acc[key] = initialPermissions.includes(key);
|
||||
return acc;
|
||||
}, {} as FormType),
|
||||
onValuesChange(values) {
|
||||
const currentKeys = objectEntries(values)
|
||||
.filter(([_key, value]) => Boolean(value))
|
||||
.map(([key]) => key);
|
||||
|
||||
if (
|
||||
currentKeys.every((key) => initialPermissions.includes(key)) &&
|
||||
initialPermissions.every((key) => currentKeys.includes(key))
|
||||
) {
|
||||
form.resetDirty(); // Reset dirty state if all keys are the same as initial
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form>
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<FormProvider form={form}>{children}</FormProvider>
|
||||
</form>
|
||||
);
|
||||
@@ -47,15 +63,329 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
|
||||
|
||||
type FormType = Record<GroupPermissionKey, boolean>;
|
||||
|
||||
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
|
||||
const form = useFormContext();
|
||||
type MatrixForm = ReturnType<typeof useFormContext>;
|
||||
|
||||
const props = form.getInputProps(name, {
|
||||
withError: false,
|
||||
type: "checkbox",
|
||||
const emptyMatrixState = (): PermissionMatrixState =>
|
||||
objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc[category] = { level: 0, capabilities: [] };
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
|
||||
const matrixStateOf = (values: FormType) => permissionsToMatrixState(groupPermissionKeys.filter((key) => values[key]));
|
||||
|
||||
export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number =>
|
||||
matrixStateOf(values)[category].level;
|
||||
|
||||
export const hasMatrixCapability = (
|
||||
values: FormType,
|
||||
category: PermissionMatrixCategory,
|
||||
capability: GroupPermissionKey,
|
||||
): boolean => matrixStateOf(values)[category].capabilities.includes(capability);
|
||||
|
||||
export const applyMatrixLevel = (form: MatrixForm, category: PermissionMatrixCategory, level: number) => {
|
||||
const targetKeys = new Set(
|
||||
matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level, capabilities: [] } }),
|
||||
);
|
||||
permissionMatrix[category].levels.forEach((key) => {
|
||||
form.setFieldValue(key, targetKeys.has(key));
|
||||
});
|
||||
// Capability keys are deliberately left untouched: a level that implies one already covers it,
|
||||
// and an explicitly granted capability must survive lowering the level again.
|
||||
};
|
||||
|
||||
return <Switch {...props} />;
|
||||
export const applyMatrixCapability = (form: MatrixForm, capability: GroupPermissionKey, granted: boolean) => {
|
||||
form.setFieldValue(capability, granted);
|
||||
};
|
||||
|
||||
const presets = {
|
||||
viewer: {
|
||||
board: { level: 1, capabilities: [] },
|
||||
app: { level: 0, capabilities: ["app-use-all"] },
|
||||
integration: { level: 1, capabilities: [] },
|
||||
media: { level: 1, capabilities: [] },
|
||||
},
|
||||
editor: {
|
||||
board: { level: 2, capabilities: ["board-create"] },
|
||||
app: { level: 1, capabilities: ["app-use-all"] },
|
||||
integration: { level: 2, capabilities: ["integration-create"] },
|
||||
"search-engine": { level: 1, capabilities: [] },
|
||||
media: { level: 1, capabilities: ["media-upload"] },
|
||||
},
|
||||
admin: { admin: { level: 1, capabilities: [] } },
|
||||
} satisfies Record<string, Partial<PermissionMatrixState>>;
|
||||
|
||||
export type PermissionPreset = keyof typeof presets;
|
||||
|
||||
const presetToFormState = (preset: PermissionPreset): FormType => {
|
||||
const presetState: Partial<PermissionMatrixState> = presets[preset];
|
||||
const state = objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc[category] = presetState[category] ?? { level: 0, capabilities: [] };
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
const enabledKeys = new Set(matrixStateToPermissions(state));
|
||||
|
||||
return groupPermissionKeys.reduce((acc, key) => {
|
||||
acc[key] = enabledKeys.has(key);
|
||||
return acc;
|
||||
}, {} as FormType);
|
||||
};
|
||||
|
||||
export const applyPreset = (form: MatrixForm, preset: PermissionPreset) => {
|
||||
form.setValues(presetToFormState(preset));
|
||||
};
|
||||
|
||||
export const getActivePreset = (values: FormType): PermissionPreset | null => {
|
||||
return (
|
||||
objectKeys(presets).find((preset) => {
|
||||
const presetValues = presetToFormState(preset);
|
||||
return groupPermissionKeys.every((key) => presetValues[key] === values[key]);
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Short, scannable label for a level. Categories with a single level use their permission label
|
||||
* instead, so the control reads "No access | View logs" rather than "No access | Enabled".
|
||||
*/
|
||||
const levelLabelKey = (key: GroupPermissionKey) => {
|
||||
if (key.endsWith("-view-all")) return "view" as const;
|
||||
if (key.endsWith("-use-all")) return "use" as const;
|
||||
if (key.endsWith("-interact-all")) return "interact" as const;
|
||||
if (key.endsWith("-modify-all")) return "modify" as const;
|
||||
if (key.endsWith("-full-all")) return "full" as const;
|
||||
return null;
|
||||
};
|
||||
|
||||
interface MatrixImpliedHintProps {
|
||||
category: PermissionMatrixCategory;
|
||||
permissionLabels: PermissionLabels;
|
||||
}
|
||||
|
||||
export const MatrixImpliedHint = ({ category, permissionLabels }: MatrixImpliedHintProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
|
||||
const level = getMatrixLevel(form.getValues(), category);
|
||||
if (level === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkedKeys: GroupPermissionKey[] = permissionMatrix[category].levels.slice(0, level);
|
||||
const implied = getPermissionsWithChildren(checkedKeys).filter((key) => !checkedKeys.includes(key));
|
||||
if (implied.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels = implied.map((key) => permissionLabels[key] ?? key);
|
||||
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.impliedHint", { permissions: labels.join(", ") })}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface MatrixRowProps {
|
||||
category: PermissionMatrixCategory;
|
||||
permissionLabels: PermissionLabels;
|
||||
permissionDescriptions: PermissionLabels;
|
||||
}
|
||||
|
||||
export const MatrixRow = ({ category, permissionLabels, permissionDescriptions }: MatrixRowProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
|
||||
const values = form.getValues();
|
||||
const level = getMatrixLevel(values, category);
|
||||
const isDanger = category === "admin";
|
||||
const disabledByAdmin = !isDanger && getMatrixLevel(values, "admin") > 0;
|
||||
const { levels, capabilities } = permissionMatrix[category];
|
||||
const hasSingleLevel = levels.length === 1;
|
||||
|
||||
const data = [
|
||||
{ value: "0", label: tPermissions("matrix.none") },
|
||||
...levels.map((key, index) => {
|
||||
const shortKey = hasSingleLevel ? null : levelLabelKey(key);
|
||||
return {
|
||||
value: String(index + 1),
|
||||
label: shortKey === null ? (permissionLabels[key] ?? key) : tPermissions(`matrix.level.${shortKey}`),
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const capabilityStates = capabilities.map((capability) => ({
|
||||
capability,
|
||||
checked: hasMatrixCapability(values, category, capability),
|
||||
implied: isCapabilityImpliedByLevel(category, capability, level),
|
||||
}));
|
||||
|
||||
const selectedKey = level === 0 ? undefined : levels[level - 1];
|
||||
// With no access level but a capability granted, describing the row as "no access" would
|
||||
// contradict the checkbox next to it, so the granted capability describes the row instead.
|
||||
const describedKey = selectedKey ?? capabilityStates.find(({ checked }) => checked)?.capability;
|
||||
const description =
|
||||
describedKey === undefined ? tPermissions("matrix.noneDescription") : (permissionDescriptions[describedKey] ?? "");
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" wrap="wrap" align="center" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isDanger && <IconShieldExclamation size={18} color="var(--mantine-color-red-8)" />}
|
||||
<Text fw={500} c={isDanger ? "red.8" : undefined}>
|
||||
{tPermissions(`${category}.title`)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="md" wrap="wrap" align="center">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
data={data}
|
||||
value={String(level)}
|
||||
disabled={disabledByAdmin}
|
||||
aria-label={tPermissions(`${category}.title`)}
|
||||
onChange={(value) => applyMatrixLevel(form, category, Number(value))}
|
||||
/>
|
||||
{/* Fixed-width column so every level control lines up, whatever capabilities a row has. */}
|
||||
<Box w={{ base: "auto", sm: 230 }}>
|
||||
<Stack gap={4}>
|
||||
{capabilityStates.map(({ capability, checked, implied }) => (
|
||||
<Tooltip
|
||||
key={capability}
|
||||
label={tPermissions("matrix.capabilityIncluded", { level: data[level]?.label ?? "" })}
|
||||
disabled={!implied}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
label={permissionLabels[capability] ?? capability}
|
||||
checked={checked}
|
||||
disabled={disabledByAdmin || implied}
|
||||
onChange={(event) => applyMatrixCapability(form, capability, event.currentTarget.checked)}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
{disabledByAdmin && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.grantedViaAdmin")}
|
||||
</Text>
|
||||
)}
|
||||
<MatrixImpliedHint category={category} permissionLabels={permissionLabels} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface PermissionMatrixProps {
|
||||
permissionLabels: PermissionLabels;
|
||||
permissionDescriptions: PermissionLabels;
|
||||
}
|
||||
|
||||
export const PermissionMatrix = ({ permissionLabels, permissionDescriptions }: PermissionMatrixProps) => (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="md">
|
||||
{objectKeys(permissionMatrix).map((category, index) => (
|
||||
<Stack key={category} gap="md">
|
||||
{index > 0 && <Divider />}
|
||||
<MatrixRow
|
||||
category={category}
|
||||
permissionLabels={permissionLabels}
|
||||
permissionDescriptions={permissionDescriptions}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export const PresetButtons = () => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
const activePreset = getActivePreset(form.getValues());
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text size="sm" fw={500}>
|
||||
{tPermissions("matrix.preset.label")}
|
||||
</Text>
|
||||
{(["viewer", "editor", "admin"] as const).map((preset) => (
|
||||
<Tooltip key={preset} label={tPermissions(`matrix.preset.${preset}Description`)}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={activePreset === preset ? "filled" : "default"}
|
||||
onClick={() => applyPreset(form, preset)}
|
||||
>
|
||||
{tPermissions(`matrix.preset.${preset}`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
{activePreset === null && <Badge variant="light">{tPermissions("matrix.preset.custom")}</Badge>}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface EffectivePermissionPreviewProps {
|
||||
permissionLabels: PermissionLabels;
|
||||
}
|
||||
|
||||
export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermissionPreviewProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
const panelId = useId();
|
||||
|
||||
const values = form.getValues();
|
||||
const checkedKeys = groupPermissionKeys.filter((key) => values[key]);
|
||||
const effective = getPermissionsWithChildren(checkedKeys);
|
||||
|
||||
const grouped = objectKeys(permissionMatrix)
|
||||
.map((category) => ({
|
||||
category,
|
||||
keys: effective.filter((key) => key === category || key.startsWith(`${category}-`)),
|
||||
}))
|
||||
.filter(({ keys }) => keys.length > 0);
|
||||
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<UnstyledButton onClick={toggle} w="100%" aria-expanded={opened} aria-controls={panelId}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{tPermissions("matrix.effective.title")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.effective.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light">{effective.length}</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse id={panelId} expanded={opened}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{grouped.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{tPermissions("matrix.effective.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
grouped.map(({ category, keys }) => (
|
||||
<Group key={category} gap="xs" wrap="wrap" align="flex-start">
|
||||
<Text size="sm" fw={500}>
|
||||
{tPermissions(`${category}.title`)}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light">
|
||||
{keys.length}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{keys.map((key) => permissionLabels[key] ?? key).join(", ")}
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface SaveAffixProps {
|
||||
@@ -80,8 +410,10 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Set new initial values for discard and reset dirty state
|
||||
// setInitialValues moves the snapshot so Discard returns here, but only resetDirty clears
|
||||
// the dirty flags that keep the unsaved-changes bar on screen.
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty(values);
|
||||
showSuccessNotification({
|
||||
title: tNotification("success.title"),
|
||||
message: tNotification("success.message"),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -9,7 +8,14 @@ import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { groupPermissions } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
|
||||
import type { PermissionLabels } from "./_group-permission-form";
|
||||
import {
|
||||
EffectivePermissionPreview,
|
||||
PermissionForm,
|
||||
PermissionMatrix,
|
||||
PresetButtons,
|
||||
SaveAffix,
|
||||
} from "./_group-permission-form";
|
||||
|
||||
interface GroupPermissionsPageProps {
|
||||
params: Promise<{
|
||||
@@ -26,27 +32,21 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
}
|
||||
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const tPermissions = await getScopedI18n("group.permission");
|
||||
const t = await getI18n();
|
||||
|
||||
const { permissionLabels, permissionDescriptions } = await buildPermissionTextsAsync();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("management.page.group.setting.permissions.title")}</Title>
|
||||
|
||||
<PermissionForm initialPermissions={group.permissions}>
|
||||
<Stack pos="relative">
|
||||
{objectKeys(groupPermissions).map((group) => {
|
||||
const isDanger = group === "admin";
|
||||
<PresetButtons />
|
||||
|
||||
return (
|
||||
<Stack key={group} gap="sm">
|
||||
<Title order={2} c={isDanger ? "red.8" : undefined}>
|
||||
{tPermissions(`${group}.title`)}
|
||||
</Title>
|
||||
<PermissionCard isDanger={isDanger} group={group} />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
<PermissionMatrix permissionLabels={permissionLabels} permissionDescriptions={permissionDescriptions} />
|
||||
|
||||
<EffectivePermissionPreview permissionLabels={permissionLabels} />
|
||||
|
||||
<SaveAffix groupId={group.id} />
|
||||
</Stack>
|
||||
@@ -55,66 +55,21 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
);
|
||||
}
|
||||
|
||||
interface PermissionCardProps {
|
||||
group: keyof typeof groupPermissions;
|
||||
isDanger: boolean;
|
||||
}
|
||||
const buildPermissionTextsAsync = async () => {
|
||||
const permissionLabels: PermissionLabels = {};
|
||||
const permissionDescriptions: PermissionLabels = {};
|
||||
|
||||
const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
|
||||
const t = await getScopedI18n(`group.permission.${group}.item`);
|
||||
const item = groupPermissions[group];
|
||||
const permissions = typeof item !== "boolean" ? item : ([group] as "admin"[]);
|
||||
for (const category of objectKeys(groupPermissions)) {
|
||||
const tItem = await getScopedI18n(`group.permission.${category}.item`);
|
||||
const item = groupPermissions[category];
|
||||
const suffixes = typeof item !== "boolean" ? item : (["admin"] as const);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{permissions.map((permission, index) => (
|
||||
<React.Fragment key={permission}>
|
||||
<PermissionRow
|
||||
name={createGroupPermissionKey(group, permission)}
|
||||
label={t(`${permission}.label`)}
|
||||
description={t(`${permission}.description`)}
|
||||
/>
|
||||
|
||||
{index < permissions.length - 1 && (
|
||||
<CardSection>
|
||||
<Divider />
|
||||
</CardSection>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const createGroupPermissionKey = (group: keyof typeof groupPermissions, permission: string): GroupPermissionKey => {
|
||||
if (typeof groupPermissions[group] === "boolean") {
|
||||
return group as GroupPermissionKey;
|
||||
suffixes.forEach((suffix) => {
|
||||
const key = (typeof item !== "boolean" ? `${category}-${suffix}` : "admin") as GroupPermissionKey;
|
||||
permissionLabels[key] = tItem(`${suffix}.label`);
|
||||
permissionDescriptions[key] = tItem(`${suffix}.description`);
|
||||
});
|
||||
}
|
||||
|
||||
return `${group}-${permission}` as GroupPermissionKey;
|
||||
};
|
||||
|
||||
interface PermissionRowProps {
|
||||
name: GroupPermissionKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
|
||||
return (
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{label}</Text>
|
||||
<Text c="gray.5">{description}</Text>
|
||||
</Stack>
|
||||
<PermissionSwitch name={name} />
|
||||
</Group>
|
||||
);
|
||||
return { permissionLabels, permissionDescriptions };
|
||||
};
|
||||
@@ -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,18 +1,21 @@
|
||||
"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 { useModalAction } 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 type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal";
|
||||
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal";
|
||||
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";
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
||||
|
||||
@@ -29,42 +32,38 @@ export const loadWidgetEditModal = () => {
|
||||
|
||||
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
||||
|
||||
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
|
||||
let integrationEditFormPromise: Promise<typeof IntegrationEditFormModule> | undefined;
|
||||
|
||||
const loadIntegrationEditModal = () => {
|
||||
if (integrationEditModalPromise) return integrationEditModalPromise;
|
||||
const loadIntegrationEditForm = () => {
|
||||
if (integrationEditFormPromise) return integrationEditFormPromise;
|
||||
|
||||
const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
|
||||
integrationEditModalPromise = currentPromise;
|
||||
const currentPromise = import("~/components/integration/embedded-integration-edit-form");
|
||||
integrationEditFormPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
|
||||
if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
const LazyIntegrationEditModalContent = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: EmbeddedIntegrationEditModalProps;
|
||||
}) => {
|
||||
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
|
||||
const Component = EmbeddedIntegrationEditModal.component;
|
||||
return <Component {...props} />;
|
||||
const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => {
|
||||
const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm());
|
||||
return <EmbeddedIntegrationEditForm {...props} />;
|
||||
};
|
||||
|
||||
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
integrationEditModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
integrationEditFormPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
|
||||
const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
|
||||
<ErrorBoundary FallbackComponent={IntegrationEditErrorFallback}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
@@ -72,15 +71,10 @@ const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyIntegrationEditModalContent {...props} />
|
||||
<LazyIntegrationEditFormContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)).withOptions({
|
||||
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
);
|
||||
|
||||
interface LazyWidgetEditModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
@@ -89,18 +83,46 @@ interface LazyWidgetEditModalContentProps {
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const Component = WidgetEditModal.component;
|
||||
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
|
||||
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,
|
||||
onEditIntegration: (integrationId) =>
|
||||
openIntegrationEditModal({
|
||||
integrationId,
|
||||
onSuccess: props.innerProps.onIntegrationSaved,
|
||||
}),
|
||||
integrationData: combinedIntegrationData,
|
||||
integrationEditForm: LazyIntegrationEditForm,
|
||||
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -136,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,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Group, Stack, Text, TextInput, 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 { filterAndRankUniversalCreateActions, 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 [query, setQuery] = useState("");
|
||||
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 visibleActions = filterAndRankUniversalCreateActions(resolvedActions, query);
|
||||
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
data-autofocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.label")}
|
||||
/>
|
||||
|
||||
{visibleActions.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
{t("search.noResults")}
|
||||
</Text>
|
||||
) : (
|
||||
groups.map((group) => {
|
||||
const groupActions = visibleActions.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;
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
import type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
|
||||
|
||||
import type { EditIntegrationFormHandle } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
|
||||
export type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
|
||||
|
||||
export const EmbeddedIntegrationEditForm = ({
|
||||
integrationId,
|
||||
handleRef,
|
||||
onSuccess,
|
||||
}: EmbeddedIntegrationEditFormProps) => {
|
||||
const t = useI18n();
|
||||
const integrationFormRef = useRef<EditIntegrationFormHandle>(null);
|
||||
const {
|
||||
data: integration,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = clientApi.integration.byId.useQuery({
|
||||
id: integrationId,
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
handleRef,
|
||||
() => ({
|
||||
submitIfDirty: async () => {
|
||||
if (!integrationFormRef.current?.isDirty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return integrationFormRef.current.submit();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
|
||||
<Skeleton height={36} width="60%" />
|
||||
<Skeleton height={60} />
|
||||
<Skeleton height={120} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !integration) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{getIntegrationName(integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t("item.edit.integration.propagationNotice")}
|
||||
</Alert>
|
||||
<EditIntegrationForm integration={integration} hideButtons formRef={integrationFormRef} onSuccess={onSuccess} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
|
||||
export interface EmbeddedIntegrationEditModalProps {
|
||||
integrationId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
data: integration,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = clientApi.integration.byId.useQuery({
|
||||
id: innerProps.integrationId,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
|
||||
<Skeleton height={36} width="60%" />
|
||||
<Skeleton height={60} />
|
||||
<Skeleton height={120} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !integration) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{getIntegrationName(integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t("item.edit.integration.propagationNotice")}
|
||||
</Alert>
|
||||
<EditIntegrationForm
|
||||
integration={integration}
|
||||
embedded
|
||||
onSuccess={() => {
|
||||
innerProps.onSuccess?.();
|
||||
actions.closeModal();
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -18,7 +18,8 @@ import { AppAccessControl } from "./app/app-access-control";
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
@@ -50,7 +51,8 @@ export const appRouter = createTRPCRouter({
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
all: protectedProcedure
|
||||
all: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.input(z.void())
|
||||
.output(z.array(selectAppSchema))
|
||||
.meta({
|
||||
@@ -88,6 +90,16 @@ export const appRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.query(({ ctx, input }) => {
|
||||
if (
|
||||
!ctx.session.user.permissions.includes("app-modify-all") &&
|
||||
!ctx.session.user.permissions.includes("board-modify-all")
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Permission denied",
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.db.query.apps.findMany({
|
||||
where: like(apps.name, `%${input.query}%`),
|
||||
orderBy: asc(apps.name),
|
||||
|
||||
@@ -302,16 +302,43 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
|
||||
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
|
||||
// Stored exactly as selected - implied permissions are resolved at read time through
|
||||
// getPermissionsWithChildren, so nothing is silently escalated here.
|
||||
const permissions = [...new Set(input.permissions)];
|
||||
|
||||
if (input.permissions.length > 0) {
|
||||
await ctx.db.insert(groupPermissions).values(
|
||||
input.permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
);
|
||||
}
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.delete(schema.groupPermissions).where(eq(schema.groupPermissions.groupId, input.groupId));
|
||||
if (permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
await transaction.insert(schema.groupPermissions).values(
|
||||
permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId)).run();
|
||||
if (permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
transaction
|
||||
.insert(groupPermissions)
|
||||
.values(
|
||||
permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
)
|
||||
.run();
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
transferOwnership: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { db, eq, inArray, or } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
@@ -6,16 +8,46 @@ import {
|
||||
boardUserPermissions,
|
||||
groupMembers,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrationUserPermissions,
|
||||
integrations,
|
||||
invites,
|
||||
medias,
|
||||
searchEngines,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId),
|
||||
});
|
||||
|
||||
const accessibleIntegrations = await db.query.integrations.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, userId),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return accessibleIntegrations
|
||||
.filter((integration) => constructIntegrationPermissions(integration, null).hasFullAccess)
|
||||
.map((integration) => integration.id);
|
||||
};
|
||||
|
||||
interface HomeStatistic {
|
||||
titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
|
||||
subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
|
||||
@@ -92,20 +124,30 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("integration-create")) {
|
||||
// A card is only shown when the page behind it is reachable, and it counts only what the user is
|
||||
// allowed to see. The rules come from @homarr/definitions so this cannot drift from the pages.
|
||||
const permissions = ctx.session?.user.permissions ?? [];
|
||||
const hasGlobalFullIntegrationAccess = permissions.includes("integration-full-all");
|
||||
const fullAccessIntegrationIds =
|
||||
hasGlobalFullIntegrationAccess || !ctx.session?.user
|
||||
? []
|
||||
: await getFullAccessIntegrationIdsAsync(ctx.db, ctx.session.user.id);
|
||||
const integrationAccess = getIntegrationManagementAccess(permissions, fullAccessIntegrationIds.length > 0);
|
||||
if (integrationAccess.canAccess) {
|
||||
statistics.push({
|
||||
titleKey: "integration",
|
||||
subtitleKey: "resources",
|
||||
count: await db.$count(integrations),
|
||||
count: integrationAccess.canManageAll ? await db.$count(integrations) : fullAccessIntegrationIds.length,
|
||||
path: "/manage/integrations",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("app-create")) {
|
||||
const appAccess = getAppManagementAccess(permissions);
|
||||
if (appAccess.canAccess) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
count: await db.$count(apps),
|
||||
count: appAccess.canManageAll ? await db.$count(apps) : 0,
|
||||
path: "/manage/apps",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
integrationSecrets,
|
||||
integrationUserPermissions,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { GroupPermissionKey, IntegrationPermission, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getIntegrationKindsByCategory,
|
||||
getPermissionsWithParents,
|
||||
@@ -71,80 +72,18 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrations = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
return integrations
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
return await getAccessibleIntegrationsAsync(ctx);
|
||||
}),
|
||||
allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
where: inArray(
|
||||
return await getAccessibleIntegrationsAsync(
|
||||
ctx,
|
||||
inArray(
|
||||
integrations.kind,
|
||||
objectEntries(integrationDefs)
|
||||
.filter(([_, integration]) => [...integration.category].includes("search"))
|
||||
.map(([kind, _]) => kind),
|
||||
),
|
||||
});
|
||||
return integrationsFromDb
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
);
|
||||
}),
|
||||
allOfGivenCategory: protectedProcedure
|
||||
.input(
|
||||
@@ -153,42 +92,10 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const intergrationKinds = getIntegrationKindsByCategory(input.category);
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
where: inArray(integrations.kind, intergrationKinds),
|
||||
});
|
||||
return integrationsFromDb
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
return await getAccessibleIntegrationsAsync(
|
||||
ctx,
|
||||
inArray(integrations.kind, getIntegrationKindsByCategory(input.category)),
|
||||
);
|
||||
}),
|
||||
search: protectedProcedure
|
||||
.meta({
|
||||
@@ -205,11 +112,29 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.integrations.findMany({
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
where: like(integrations.name, `%${input.query}%`),
|
||||
orderBy: asc(integrations.name),
|
||||
limit: input.limit,
|
||||
with: getIntegrationAccessRelationsAsync(
|
||||
ctx.session.user.id,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
});
|
||||
|
||||
return integrationsFromDb
|
||||
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationA.name.localeCompare(integrationB.name) || integrationA.id.localeCompare(integrationB.id),
|
||||
)
|
||||
.slice(0, input.limit)
|
||||
.map(
|
||||
({ userPermissions: _userPermissions, groupPermissions: _groupPermissions, ...integration }) => integration,
|
||||
);
|
||||
}),
|
||||
// This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
|
||||
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
|
||||
@@ -774,6 +699,66 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const globalIntegrationPermissions = [
|
||||
"integration-use-all",
|
||||
"integration-interact-all",
|
||||
"integration-full-all",
|
||||
] satisfies GroupPermissionKey[];
|
||||
|
||||
const hasGlobalIntegrationAccess = (session: Session | null) =>
|
||||
globalIntegrationPermissions.some((permission) => session?.user.permissions.includes(permission) ?? false);
|
||||
|
||||
const hasIntegrationQueryAccess = (
|
||||
session: Session | null,
|
||||
integration: {
|
||||
userPermissions: { permission: IntegrationPermission }[];
|
||||
groupPermissions: { permission: IntegrationPermission }[];
|
||||
},
|
||||
) => hasGlobalIntegrationAccess(session) || constructIntegrationPermissions(integration, session).hasUseAccess;
|
||||
|
||||
const getIntegrationAccessRelationsAsync = (userId: string, groupIds: string[]) => ({
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, userId),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(integrationGroupPermissions.groupId, groupIds),
|
||||
},
|
||||
});
|
||||
|
||||
const getAccessibleIntegrationsAsync = async (ctx: { db: Database; session: Session }, where?: SQL) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
where,
|
||||
with: getIntegrationAccessRelationsAsync(
|
||||
ctx.session.user.id,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
});
|
||||
|
||||
return integrationsFromDb
|
||||
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind) ||
|
||||
integrationA.name.localeCompare(integrationB.name) ||
|
||||
integrationA.id.localeCompare(integrationB.id),
|
||||
);
|
||||
};
|
||||
|
||||
interface IntegrationRouterContext {
|
||||
db: Database;
|
||||
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
|
||||
|
||||
@@ -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?.();
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("all should return all apps", () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
|
||||
await db.insert(apps).values([
|
||||
@@ -74,6 +74,20 @@ describe("all should return all apps", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
// Arrange
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.all();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPaginated should return apps to authenticated users", () => {
|
||||
@@ -87,12 +101,12 @@ describe("getPaginated should return apps to authenticated users", () => {
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("should return apps without board permissions", async () => {
|
||||
test("should return apps with app-modify-all permission", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
@@ -101,6 +115,43 @@ describe("getPaginated should return apps to authenticated users", () => {
|
||||
items: [{ id: "1" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search should require app-modify-all or board-modify-all permission", () => {
|
||||
test("should throw FORBIDDEN without app-modify-all or board-modify-all permission", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await expect(caller.search({ query: "Homarr" })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
test("should return apps with board-modify-all permission", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["board-modify-all"]),
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
const result = await caller.search({ query: "Homarr" });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("byId should return an app by id", () => {
|
||||
|
||||
@@ -449,6 +449,165 @@ describe("savePermissions should save permissions for group", () => {
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
|
||||
});
|
||||
|
||||
test("with a standalone create permission it should not escalate to its parent permission", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["board-create"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["board-create"]);
|
||||
});
|
||||
|
||||
test.each([["app-create"], ["integration-create"], ["search-engine-create"], ["media-upload"]] as const)(
|
||||
"with %s alone it should persist exactly that permission",
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({ groupId, permissions: [permission] });
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission: key }) => key)).toEqual([permission]);
|
||||
},
|
||||
);
|
||||
|
||||
test("with a create permission and its parent it should persist exactly two rows", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["app-create", "app-modify-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(2);
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
|
||||
});
|
||||
|
||||
test("with duplicate permissions it should persist each permission only once", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["board-view-all", "board-view-all", "board-modify-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-modify-all", "board-view-all"]);
|
||||
});
|
||||
|
||||
test("saving the same permissions twice should be idempotent", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
|
||||
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-create", "media-upload"]);
|
||||
});
|
||||
|
||||
test("with an empty permission list it should clear all existing permissions", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values([
|
||||
{ groupId, permission: "admin" },
|
||||
{ groupId, permission: "board-full-all" },
|
||||
]);
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(0);
|
||||
});
|
||||
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
@@ -58,6 +58,171 @@ describe("all should return all integrations", () => {
|
||||
expect(result[1]!.kind).toBe("homeAssistant");
|
||||
expect(result.every(({ permissions }) => permissions.hasFullAccess && permissions.hasUseAccess)).toBe(true);
|
||||
});
|
||||
|
||||
test("with integration full access and no per-integration rows should return all integrations with full access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(2);
|
||||
for (const integration of result) {
|
||||
expect(integration.permissions.hasFullAccess).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("with integration use access should return all integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
test("without global integration access should only return integrations with use access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.permissions.hasUseAccess).toBe(true);
|
||||
});
|
||||
|
||||
test("with a per-integration interact grant should return the integration with use access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "interact",
|
||||
});
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.permissions.hasUseAccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search should return accessible integrations", () => {
|
||||
test("without global integration access should only return integrations with use access as plain rows", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.search({ query: "Home" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]).not.toHaveProperty("userPermissions");
|
||||
expect(result[0]).not.toHaveProperty("groupPermissions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user