Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f7d150d01 | ||
|
|
ef6d350b4d | ||
|
|
f67f8c1c33 | ||
|
|
d79c748c4d | ||
|
|
80b90802e5 |
No files matched your search
@@ -1,60 +0,0 @@
|
||||
---
|
||||
name: homarr-local-harness
|
||||
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
|
||||
---
|
||||
|
||||
# Homarr Local Harness
|
||||
|
||||
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
|
||||
|
||||
## Set up the harness
|
||||
|
||||
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
|
||||
|
||||
```sh
|
||||
pnpm cli harness setup feature-board
|
||||
```
|
||||
|
||||
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
|
||||
|
||||
- `DEMO_MODE=true`
|
||||
- `DEMO_READ_ONLY=false`
|
||||
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
|
||||
|
||||
Pass repeatable environment overrides when the feature needs them:
|
||||
|
||||
```sh
|
||||
pnpm cli harness setup feature-board \
|
||||
--env WORKSHOP_WEB_URL=https://example.test \
|
||||
--env FEATURE_FLAG=true
|
||||
```
|
||||
|
||||
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
|
||||
|
||||
Always use the URL printed by the command. If the output is no longer available, query it directly:
|
||||
|
||||
```sh
|
||||
pnpm cli harness port feature-board
|
||||
```
|
||||
|
||||
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
|
||||
|
||||
## Browser validation
|
||||
|
||||
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
|
||||
|
||||
```sh
|
||||
agent-browser skills get core
|
||||
```
|
||||
|
||||
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
|
||||
|
||||
## Cleanup
|
||||
|
||||
Stop the named container when finished:
|
||||
|
||||
```sh
|
||||
pnpm cli harness stop feature-board
|
||||
```
|
||||
|
||||
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Homarr Local Harness"
|
||||
short_description: "Build and browse a writable local Homarr branch harness"
|
||||
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
|
||||
@@ -374,31 +374,21 @@ jobs:
|
||||
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||
run: |
|
||||
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[@]}" \
|
||||
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
|
||||
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
||||
|
||||
- name: Verify preview manifest platforms
|
||||
if: "!env.ACT"
|
||||
run: |
|
||||
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"
|
||||
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
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
|
||||
cleanup:
|
||||
name: Clean up preview image
|
||||
|
||||
@@ -27,103 +27,37 @@ jobs:
|
||||
- run: pnpm test:workshop
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish-amd64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64
|
||||
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=workshop-linux-amd64
|
||||
cache-to: type=gha,scope=workshop-linux-amd64,mode=max
|
||||
|
||||
publish-arm64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/arm64
|
||||
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=workshop-linux-arm64
|
||||
cache-to: type=gha,scope=workshop-linux-arm64,mode=max
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: [publish-amd64, publish-arm64]
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
WORKSHOP_IMAGE: ghcr.io/homarr-labs/workshop
|
||||
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 }}
|
||||
- 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"
|
||||
- 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
|
||||
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 }}
|
||||
@@ -25,8 +25,6 @@ packages/db/migrations/*/migrate.cjs
|
||||
|
||||
# production
|
||||
build
|
||||
tools/homarr-dev/homarr
|
||||
tools/homarr-dev/homarr-dev
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -1,175 +1,43 @@
|
||||
# Developer Docker CLI
|
||||
|
||||
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
||||
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
||||
|
||||
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features.
|
||||
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
|
||||
|
||||
## Requirements
|
||||
|
||||
- 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
|
||||
## Browse development images
|
||||
|
||||
```bash
|
||||
pnpm dev:cli --
|
||||
# or with global binary:
|
||||
homarr
|
||||
```
|
||||
|
||||
The main browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` (or `1`/`2`) to toggle between the image browser and the instance dashboard.
|
||||
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
|
||||
|
||||
A locally built PR image is folded into its remote PR row only when its recorded revision matches the current GitHub head SHA (marked `◆ local`). When the matching GHCR image is also available, pressing `Enter` opens a local/remote selector with local preselected. Outdated local builds remain separate rows.
|
||||
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
|
||||
|
||||
### Multi-Source Sidebar
|
||||
|
||||
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
|
||||
## Build images
|
||||
|
||||
```bash
|
||||
homarr dash
|
||||
# or
|
||||
pnpm dev:cli -- dash
|
||||
pnpm dev:cli -- build <name>
|
||||
pnpm dev:cli -- build --pr <number> [name]
|
||||
```
|
||||
|
||||
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
|
||||
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
|
||||
|
||||
| Key | Action |
|
||||
| ------------------ | ---------------------------------------- |
|
||||
| `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
|
||||
## Rebuild images
|
||||
|
||||
```bash
|
||||
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/
|
||||
pnpm dev:cli -- rebuild <name>
|
||||
```
|
||||
|
||||
- `--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`).
|
||||
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.
|
||||
|
||||
### Build & Rebuild
|
||||
## Run images
|
||||
|
||||
```bash
|
||||
# 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
|
||||
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
|
||||
homarr run <name>
|
||||
homarr run --pr <number>
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
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.
|
||||
@@ -25,27 +25,3 @@ You should also create a backup of the database, in case you break something.
|
||||
4. Click "Execute" in the top right of the Window. 
|
||||
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`.
|
||||
@@ -204,13 +204,10 @@ 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 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.
|
||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
||||
Spotlight. It lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
||||
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
|
||||
destinations before the widget is created, so the common flow does not require a second move step.
|
||||
|
||||
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
|
||||
@@ -230,7 +227,11 @@ During a pointer or touch interaction, Escape cancels the move or resize and res
|
||||
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
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`.
|
||||
@@ -1,43 +0,0 @@
|
||||
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,26 +15,6 @@ 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";
|
||||
@@ -47,7 +27,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 />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -20,6 +20,18 @@ 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.",
|
||||
@@ -40,46 +52,21 @@ export const clockWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Date format",
|
||||
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",
|
||||
description: "Format of the date displayed below the clock.",
|
||||
values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.",
|
||||
defaultValue: "dddd, MMMM D",
|
||||
},
|
||||
{
|
||||
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 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: "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",
|
||||
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: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
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} />
|
||||
@@ -1,31 +0,0 @@
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
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} />
|
||||
@@ -1,61 +0,0 @@
|
||||
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,21 +13,23 @@ import { weatherWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
### Screenshots
|
||||
|
||||
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.
|
||||
import forecastWeather from "./img/forecast.png";
|
||||
import currentWeather from "./img/current.png";
|
||||
import advancedWeather from "./img/advanced-view.png";
|
||||
|
||||
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.
|
||||
<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>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
@@ -41,10 +43,5 @@ reduced-motion preference always disables it.
|
||||
|
||||
### Data source
|
||||
|
||||
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.
|
||||
We use the Open Meteo API to fetch the weather and locations.
|
||||
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
|
||||
@@ -28,24 +28,8 @@ 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",
|
||||
@@ -75,7 +59,7 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "yes",
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Has forecast",
|
||||
@@ -83,13 +67,13 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "yes",
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Amount of forecast days",
|
||||
description: "Number of days to show in the forecast",
|
||||
values: "1-7",
|
||||
defaultValue: "3",
|
||||
defaultValue: "5",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
|
||||
import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
|
||||
import { CommonWidgetProps, WidgetCard } from "./card";
|
||||
import clsx from "clsx";
|
||||
|
||||
@@ -6,27 +6,21 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => {
|
||||
return (
|
||||
<WidgetCard width={1} className={clsx("gap-2", className)}>
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
<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>
|
||||
<IconCloud size={20} />
|
||||
<span className="text-xl font-bold">10.8°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 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>
|
||||
</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 className="flex justify-center items-center">
|
||||
<IconArrowDownRight size={14} />
|
||||
<span className="text-sm">6.4°C</span>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetCard>
|
||||
|
||||
-3
@@ -2106,9 +2106,6 @@
|
||||
"enum": [
|
||||
"clock",
|
||||
"weather",
|
||||
"airQuality",
|
||||
"countdown",
|
||||
"timer",
|
||||
"app",
|
||||
"iframe",
|
||||
"video",
|
||||
|
||||
@@ -23,8 +23,6 @@ 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;
|
||||
@@ -65,74 +63,71 @@ export const ClientBoard = () => {
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<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")}>
|
||||
<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}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</section>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<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}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
@@ -2,19 +2,14 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
import { IconPlus } 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 { 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 { useBoardAddActions } from "~/components/board/use-board-add-actions";
|
||||
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export default function BoardEditActions() {
|
||||
@@ -22,13 +17,9 @@ export default function BoardEditActions() {
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const t = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -38,69 +29,29 @@ const AddMenu = () => {
|
||||
useEffect(() => {
|
||||
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
||||
handledInitialAdd.current = true;
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
addWidget();
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("add");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [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 });
|
||||
};
|
||||
}, [addWidget, board.id, pathname, router, searchParams]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* Procedural UI sounds for Homarr using the Web Audio API.
|
||||
* Zero audio files shipped. Pure runtime synthesis with oscillators, noise buffers, and gain envelopes.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
const getAudioContext = (): AudioContext | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
if (!audioContext) {
|
||||
const AudioContextClass =
|
||||
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioContextClass) audioContext = new AudioContextClass();
|
||||
}
|
||||
if (audioContext?.state === "suspended") void audioContext.resume().catch(() => undefined);
|
||||
return audioContext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a white noise audio buffer for percussive textures
|
||||
*/
|
||||
const createNoiseBuffer = (ctx: AudioContext, durationSeconds = 0.2): AudioBuffer => {
|
||||
const bufferSize = Math.floor(ctx.sampleRate * durationSeconds);
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Procedural trash / delete crunch sound.
|
||||
* Synthesizes a crunchy bin/paper crumple noise burst and downward pitch thud.
|
||||
*/
|
||||
export const playTrashSound = (volume = 0.35) => {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Master Gain
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.gain.setValueAtTime(volume, now);
|
||||
masterGain.connect(ctx.destination);
|
||||
|
||||
// 1. Downward sweep sub-oscillator (physical thud)
|
||||
const osc = ctx.createOscillator();
|
||||
const oscGain = ctx.createGain();
|
||||
osc.type = "triangle";
|
||||
osc.frequency.setValueAtTime(260, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(35, now + 0.18);
|
||||
oscGain.gain.setValueAtTime(0.7, now);
|
||||
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.2);
|
||||
|
||||
// 2. High-crunch noise burst (crush / crumple texture)
|
||||
try {
|
||||
const noiseBuffer = createNoiseBuffer(ctx, 0.22);
|
||||
const noiseSource = ctx.createBufferSource();
|
||||
noiseSource.buffer = noiseBuffer;
|
||||
|
||||
const noiseFilter = ctx.createBiquadFilter();
|
||||
noiseFilter.type = "bandpass";
|
||||
noiseFilter.frequency.setValueAtTime(1400, now);
|
||||
noiseFilter.frequency.exponentialRampToValueAtTime(320, now + 0.2);
|
||||
noiseFilter.Q.setValueAtTime(2.2, now);
|
||||
|
||||
const noiseGain = ctx.createGain();
|
||||
noiseGain.gain.setValueAtTime(0.9, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
|
||||
|
||||
noiseSource.connect(noiseFilter);
|
||||
noiseFilter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
|
||||
noiseSource.start(now);
|
||||
noiseSource.stop(now + 0.22);
|
||||
} catch {
|
||||
// The noise layer is optional; retain the oscillator when buffers are constrained.
|
||||
}
|
||||
} catch {
|
||||
// Sound feedback must never interrupt deleting board items.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Procedural select / pop sound.
|
||||
*/
|
||||
export const playPopSound = (volume = 0.25) => {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
const now = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(460, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(780, now + 0.05);
|
||||
|
||||
gain.gain.setValueAtTime(volume, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.07);
|
||||
} catch {
|
||||
// Sound feedback must never interrupt selecting board items.
|
||||
}
|
||||
};
|
||||
@@ -11,19 +11,6 @@
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -52,9 +39,3 @@
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settingsButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -227,9 +227,7 @@ const LoadedBoardItemContent = ({
|
||||
(host: HTMLDivElement | null) => {
|
||||
if (!host || !surfacePortalTarget || isAdvanced) return;
|
||||
cancelPreviewEntrance();
|
||||
if (host.childNodes.length !== 1 || host.firstChild !== surfacePortalTarget) {
|
||||
host.replaceChildren(surfacePortalTarget);
|
||||
}
|
||||
host.append(surfacePortalTarget);
|
||||
},
|
||||
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 {
|
||||
@@ -106,11 +105,9 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
top={12}
|
||||
right={8}
|
||||
style={{ zIndex: 30 }}
|
||||
className={itemContentClasses.settingsButton}
|
||||
data-menu-open={isMenuOpen || undefined}
|
||||
data-board-widget-settings
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import {
|
||||
getWidgetConnectionStatus,
|
||||
resolveMatchingIntegrationsAsync,
|
||||
@@ -10,246 +8,56 @@ import {
|
||||
} from "./item-select-data";
|
||||
|
||||
describe("getWidgetConnectionStatus", () => {
|
||||
const availableKinds = new Set<IntegrationKind>(["sonarr", "plex"]);
|
||||
const availableKinds = new Set(["sonarr" as const]);
|
||||
|
||||
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 widgets with a configured compatible integration as ready", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
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("reports required integrations that are not configured as needing setup", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("needsSetup");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
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("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", () => {
|
||||
describe("widget selection lock", () => {
|
||||
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
||||
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("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 }]);
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: false,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses currentData synchronously when available without calling ensureDataAsync", async () => {
|
||||
const currentData = [
|
||||
{ id: "1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
{ id: "2", kind: "radarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
{ id: "3", kind: "plex" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
];
|
||||
const ensureDataAsync = vi.fn();
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
currentData,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual([currentData[0], currentData[1]]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls ensureDataAsync when currentData is undefined and filters by supported kinds", async () => {
|
||||
const fetchedData = [
|
||||
{ id: "1", kind: "sonarr" as IntegrationKind },
|
||||
{ id: "2", kind: "radarr" as IntegrationKind },
|
||||
{ id: "3", kind: "sabNzbd" as IntegrationKind },
|
||||
];
|
||||
const ensureDataAsync = vi.fn(async () => fetchedData);
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["radarr", "sabNzbd"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual([fetchedData[1], fetchedData[2]]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("excludes integrations where permissions.hasUseAccess is explicitly false", async () => {
|
||||
const currentData = [
|
||||
{ id: "allowed-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
{ id: "blocked-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: false } },
|
||||
{ id: "allowed-no-perms", kind: "sonarr" as IntegrationKind },
|
||||
];
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData,
|
||||
ensureDataAsync: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toEqual([currentData[0], currentData[2]]);
|
||||
});
|
||||
|
||||
it("returns empty array when none of the fetched integrations match supported kinds", async () => {
|
||||
const fetchedData = [
|
||||
{ id: "1", kind: "plex" as IntegrationKind },
|
||||
{ id: "2", kind: "jellyfin" as IntegrationKind },
|
||||
];
|
||||
const ensureDataAsync = vi.fn(async () => fetchedData);
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("propagates error when ensureDataAsync rejects", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => {
|
||||
throw new Error("Network timeout");
|
||||
});
|
||||
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 },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
@@ -258,6 +66,37 @@ describe("resolveMatchingIntegrationsAsync", () => {
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).rejects.toThrow("Network timeout");
|
||||
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not wait for integrations when the widget has no integration support", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: false,
|
||||
supportedIntegrations: [],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("excludes matching integrations the user cannot use", async () => {
|
||||
const currentData = [
|
||||
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
|
||||
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
|
||||
];
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData,
|
||||
ensureDataAsync: vi.fn(),
|
||||
}),
|
||||
).resolves.toEqual([currentData[0]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
.action {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.card:hover .action,
|
||||
.card:focus-within .action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { ConnectionStatusBadge, SupportedIntegrations, WidgetItem } from "./item-select-modal";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"item.create.connectionStatus.ready": "Ready",
|
||||
"item.create.connectionStatus.needsSetup": "Needs setup",
|
||||
"item.create.connectionStatus.noConnectionRequired": "No connection required",
|
||||
"item.create.standalone": "Standalone",
|
||||
"widget.mediaServer.name": "Media Server",
|
||||
"widget.mediaServer.description": "Stream your media",
|
||||
"widget.clock.name": "Clock",
|
||||
"widget.clock.description": "Displays current time",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/definitions", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("@homarr/definitions");
|
||||
return {
|
||||
...actual,
|
||||
getIntegrationName: (kind: string) => kind.charAt(0).toUpperCase() + kind.slice(1),
|
||||
};
|
||||
});
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("Advanced Add App - Modal Subcomponents", () => {
|
||||
describe("ConnectionStatusBadge", () => {
|
||||
it("renders 'Ready' badge with teal color when status is 'ready'", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="ready" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Ready");
|
||||
const badge = host.querySelector("span, div");
|
||||
expect(badge).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders 'Needs setup' badge with orange color when status is 'needsSetup'", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="needsSetup" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Needs setup");
|
||||
});
|
||||
|
||||
it("renders null when status is 'noConnectionRequired'", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[class*='Badge-root']")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SupportedIntegrations", () => {
|
||||
it("renders up to 5 avatars for supported integrations", async () => {
|
||||
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex"];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={integrations} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Avatars should be rendered for the 3 items
|
||||
expect(host.querySelectorAll("img, svg, div").length).toBeGreaterThan(0);
|
||||
expect(host.textContent).not.toContain("+");
|
||||
});
|
||||
|
||||
it("renders overflow indicator when 6 or more integrations are supported", async () => {
|
||||
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby", "deluge"];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={integrations} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// 6 integrations total: 4 shown, +2 in badge
|
||||
expect(host.textContent).toContain("+2");
|
||||
});
|
||||
|
||||
it("renders Standalone label when integrations list is empty", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={[]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Standalone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WidgetItem", () => {
|
||||
const FakeIcon = () => <span data-testid="widget-icon">Icon</span>;
|
||||
|
||||
it("renders widget card with icon, title, description, and connection status", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const item = {
|
||||
kind: "mediaServer" as WidgetKind,
|
||||
name: "Media Server",
|
||||
description: "Stream your media collection effortlessly",
|
||||
icon: FakeIcon as never,
|
||||
supportedIntegrations: ["plex" as IntegrationKind, "jellyfin" as IntegrationKind],
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<WidgetItem item={item} disabled={false} loading={false} connectionStatus="ready" onSelect={onSelect} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
expect(host.textContent).toContain("Media Server");
|
||||
expect(host.textContent).toContain("Stream your media collection effortlessly");
|
||||
expect(host.textContent).toContain("Ready");
|
||||
expect(host.querySelector("[data-testid='widget-icon']")).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not mark widget cards as selected", async () => {
|
||||
const item = {
|
||||
kind: "clock" as WidgetKind,
|
||||
name: "Clock",
|
||||
description: "Displays current time",
|
||||
icon: FakeIcon as never,
|
||||
supportedIntegrations: [] as IntegrationKind[],
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<WidgetItem
|
||||
item={item}
|
||||
disabled={false}
|
||||
loading={false}
|
||||
connectionStatus="noConnectionRequired"
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.hasAttribute("data-selected")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
@@ -6,18 +6,16 @@ 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, IconSearch } from "@tabler/icons-react";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -25,8 +23,8 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getIconUrl,
|
||||
getBoardLaneColumnCount,
|
||||
getIntegrationName,
|
||||
getRootSectionLane,
|
||||
widgetIntegrationSupport,
|
||||
@@ -38,14 +36,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 { SelectableCard } from "@homarr/ui";
|
||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||
import {
|
||||
@@ -66,16 +64,6 @@ 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,
|
||||
@@ -120,14 +108,15 @@ 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();
|
||||
@@ -139,7 +128,7 @@ const ItemSelectModalContent = ({
|
||||
[integrationData],
|
||||
);
|
||||
|
||||
const items = useMemo<WidgetItemData[]>(
|
||||
const items = useMemo(
|
||||
() =>
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
@@ -199,7 +188,7 @@ const ItemSelectModalContent = ({
|
||||
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
|
||||
createdItem.layouts[0]?.sectionId;
|
||||
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
|
||||
const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination;
|
||||
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
|
||||
const notificationId = `board-item-created:${itemId}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
@@ -258,7 +247,7 @@ const ItemSelectModalContent = ({
|
||||
kind: "customApi",
|
||||
integrationIds: [],
|
||||
options: configuredOptions,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||
@@ -321,7 +310,7 @@ const ItemSelectModalContent = ({
|
||||
kind,
|
||||
options,
|
||||
integrationIds: newIntegrationIds,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
|
||||
@@ -388,233 +377,125 @@ const ItemSelectModalContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
}
|
||||
<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 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>
|
||||
|
||||
{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>
|
||||
{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={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<CatalogItem
|
||||
key={def.id}
|
||||
height={selectGridCardHeight}
|
||||
label={def.name}
|
||||
status={t("item.create.connectionStatus.noConnectionRequired")}
|
||||
disabled={loadingSelection !== null}
|
||||
busy={loadingSelection === `custom:${def.id}`}
|
||||
onSelect={() => void handleAddCustomWidget(def)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition("customApi").catch(() => undefined);
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition("customApi").catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
))}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
{/* Opens in a new tab so unsaved board changes survive the detour. */}
|
||||
<Button
|
||||
component="a"
|
||||
href="/manage/custom-widgets/workshop"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconBuildingStore size={16} />}
|
||||
>
|
||||
{t("workshop.browseWorkshop")}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemSelectModalFrame = ({
|
||||
actions,
|
||||
innerProps,
|
||||
@@ -660,3 +541,109 @@ 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,19 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
||||
import { Suspense, use } 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, useModalAction } from "@homarr/modals";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
||||
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
||||
|
||||
@@ -83,46 +80,13 @@ interface LazyWidgetEditModalContentProps {
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const Component = WidgetEditModal.component;
|
||||
const utils = clientApi.useUtils();
|
||||
const [createdIntegrations, setCreatedIntegrations] = useState<IntegrationSelectOption[]>([]);
|
||||
|
||||
const supportedKinds = useMemo(() => {
|
||||
if (!("supportedIntegrations" in props.innerProps.definition)) return [];
|
||||
return (props.innerProps.definition.supportedIntegrations ?? []).filter((k) => k !== "mock");
|
||||
}, [props.innerProps.definition]);
|
||||
|
||||
const handleOpenNewIntegration = useCallback(
|
||||
(onCreated?: (id: string) => void) => {
|
||||
openIntegrationModal({
|
||||
allowedKinds: supportedKinds,
|
||||
onSuccess: (result) => {
|
||||
if (result?.integration) {
|
||||
setCreatedIntegrations((prev) => [...prev, result.integration]);
|
||||
void utils.integration.all.invalidate();
|
||||
onCreated?.(result.integration.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[openIntegrationModal, supportedKinds, utils],
|
||||
);
|
||||
|
||||
const combinedIntegrationData = useMemo(() => {
|
||||
const existing = props.innerProps.integrationData;
|
||||
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
||||
return [...existing, ...additional];
|
||||
}, [props.innerProps.integrationData, createdIntegrations]);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
innerProps={{
|
||||
...props.innerProps,
|
||||
integrationData: combinedIntegrationData,
|
||||
integrationEditForm: LazyIntegrationEditForm,
|
||||
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -158,5 +122,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -17,7 +17,6 @@ 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";
|
||||
@@ -49,16 +48,6 @@ 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);
|
||||
@@ -189,8 +178,6 @@ 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}
|
||||
@@ -200,7 +187,6 @@ 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,27 +32,6 @@
|
||||
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;
|
||||
@@ -198,115 +177,138 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 25;
|
||||
display: grid;
|
||||
z-index: 20;
|
||||
display: block;
|
||||
padding: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
appearance: none;
|
||||
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;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
touch-action: none;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
scale 160ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle::before {
|
||||
position: absolute;
|
||||
inset: -0.375rem;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.board-grid-resize-handle > svg {
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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);
|
||||
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 {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -8px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
left: 50%;
|
||||
width: 1.75rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
translate: -50% 0;
|
||||
right: 44px;
|
||||
left: 44px;
|
||||
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||
top: -0.25rem;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
bottom: -0.25rem;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
top: 50%;
|
||||
width: 1rem;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
translate: 0 -50%;
|
||||
top: 44px;
|
||||
bottom: 44px;
|
||||
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||
right: -0.25rem;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
left: -0.25rem;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: var(--board-item-radius, var(--mantine-radius-md));
|
||||
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)));
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
top: -0.625rem;
|
||||
right: -0.625rem;
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: -0.625rem;
|
||||
bottom: -0.625rem;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: -0.625rem;
|
||||
left: -0.625rem;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: -0.625rem;
|
||||
left: -0.625rem;
|
||||
top: 0;
|
||||
left: 0;
|
||||
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 {
|
||||
.board-grid-resize-handle::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ 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";
|
||||
@@ -62,7 +61,6 @@ 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";
|
||||
|
||||
@@ -848,9 +846,6 @@ 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);
|
||||
@@ -926,12 +921,7 @@ const DndGridEntryComponent = ({
|
||||
<div
|
||||
ref={setShellRef}
|
||||
className="board-grid-entry"
|
||||
style={
|
||||
{
|
||||
...getLogicalItemStyle(placement),
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
style={getLogicalItemStyle(placement) as CSSProperties}
|
||||
data-grid-item-id={placement.id}
|
||||
data-grid-item-type={placement.type}
|
||||
data-grid-x={placement.x}
|
||||
@@ -940,7 +930,6 @@ 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
|
||||
@@ -1113,27 +1102,19 @@ const GridResizeHandles = ({
|
||||
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
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)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const getProjectedDragShape = (
|
||||
@@ -1258,7 +1239,6 @@ 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,8 +10,6 @@ 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: [
|
||||
@@ -46,13 +44,6 @@ 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",
|
||||
}));
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { usePersistBoard } from "@homarr/boards/updater";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||
import { playPopSound, playTrashSound } from "../audio/board-sounds";
|
||||
|
||||
interface BoardSelectionContextValue {
|
||||
selectedItemIds: Set<string>;
|
||||
toggleSelectItem: (id: string) => void;
|
||||
clearSelection: () => void;
|
||||
isSelected: (id: string) => boolean;
|
||||
removeSelectedItems: () => void;
|
||||
moveSelectedItemsToSection: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
|
||||
|
||||
export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { updateAndPersistBoard } = usePersistBoard(board);
|
||||
const t = useI18n();
|
||||
const tSelection = useScopedI18n("item.selection");
|
||||
|
||||
const persistBoard = useCallback(
|
||||
(updater: (previous: typeof board) => typeof board) => {
|
||||
updateAndPersistBoard(updater, {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tSelection("saveErrorTitle"),
|
||||
message: tSelection("saveErrorMessage"),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[tSelection, updateAndPersistBoard],
|
||||
);
|
||||
|
||||
// Clear selection if leaving edit mode
|
||||
useEffect(() => {
|
||||
if (!isEditMode) {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
// Clear selection on Escape key
|
||||
useEffect(() => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedItemIds.size]);
|
||||
|
||||
const toggleSelectItem = useCallback((id: string) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
playPopSound();
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedItemIds(new Set());
|
||||
}, []);
|
||||
|
||||
const isSelected = useCallback((id: string) => selectedItemIds.has(id), [selectedItemIds]);
|
||||
|
||||
const removeSelectedItems = useCallback(() => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const idsToRemove = Array.from(selectedItemIds);
|
||||
const count = idsToRemove.length;
|
||||
const removedItems = board.items.filter((item) => idsToRemove.includes(item.id));
|
||||
|
||||
playTrashSound();
|
||||
clearSelection();
|
||||
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.filter((item) => !idsToRemove.includes(item.id)),
|
||||
}));
|
||||
|
||||
const notificationId = `batch-remove-${Date.now()}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
title: t("common.success"),
|
||||
message: (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">{tSelection("removed", { count })}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => {
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: [...previous.items, ...removedItems],
|
||||
}));
|
||||
notifications.hide(notificationId);
|
||||
}}
|
||||
>
|
||||
{t("common.action.undo")}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
autoClose: 10_000,
|
||||
});
|
||||
}, [board.items, clearSelection, persistBoard, selectedItemIds, t, tSelection]);
|
||||
|
||||
const moveSelectedItemsToSection = useCallback(
|
||||
(targetSectionId: string) => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const idsToMove = new Set(selectedItemIds);
|
||||
let didMoveAll = false;
|
||||
|
||||
persistBoard((previous) => {
|
||||
const targetSection = previous.sections.find((section) => section.id === targetSectionId);
|
||||
const boardLayout = previous.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
if (!targetSection || !boardLayout) return previous;
|
||||
|
||||
const containerLayout =
|
||||
targetSection.kind === "container"
|
||||
? targetSection.layouts.find((layout) => layout.layoutId === currentLayoutId)
|
||||
: undefined;
|
||||
const columnCount =
|
||||
targetSection.kind === "container"
|
||||
? (containerLayout?.width ?? 0)
|
||||
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
|
||||
const rowCount = containerLayout?.height ?? 9999;
|
||||
if (columnCount === 0 || rowCount === 0) return previous;
|
||||
|
||||
const existingContainers = previous.sections
|
||||
.filter((section): section is ContainerSection => section.kind === "container")
|
||||
.flatMap((section) => {
|
||||
const layout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === currentLayoutId && candidate.parentSectionId === targetSection.id,
|
||||
);
|
||||
return layout
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const existingItems = previous.items.flatMap((item) => {
|
||||
const layout = item.layouts.find(
|
||||
(candidate) => candidate.layoutId === currentLayoutId && candidate.sectionId === targetSection.id,
|
||||
);
|
||||
return layout
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const occupiedPositions = [...existingContainers, ...existingItems];
|
||||
const movedLayouts = new Map<string, ItemLayout>();
|
||||
|
||||
for (const item of previous.items.filter((candidate) => idsToMove.has(candidate.id))) {
|
||||
const currentItemLayout = item.layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||
if (!currentItemLayout) continue;
|
||||
|
||||
const previousPositionIndex = occupiedPositions.findIndex((position) => position.id === item.id);
|
||||
const previousPosition =
|
||||
previousPositionIndex >= 0 ? occupiedPositions.splice(previousPositionIndex, 1).at(0) : undefined;
|
||||
const { width, height } = currentItemLayout;
|
||||
const emptyPosition =
|
||||
width <= columnCount && height <= rowCount
|
||||
? getFirstEmptyPosition(occupiedPositions, columnCount, rowCount, { width, height })
|
||||
: undefined;
|
||||
|
||||
if (!emptyPosition) {
|
||||
if (previousPosition) occupiedPositions.push(previousPosition);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextLayout: ItemLayout = {
|
||||
...currentItemLayout,
|
||||
sectionId: targetSectionId,
|
||||
xOffset: emptyPosition.xOffset,
|
||||
yOffset: emptyPosition.yOffset,
|
||||
};
|
||||
movedLayouts.set(item.id, nextLayout);
|
||||
occupiedPositions.push({ id: item.id, ...emptyPosition, width, height });
|
||||
}
|
||||
|
||||
if (movedLayouts.size !== idsToMove.size) return previous;
|
||||
didMoveAll = true;
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) => {
|
||||
const movedLayout = movedLayouts.get(item.id);
|
||||
if (!movedLayout) return item;
|
||||
return {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => (layout.layoutId === currentLayoutId ? movedLayout : layout)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
if (didMoveAll) {
|
||||
clearSelection();
|
||||
} else {
|
||||
showErrorNotification({
|
||||
title: tSelection("moveUnavailableTitle"),
|
||||
message: tSelection("moveUnavailableMessage"),
|
||||
});
|
||||
}
|
||||
},
|
||||
[clearSelection, currentLayoutId, persistBoard, selectedItemIds, tSelection],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
selectedItemIds,
|
||||
toggleSelectItem,
|
||||
clearSelection,
|
||||
isSelected,
|
||||
removeSelectedItems,
|
||||
moveSelectedItemsToSection,
|
||||
}),
|
||||
[clearSelection, isSelected, moveSelectedItemsToSection, removeSelectedItems, selectedItemIds, toggleSelectItem],
|
||||
);
|
||||
|
||||
return <BoardSelectionContext.Provider value={value}>{children}</BoardSelectionContext.Provider>;
|
||||
};
|
||||
|
||||
export const useBoardSelection = () => {
|
||||
const context = useContext(BoardSelectionContext);
|
||||
if (!context) {
|
||||
throw new Error("useBoardSelection must be used within a BoardSelectionProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,255 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
HoverCard,
|
||||
Menu,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useBoardSelection } from "./board-selection-context";
|
||||
|
||||
const MAX_AVATARS_DISPLAYED = 5;
|
||||
|
||||
export const BoardSelectionToolbar = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const t = useI18n();
|
||||
const tSelection = useScopedI18n("item.selection");
|
||||
|
||||
const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id));
|
||||
const hasAppWidgets = selectedItems.some((item) => item.kind === "app");
|
||||
const { data: selectableApps } = clientApi.app.selectable.useQuery(undefined, {
|
||||
enabled: hasAppWidgets,
|
||||
});
|
||||
|
||||
if (!isEditMode || selectedItemIds.size === 0) return null;
|
||||
|
||||
const appMap = new Map((selectableApps ?? []).map((app) => [app.id, app]));
|
||||
|
||||
// Sort items so apps appear first
|
||||
const sortedSelectedItems = selectedItems.toSorted((itemA, itemB) => {
|
||||
if (itemA.kind === "app" && itemB.kind !== "app") return -1;
|
||||
if (itemA.kind !== "app" && itemB.kind === "app") return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const getItemDetails = (item: (typeof selectedItems)[number]) => {
|
||||
let displayName = item.advancedOptions.title?.trim();
|
||||
let iconUrl: string | undefined = undefined;
|
||||
const IconComponent = widgetCatalogIcons[item.kind] ?? IconCheck;
|
||||
|
||||
if (item.kind === "app") {
|
||||
const appId = (item.options as { appId?: string })?.appId;
|
||||
const app = appId ? appMap.get(appId) : undefined;
|
||||
if (!displayName) {
|
||||
displayName = app?.name || t("widget.app.name");
|
||||
}
|
||||
if (app?.iconUrl) {
|
||||
iconUrl = app.iconUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
displayName = t(`widget.${item.kind}.name`);
|
||||
}
|
||||
|
||||
return { displayName, iconUrl, IconComponent, kind: item.kind };
|
||||
};
|
||||
|
||||
const visibleItems = sortedSelectedItems.slice(0, MAX_AVATARS_DISPLAYED);
|
||||
const overflowCount = sortedSelectedItems.length - visibleItems.length;
|
||||
|
||||
const mainCanvasSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
const placementOptions = [
|
||||
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "empty") return [];
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
|
||||
return [
|
||||
{
|
||||
value: section.id,
|
||||
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
||||
},
|
||||
];
|
||||
}),
|
||||
...board.sections
|
||||
.filter((section) => section.kind === "container")
|
||||
.map((section) => ({
|
||||
value: section.id,
|
||||
label: section.options.title.trim() || t("section.container.untitled"),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="xl"
|
||||
radius="xl"
|
||||
p="xs"
|
||||
bg="light-dark(var(--mantine-color-body), var(--mantine-color-dark-7))"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 28,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 1000,
|
||||
width: "max-content",
|
||||
maxWidth: "calc(100vw - 1rem)",
|
||||
boxShadow: "0 12px 40px rgba(0, 0, 0, 0.28)",
|
||||
backdropFilter: "blur(14px)",
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
animation: "slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Avatar Group with HoverCard Popover showing all items */}
|
||||
<HoverCard position="top" withArrow shadow="xl" radius="md" openDelay={100} closeDelay={200} withinPortal>
|
||||
<HoverCard.Target>
|
||||
<UnstyledButton
|
||||
display="inline-flex"
|
||||
aria-label={tSelection("summary", { count: sortedSelectedItems.length })}
|
||||
style={{ alignItems: "center", borderRadius: "var(--mantine-radius-xl)" }}
|
||||
>
|
||||
<Avatar.Group>
|
||||
{visibleItems.map((item) => {
|
||||
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
|
||||
return (
|
||||
<Avatar
|
||||
key={item.id}
|
||||
src={iconUrl}
|
||||
radius="xl"
|
||||
size={32}
|
||||
color="primaryColor"
|
||||
variant="light"
|
||||
styles={{ image: { objectFit: "contain", padding: 2 } }}
|
||||
alt={displayName}
|
||||
>
|
||||
{!iconUrl && <IconComponent size={16} />}
|
||||
</Avatar>
|
||||
);
|
||||
})}
|
||||
{overflowCount > 0 && (
|
||||
<Avatar radius="xl" size={32} color="gray" variant="filled">
|
||||
+{overflowCount}
|
||||
</Avatar>
|
||||
)}
|
||||
</Avatar.Group>
|
||||
</UnstyledButton>
|
||||
</HoverCard.Target>
|
||||
|
||||
<HoverCard.Dropdown p={6} style={{ width: 220 }}>
|
||||
<Stack gap={4}>
|
||||
<Text size="10px" fw={700} c="dimmed" tt="uppercase" px={4}>
|
||||
{tSelection("selected", { count: sortedSelectedItems.length })}
|
||||
</Text>
|
||||
|
||||
<ScrollArea.Autosize mah={176} type="auto">
|
||||
<Stack gap={2}>
|
||||
{sortedSelectedItems.map((item) => {
|
||||
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
|
||||
return (
|
||||
<Group key={item.id} gap={6} wrap="nowrap" px={4} py={2}>
|
||||
<Avatar
|
||||
src={iconUrl}
|
||||
radius="sm"
|
||||
size={22}
|
||||
color="primaryColor"
|
||||
variant="light"
|
||||
styles={{ image: { objectFit: "contain", padding: 2 } }}
|
||||
alt={displayName}
|
||||
>
|
||||
{!iconUrl && <IconComponent size={12} />}
|
||||
</Avatar>
|
||||
<Text size="xs" fw={500} truncate>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
|
||||
<Text size="xs" c="dimmed" visibleFrom="sm" maw={220} lh={1.25}>
|
||||
{t("app.action.select.multiSelectTip")}
|
||||
</Text>
|
||||
|
||||
<Divider orientation="vertical" />
|
||||
|
||||
{/* Actions */}
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{placementOptions.length > 1 && (
|
||||
<Menu position="top" withArrow shadow="md">
|
||||
<Menu.Target>
|
||||
<Button size="xs" variant="light" color="primaryColor" leftSection={<IconFolderShare size={15} />}>
|
||||
{tSelection("move")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
|
||||
{placementOptions.map((opt) => (
|
||||
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
|
||||
{opt.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={15} />}
|
||||
onClick={removeSelectedItems}
|
||||
>
|
||||
{tSelection("delete")}
|
||||
</Button>
|
||||
|
||||
<Tooltip label={tSelection("clearShortcut")} withArrow>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={clearSelection}
|
||||
aria-label={tSelection("clear")}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -1,251 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "~/components/board/items/actions/test/mocks/layout-mock";
|
||||
import { BoardSelectionProvider, useBoardSelection } from "./board-selection-context";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => key,
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("../audio/board-sounds", () => ({
|
||||
playPopSound: vi.fn(),
|
||||
playTrashSound: vi.fn(),
|
||||
}));
|
||||
|
||||
let currentBoardState: Board;
|
||||
let mockUpdateBoard: (updater: (prev: Board) => Board) => void;
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useRequiredBoard: () => currentBoardState,
|
||||
useCurrentLayout: () => "layout-1",
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [true, vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/updater", () => ({
|
||||
usePersistBoard: () => ({
|
||||
updateAndPersistBoard: (updater: (prev: Board) => Board) => {
|
||||
mockUpdateBoard(updater);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const SelectionConsumer = ({ onReady }: { onReady: (ctx: ReturnType<typeof useBoardSelection>) => void }) => {
|
||||
const selection = useBoardSelection();
|
||||
onReady(selection);
|
||||
return <div>Selection Ready</div>;
|
||||
};
|
||||
|
||||
describe("BoardSelectionContext", () => {
|
||||
it("toggles item selection and clears on clearSelection", async () => {
|
||||
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
|
||||
const item1 = new ItemMockBuilder({ id: "item-1" }).addLayout({ layoutId: "layout-1" }).build();
|
||||
currentBoardState = new BoardMockBuilder().addLayout(layout).addItem(item1).build();
|
||||
mockUpdateBoard = vi.fn();
|
||||
|
||||
let selectionContext!: ReturnType<typeof useBoardSelection>;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<BoardSelectionProvider>
|
||||
<SelectionConsumer
|
||||
onReady={(ctx) => {
|
||||
selectionContext = ctx;
|
||||
}}
|
||||
/>
|
||||
</BoardSelectionProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(selectionContext.isSelected("item-1")).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.toggleSelectItem("item-1");
|
||||
});
|
||||
expect(selectionContext.isSelected("item-1")).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.clearSelection();
|
||||
});
|
||||
expect(selectionContext.isSelected("item-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("moves selected items to the first available positions in a container section", async () => {
|
||||
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
|
||||
const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container-1" })
|
||||
.addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 4, height: 4 })
|
||||
.build();
|
||||
|
||||
// Existing item inside the container at (0, 0) with 2x2
|
||||
const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 2, height: 2 })
|
||||
.build();
|
||||
|
||||
// Two items to move from emptyRoot
|
||||
const itemA = new ItemMockBuilder({ id: "item-a" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 5, yOffset: 5, width: 2, height: 1 })
|
||||
.build();
|
||||
const itemB = new ItemMockBuilder({ id: "item-b" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 8, yOffset: 8, width: 2, height: 1 })
|
||||
.build();
|
||||
|
||||
currentBoardState = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addSection(emptyRoot)
|
||||
.addSection(container)
|
||||
.addItem(existingContainerItem)
|
||||
.addItem(itemA)
|
||||
.addItem(itemB)
|
||||
.build();
|
||||
|
||||
let updatedBoard: Board = currentBoardState;
|
||||
mockUpdateBoard = vi.fn((updater) => {
|
||||
updatedBoard = updater(currentBoardState);
|
||||
currentBoardState = updatedBoard;
|
||||
});
|
||||
|
||||
let selectionContext!: ReturnType<typeof useBoardSelection>;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<BoardSelectionProvider>
|
||||
<SelectionConsumer
|
||||
onReady={(ctx) => {
|
||||
selectionContext = ctx;
|
||||
}}
|
||||
/>
|
||||
</BoardSelectionProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.toggleSelectItem("item-a");
|
||||
selectionContext.toggleSelectItem("item-b");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.moveSelectedItemsToSection("container-1");
|
||||
});
|
||||
|
||||
const movedA = updatedBoard.items.find((i) => i.id === "item-a")?.layouts.find((l) => l.layoutId === "layout-1");
|
||||
const movedB = updatedBoard.items.find((i) => i.id === "item-b")?.layouts.find((l) => l.layoutId === "layout-1");
|
||||
|
||||
expect(movedA?.sectionId).toBe("container-1");
|
||||
expect(movedB?.sectionId).toBe("container-1");
|
||||
|
||||
// Container width is 4. Existing item at (0, 0) occupies (0,0)-(1,1).
|
||||
// itemA (2x1) is placed at first empty spot: (2, 0)
|
||||
expect(movedA?.xOffset).toBe(2);
|
||||
expect(movedA?.yOffset).toBe(0);
|
||||
|
||||
// itemB (2x1) is placed at next first empty spot: (2, 1)
|
||||
expect(movedB?.xOffset).toBe(2);
|
||||
expect(movedB?.yOffset).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the entire selection in place when the destination cannot fit every item", async () => {
|
||||
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
|
||||
const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container-1" })
|
||||
.addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 2, height: 1 })
|
||||
.build();
|
||||
const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
const itemA = new ItemMockBuilder({ id: "item-a" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 0, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
const itemB = new ItemMockBuilder({ id: "item-b" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 1, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
|
||||
currentBoardState = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addSection(emptyRoot)
|
||||
.addSection(container)
|
||||
.addItem(existingContainerItem)
|
||||
.addItem(itemA)
|
||||
.addItem(itemB)
|
||||
.build();
|
||||
|
||||
let updatedBoard = currentBoardState;
|
||||
mockUpdateBoard = vi.fn((updater) => {
|
||||
updatedBoard = updater(currentBoardState);
|
||||
currentBoardState = updatedBoard;
|
||||
});
|
||||
|
||||
let selectionContext!: ReturnType<typeof useBoardSelection>;
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<BoardSelectionProvider>
|
||||
<SelectionConsumer
|
||||
onReady={(ctx) => {
|
||||
selectionContext = ctx;
|
||||
}}
|
||||
/>
|
||||
</BoardSelectionProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.toggleSelectItem("item-a");
|
||||
selectionContext.toggleSelectItem("item-b");
|
||||
});
|
||||
await act(async () => {
|
||||
selectionContext.moveSelectedItemsToSection("container-1");
|
||||
});
|
||||
|
||||
expect(updatedBoard.items.find((item) => item.id === "item-a")?.layouts[0]?.sectionId).toBe("empty-root");
|
||||
expect(updatedBoard.items.find((item) => item.id === "item-b")?.layouts[0]?.sectionId).toBe("empty-root");
|
||||
expect(selectionContext.isSelected("item-a")).toBe(true);
|
||||
expect(selectionContext.isSelected("item-b")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export const useSetupAnalytics = () => {
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: t("common.analyticsError"),
|
||||
message: t("universalCreate.analyticsError"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { RankedUniversalCreateAction } from "./universal-create-actions";
|
||||
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
|
||||
describe("getUniversalCreateActionDefinitions", () => {
|
||||
test("only exposes actions allowed without a board context", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: false,
|
||||
permissions: ["board-create", "integration-create"],
|
||||
});
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
|
||||
});
|
||||
|
||||
test("places globally-created apps in the library group", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
|
||||
|
||||
expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
|
||||
});
|
||||
|
||||
test("keeps board content actions available without global create permissions", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
|
||||
});
|
||||
|
||||
test("exposes the complete surface to administrators on a board", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: true,
|
||||
permissions: ["admin", "app-create", "integration-create", "board-create"],
|
||||
});
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual([
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAndRankUniversalCreateActions", () => {
|
||||
const actions = [
|
||||
{ key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
|
||||
{
|
||||
key: "integration",
|
||||
group: "library",
|
||||
priority: 80,
|
||||
name: "Connect service",
|
||||
description: "Create an integration",
|
||||
keywords: ["provider"],
|
||||
},
|
||||
{ key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
|
||||
] satisfies RankedUniversalCreateAction[];
|
||||
|
||||
test("uses product priority when no search is entered", () => {
|
||||
expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
|
||||
"widget",
|
||||
"integration",
|
||||
"board",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ranks a name prefix ahead of a description or keyword match", () => {
|
||||
const results = filterAndRankUniversalCreateActions(actions, "create");
|
||||
|
||||
expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
|
||||
});
|
||||
|
||||
test("matches keywords and removes unrelated actions", () => {
|
||||
expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
export const universalCreateActionKeys = [
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
] as const;
|
||||
|
||||
export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
|
||||
export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
|
||||
|
||||
export interface UniversalCreateContext {
|
||||
hasBoardContext: boolean;
|
||||
permissions: readonly GroupPermissionKey[];
|
||||
}
|
||||
|
||||
interface UniversalCreateActionDefinition {
|
||||
key: UniversalCreateActionKey;
|
||||
group: UniversalCreateActionGroup;
|
||||
priority: number;
|
||||
isVisible: (context: UniversalCreateContext) => boolean;
|
||||
}
|
||||
|
||||
const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
|
||||
context.permissions.includes(permission);
|
||||
|
||||
const definitions: readonly UniversalCreateActionDefinition[] = [
|
||||
{
|
||||
key: "widget",
|
||||
group: "currentBoard",
|
||||
priority: 100,
|
||||
isVisible: ({ hasBoardContext }) => hasBoardContext,
|
||||
},
|
||||
{
|
||||
key: "app",
|
||||
group: "currentBoard",
|
||||
priority: 90,
|
||||
isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
|
||||
},
|
||||
{
|
||||
key: "integration",
|
||||
group: "library",
|
||||
priority: 80,
|
||||
isVisible: (context) => hasPermission(context, "integration-create"),
|
||||
},
|
||||
{
|
||||
key: "container",
|
||||
group: "currentBoard",
|
||||
priority: 70,
|
||||
isVisible: ({ hasBoardContext }) => hasBoardContext,
|
||||
},
|
||||
{
|
||||
key: "board",
|
||||
group: "boards",
|
||||
priority: 60,
|
||||
isVisible: (context) => hasPermission(context, "board-create"),
|
||||
},
|
||||
{
|
||||
key: "workshop",
|
||||
group: "library",
|
||||
priority: 40,
|
||||
isVisible: (context) => hasPermission(context, "admin"),
|
||||
},
|
||||
{
|
||||
key: "customWidget",
|
||||
group: "library",
|
||||
priority: 30,
|
||||
isVisible: (context) => hasPermission(context, "admin"),
|
||||
},
|
||||
];
|
||||
|
||||
export interface RankedUniversalCreateAction {
|
||||
key: UniversalCreateActionKey;
|
||||
group: UniversalCreateActionGroup;
|
||||
priority: number;
|
||||
name: string;
|
||||
description: string;
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
|
||||
definitions
|
||||
.filter((definition) => definition.isVisible(context))
|
||||
.map((definition) =>
|
||||
definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
|
||||
);
|
||||
|
||||
const normalize = (value: string) => value.trim().toLocaleLowerCase();
|
||||
|
||||
export const filterAndRankUniversalCreateActions = <TAction extends RankedUniversalCreateAction>(
|
||||
actions: readonly TAction[],
|
||||
query: string,
|
||||
) => {
|
||||
const normalizedQuery = normalize(query);
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const name = normalize(action.name);
|
||||
const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
|
||||
const relevance =
|
||||
normalizedQuery.length === 0
|
||||
? 0
|
||||
: name === normalizedQuery
|
||||
? 300
|
||||
: name.startsWith(normalizedQuery)
|
||||
? 200
|
||||
: searchableText.includes(normalizedQuery)
|
||||
? 100
|
||||
: -1;
|
||||
|
||||
return { action, relevance };
|
||||
})
|
||||
.filter(({ relevance }) => relevance >= 0)
|
||||
.toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
|
||||
.map(({ action }) => action);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
.action {
|
||||
width: 100%;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.action:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
declare const classes: {
|
||||
readonly action: string;
|
||||
readonly actionIcon: string;
|
||||
};
|
||||
export default classes;
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconApps,
|
||||
IconBox,
|
||||
IconCode,
|
||||
IconLayoutDashboard,
|
||||
IconLayoutGridAdd,
|
||||
IconPlugConnected,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import classes from "./universal-create-modal.module.css";
|
||||
import type {
|
||||
RankedUniversalCreateAction,
|
||||
UniversalCreateActionGroup,
|
||||
UniversalCreateActionKey,
|
||||
} from "./universal-create-actions";
|
||||
import { getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
import type { SetupMetricEntryPoint } from "./setup-analytics";
|
||||
import { useSetupAnalytics } from "./setup-analytics";
|
||||
|
||||
type BoardActionKey = "widget" | "app" | "integration" | "container";
|
||||
|
||||
export interface UniversalCreateModalProps {
|
||||
boardActions?: Partial<Record<BoardActionKey, () => void>>;
|
||||
entryPoint?: SetupMetricEntryPoint;
|
||||
}
|
||||
|
||||
const actionIcons = {
|
||||
widget: IconApps,
|
||||
app: IconBox,
|
||||
integration: IconPlugConnected,
|
||||
container: IconLayoutGridAdd,
|
||||
board: IconLayoutDashboard,
|
||||
workshop: IconSearch,
|
||||
customWidget: IconCode,
|
||||
} satisfies Record<UniversalCreateActionKey, TablerIcon>;
|
||||
|
||||
export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ actions, innerProps }) => {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
|
||||
const trackedOpen = useRef(false);
|
||||
const boardActions = innerProps.boardActions;
|
||||
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
|
||||
const trackSetup = useSetupAnalytics();
|
||||
const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
|
||||
const definitions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
permissions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedOpen.current) return;
|
||||
trackedOpen.current = true;
|
||||
trackSetup("surface-opened", {
|
||||
entryPoint,
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
});
|
||||
}, [boardActions, entryPoint, trackSetup]);
|
||||
|
||||
const invokeAction = (key: UniversalCreateActionKey) => {
|
||||
const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
|
||||
actions.closeModal();
|
||||
trackSetup("intent-selected", {
|
||||
entryPoint,
|
||||
intent: key,
|
||||
outcome: "continued",
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
});
|
||||
|
||||
if (boardAction) {
|
||||
boardAction();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "app":
|
||||
router.push("/manage/apps/new");
|
||||
break;
|
||||
case "integration":
|
||||
router.push("/manage/integrations/new");
|
||||
break;
|
||||
case "board":
|
||||
openAddBoardModal(undefined);
|
||||
break;
|
||||
case "workshop":
|
||||
router.push("/manage/workshop");
|
||||
break;
|
||||
case "customWidget":
|
||||
router.push("/manage/custom-widgets/new");
|
||||
break;
|
||||
case "widget":
|
||||
case "container":
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedActions = definitions.map(
|
||||
(definition) =>
|
||||
({
|
||||
...definition,
|
||||
name: t(`action.${definition.key}.label`),
|
||||
description: t(`action.${definition.key}.description`),
|
||||
keywords: t(`action.${definition.key}.keywords`).split(" "),
|
||||
}) satisfies RankedUniversalCreateAction,
|
||||
);
|
||||
const orderedActions = resolvedActions.toSorted((left, right) => right.priority - left.priority);
|
||||
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{groups.map((group) => {
|
||||
const groupActions = orderedActions.filter((action) => action.group === group);
|
||||
if (groupActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack key={group} gap={4}>
|
||||
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
|
||||
{t(`group.${group}`)}
|
||||
</Text>
|
||||
{groupActions.map((action) => {
|
||||
const Icon = actionIcons[action.key];
|
||||
return (
|
||||
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
|
||||
<Icon size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={1}>
|
||||
<Text fw={600} size="sm">
|
||||
{action.name}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{action.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("universalCreate.title"),
|
||||
size: "lg",
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UniversalCreateModal } from "./universal-create-modal";
|
||||
|
||||
/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
|
||||
export const UniversalCreateSpotlightResult = () => {
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const item = useMemo(
|
||||
() => ({
|
||||
id: "universal-create",
|
||||
name: t("title"),
|
||||
description: t("trigger.description"),
|
||||
icon: IconPlus,
|
||||
alwaysVisible: true,
|
||||
interaction: () => ({
|
||||
type: "javaScript" as const,
|
||||
onSelect: () => {
|
||||
openModal({ entryPoint: "spotlight" });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
[openModal, t],
|
||||
);
|
||||
|
||||
useRegisterSpotlightContextResults("universal-create", [item], [item]);
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { UniversalCreateModal } from "./universal-create-modal";
|
||||
|
||||
export const UniversalCreateHeaderAction = () => {
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
|
||||
return (
|
||||
<HeaderButton onClick={() => openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const UniversalCreateQueryGate = () => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const handled = useRef(false);
|
||||
const shouldOpen = searchParams.get("create") === "true";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldOpen) {
|
||||
handled.current = false;
|
||||
return;
|
||||
}
|
||||
if (handled.current) return;
|
||||
|
||||
handled.current = true;
|
||||
openModal({ entryPoint: "spotlight" });
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("create");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [openModal, pathname, router, searchParams, shouldOpen]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Group, ScrollArea, 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";
|
||||
@@ -28,28 +27,9 @@ interface IntegrationSelectModalProps {
|
||||
|
||||
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
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 [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? 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);
|
||||
@@ -60,7 +40,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
setStep("select");
|
||||
setSelectedKind(null);
|
||||
};
|
||||
const handleFormBack = isDirectForm ? actions.closeModal : handleBack;
|
||||
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
|
||||
|
||||
const handleSuccess = (result?: CreatedIntegrationResult) => {
|
||||
actions.closeModal();
|
||||
@@ -72,8 +52,12 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
};
|
||||
|
||||
if (step === "form" && selectedKind) {
|
||||
const HeaderIcon = isDirectForm ? IconX : IconArrowLeft;
|
||||
const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous");
|
||||
let HeaderIcon = IconArrowLeft;
|
||||
let headerLabel = t("common.action.previous");
|
||||
if (innerProps.initialKind) {
|
||||
HeaderIcon = IconX;
|
||||
headerLabel = t("common.action.close");
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea.Autosize mah="80vh">
|
||||
@@ -95,25 +79,11 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
);
|
||||
}
|
||||
|
||||
if (isIntegrationDataError) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("integration.grid.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationSelectGrid
|
||||
onSelect={handleSelect}
|
||||
enableMockIntegration={innerProps.enableMockIntegration}
|
||||
allowedKinds={innerProps.allowedKinds}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
);
|
||||
}).withOptions({
|
||||
|
||||
@@ -3,6 +3,8 @@ 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";
|
||||
@@ -36,6 +38,7 @@ 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">
|
||||
@@ -43,6 +46,8 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<UniversalCreateQueryGate />
|
||||
<UniversalCreateSpotlightResult />
|
||||
<LazySpotlight />
|
||||
</AppShellHeader>
|
||||
);
|
||||
|
||||
@@ -78,15 +78,11 @@ 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((url) => url.origin === baseUrl && url.pathname === "/", {
|
||||
waitUntil: "commit",
|
||||
timeout: 60_000,
|
||||
});
|
||||
const signedIn = page.waitForURL(baseUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await signedIn;
|
||||
|
||||
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
||||
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator("[data-homarr-dev-benchmark-board]")).toBeVisible({ timeout: 30_000 });
|
||||
const clockWidget = page.locator(
|
||||
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
||||
);
|
||||
|
||||
+2
-3
@@ -30,9 +30,8 @@
|
||||
"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 -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:cli": "go -C tools/homarr-dev run .",
|
||||
"dev:cli:install": "go -C tools/homarr-dev install .",
|
||||
"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,7 +12,6 @@ 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";
|
||||
@@ -36,7 +35,6 @@ export const mcpRouter = createTRPCRouter({
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
airQuality: airQualityRouter,
|
||||
bazarr: bazarrRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { airQualityRequestHandler } from "@homarr/request-handler/air-quality";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const atLocationInput = z.object({
|
||||
longitude: z.number().min(-180).max(180),
|
||||
latitude: z.number().min(-90).max(90),
|
||||
});
|
||||
|
||||
export const airQualityRouter = createTRPCRouter({
|
||||
atLocation: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get current and forecast air quality, UV, pollutant, and pollen data for geographic coordinates. REQUIRED: latitude (-90 to 90) and longitude (-180 to 180).",
|
||||
},
|
||||
})
|
||||
.input(atLocationInput)
|
||||
.query(async ({ input }) => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||
|
||||
const handler = airQualityRequestHandler.handler(input);
|
||||
return await handler.getDataAsync().then((result) => result.data);
|
||||
}),
|
||||
});
|
||||
@@ -13,7 +13,6 @@ export const widgetRouter = createTRPCRouter({
|
||||
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
|
||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
||||
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
|
||||
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
|
||||
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
|
||||
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
|
||||
|
||||
@@ -6,8 +6,8 @@ import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const atLocationInput = z.object({
|
||||
longitude: z.number().min(-180).max(180),
|
||||
latitude: z.number().min(-90).max(90),
|
||||
longitude: z.number(),
|
||||
latitude: z.number(),
|
||||
});
|
||||
|
||||
export const weatherRouter = createTRPCRouter({
|
||||
|
||||
@@ -9,7 +9,6 @@ vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
const MCP_TOOL_ALLOWLIST = {
|
||||
query: [
|
||||
"airQuality_atLocation",
|
||||
"apiKeys_getAll",
|
||||
"app_all",
|
||||
"app_byId",
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { command, number, positional, string } from "@drizzle-team/brocli";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const environmentSeparator = "\u0000";
|
||||
const validEnvironmentName = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const validHarnessName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
|
||||
|
||||
const demoEnvironment: ReadonlyArray<[string, string]> = [
|
||||
["DEMO_MODE", "true"],
|
||||
["DEMO_READ_ONLY", "false"],
|
||||
["UNSAFE_ENABLE_MOCK_INTEGRATION", "true"],
|
||||
];
|
||||
|
||||
type HarnessRuntimeOptions = {
|
||||
name: string;
|
||||
env?: string;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
const runtimeOptions = () => ({
|
||||
name: positional("name").required().desc("Name used for the image, container, and data volume"),
|
||||
env: string("env").alias("e").desc("Environment override KEY=VALUE; repeat the flag for more values"),
|
||||
port: number("port").int().min(0).max(65535).desc("Host port, or 0 for an automatically assigned port"),
|
||||
});
|
||||
|
||||
function validateHarnessName(name: string): string {
|
||||
if (!validHarnessName.test(name)) {
|
||||
throw new Error("Harness name must be 1-63 characters and contain only letters, numbers, '.', '_' or '-'");
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
function imageName(name: string): string {
|
||||
return `homarr:${validateHarnessName(name)}`;
|
||||
}
|
||||
|
||||
function containerName(name: string): string {
|
||||
return `homarr-harness-${validateHarnessName(name)}`;
|
||||
}
|
||||
|
||||
function volumeName(name: string): string {
|
||||
return `homarr-harness-${validateHarnessName(name)}-data`;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function commandError(commandName: string, error: unknown): Error {
|
||||
if (hasErrorCode(error, "ENOENT")) {
|
||||
return new Error(`${commandName} is required but was not found on PATH`);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new Error(`${commandName} failed: ${error.message}`);
|
||||
}
|
||||
|
||||
return new Error(`${commandName} failed`);
|
||||
}
|
||||
|
||||
async function execCommand(commandName: string, args: string[], cwd?: string): Promise<string> {
|
||||
try {
|
||||
const result = await execFileAsync(commandName, args, {
|
||||
cwd,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
} catch (error) {
|
||||
throw commandError(commandName, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(commandName: string, args: string[], cwd?: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(commandName, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.once("error", (error) => reject(commandError(commandName, error)));
|
||||
child.once("close", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
|
||||
reject(new Error(`${commandName} failed with ${reason}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function repositoryRoot(): Promise<string> {
|
||||
const root = await execCommand("git", ["rev-parse", "--show-toplevel"], process.cwd());
|
||||
|
||||
try {
|
||||
await access(join(root, "Dockerfile"), constants.R_OK);
|
||||
} catch {
|
||||
throw new Error("Run the harness command from a Homarr checkout containing a Dockerfile");
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function parseEnvironmentOverrides(raw?: string): Map<string, string> {
|
||||
const values = raw?.split(environmentSeparator) ?? [];
|
||||
const environment = new Map(demoEnvironment);
|
||||
|
||||
for (const value of values) {
|
||||
const separator = value.indexOf("=");
|
||||
if (separator <= 0) {
|
||||
throw new Error(`Invalid environment override ${JSON.stringify(value)}; use KEY=VALUE`);
|
||||
}
|
||||
|
||||
const name = value.slice(0, separator);
|
||||
if (!validEnvironmentName.test(name)) {
|
||||
throw new Error(`Invalid environment variable name ${JSON.stringify(name)}`);
|
||||
}
|
||||
|
||||
environment.set(name, value.slice(separator + 1));
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
function isEncryptionKey(value: string | undefined): value is string {
|
||||
return value !== undefined && /^[a-f0-9]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
async function readEncryptionKey(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
const value = (await readFile(path, "utf8")).trim();
|
||||
if (isEncryptionKey(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid encryption key in ${path}`);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function harnessEncryptionKey(name: string): Promise<string> {
|
||||
if (isEncryptionKey(process.env.SECRET_ENCRYPTION_KEY)) {
|
||||
return process.env.SECRET_ENCRYPTION_KEY;
|
||||
}
|
||||
|
||||
const configRoot = process.env.HOMARR_HARNESS_CONFIG_DIR ?? join(homedir(), ".config", "homarr");
|
||||
const keyPath = join(configRoot, "local-harness", `${validateHarnessName(name)}.key`);
|
||||
const existingKey = await readEncryptionKey(keyPath);
|
||||
if (existingKey) {
|
||||
return existingKey;
|
||||
}
|
||||
|
||||
const newKey = randomBytes(32).toString("hex");
|
||||
await mkdir(join(configRoot, "local-harness"), { recursive: true, mode: 0o700 });
|
||||
|
||||
try {
|
||||
await writeFile(keyPath, `${newKey}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
return newKey;
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EEXIST")) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const racedKey = await readEncryptionKey(keyPath);
|
||||
if (racedKey) {
|
||||
return racedKey;
|
||||
}
|
||||
|
||||
throw new Error(`Could not read the harness encryption key at ${keyPath}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function harnessEnvironment(name: string, raw?: string): Promise<string[]> {
|
||||
const environment = parseEnvironmentOverrides(raw);
|
||||
|
||||
if (!environment.has("SECRET_ENCRYPTION_KEY")) {
|
||||
environment.set("SECRET_ENCRYPTION_KEY", await harnessEncryptionKey(name));
|
||||
}
|
||||
|
||||
return Array.from(environment, ([key, value]) => `${key}=${value}`);
|
||||
}
|
||||
|
||||
async function buildHarnessImage(name: string): Promise<void> {
|
||||
const root = await repositoryRoot();
|
||||
const image = imageName(name);
|
||||
|
||||
console.log(`Building ${image} from ${root}`);
|
||||
await runCommand("docker", ["build", "--tag", image, "."], root);
|
||||
console.log(`Built ${image}`);
|
||||
}
|
||||
|
||||
async function removeExistingContainer(name: string): Promise<void> {
|
||||
try {
|
||||
await execCommand("docker", ["rm", "--force", containerName(name)]);
|
||||
} catch {
|
||||
// A missing container is the normal first-run path. Docker reports it as
|
||||
// an error, so the cleanup is intentionally best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
async function publishedPort(name: string): Promise<number> {
|
||||
const output = await execCommand("docker", ["port", containerName(name), "7575/tcp"]);
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const match = line.trim().match(/:(\d+)$/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Container ${containerName(name)} has no published port for 7575/tcp`);
|
||||
}
|
||||
|
||||
async function startHarness(options: HarnessRuntimeOptions): Promise<void> {
|
||||
const name = validateHarnessName(options.name);
|
||||
const image = imageName(name);
|
||||
const container = containerName(name);
|
||||
const requestedPort = options.port ?? 0;
|
||||
const environment = await harnessEnvironment(name, options.env);
|
||||
|
||||
await removeExistingContainer(name);
|
||||
console.log(`Starting ${container} from ${image}`);
|
||||
await runCommand("docker", [
|
||||
"run",
|
||||
"--detach",
|
||||
"--name",
|
||||
container,
|
||||
"--publish",
|
||||
`${requestedPort}:7575`,
|
||||
"--volume",
|
||||
`${volumeName(name)}:/appdata`,
|
||||
...environment.flatMap((value) => ["--env", value]),
|
||||
image,
|
||||
]);
|
||||
|
||||
const port = await publishedPort(name);
|
||||
console.log(`Harness ${name} is running`);
|
||||
console.log(` Container : ${container}`);
|
||||
console.log(` Image : ${image}`);
|
||||
console.log(` Port : ${port}`);
|
||||
console.log(` URL : http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
export const normalizeHarnessEnvironmentArgs = (argv: string[]): string[] => {
|
||||
const normalized = argv.slice(0, 2);
|
||||
const args = argv.slice(2);
|
||||
const environmentValues: string[] = [];
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--env" || arg === "-e") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined) {
|
||||
normalized.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
environmentValues.push(value);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--env=")) {
|
||||
environmentValues.push(arg.slice("--env=".length));
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.push(arg);
|
||||
}
|
||||
|
||||
if (environmentValues.length > 0) {
|
||||
normalized.push(`--env=${environmentValues.join(environmentSeparator)}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const harnessBuild = command({
|
||||
name: "build",
|
||||
desc: "Build the current checkout as a named local Homarr image",
|
||||
options: {
|
||||
name: positional("name").required().desc("Local image tag"),
|
||||
},
|
||||
handler: async ({ name }) => buildHarnessImage(name),
|
||||
});
|
||||
|
||||
const harnessRun = command({
|
||||
name: "run",
|
||||
aliases: ["start", "up"],
|
||||
desc: "Run a named local image in writable demo mode",
|
||||
options: runtimeOptions(),
|
||||
handler: async (options) => startHarness(options),
|
||||
});
|
||||
|
||||
const harnessSetup = command({
|
||||
name: "setup",
|
||||
aliases: ["create"],
|
||||
desc: "Build the current checkout and run its writable demo harness",
|
||||
options: runtimeOptions(),
|
||||
handler: async (options) => {
|
||||
await buildHarnessImage(options.name);
|
||||
await startHarness(options);
|
||||
},
|
||||
});
|
||||
|
||||
const harnessPort = command({
|
||||
name: "port",
|
||||
desc: "Show the published host port and URL for a harness",
|
||||
options: {
|
||||
name: positional("name").required().desc("Harness name"),
|
||||
},
|
||||
handler: async ({ name }) => {
|
||||
const port = await publishedPort(name);
|
||||
console.log(`Port: ${port}`);
|
||||
console.log(`URL: http://127.0.0.1:${port}`);
|
||||
},
|
||||
});
|
||||
|
||||
const harnessStop = command({
|
||||
name: "stop",
|
||||
aliases: ["down"],
|
||||
desc: "Stop a local harness container and keep its data volume",
|
||||
options: {
|
||||
name: positional("name").required().desc("Harness name"),
|
||||
},
|
||||
handler: async ({ name }) => {
|
||||
const container = containerName(name);
|
||||
await execCommand("docker", ["stop", container]);
|
||||
console.log(`Stopped ${container}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const harnessRoot = command({
|
||||
name: "harness",
|
||||
aliases: ["local-harness"],
|
||||
desc: "Build and run a local branch in a browser-ready demo harness",
|
||||
subcommands: [harnessBuild, harnessRun, harnessSetup, harnessPort, harnessStop],
|
||||
});
|
||||
+10
-38
@@ -1,42 +1,14 @@
|
||||
import { run } from "@drizzle-team/brocli";
|
||||
import type { Command } from "@drizzle-team/brocli";
|
||||
|
||||
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness";
|
||||
import { fixUsernames } from "./commands/fix-usernames";
|
||||
import { integrationsRoot } from "./commands/integrations";
|
||||
import { recreateAdmin } from "./commands/recreate-admin";
|
||||
import { resetPassword } from "./commands/reset-password";
|
||||
import { usersRoot } from "./commands/users";
|
||||
|
||||
const harnessCommandNames = new Set(["harness", "local-harness"]);
|
||||
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
|
||||
|
||||
function isHarnessInvocation(argv: string[]): boolean {
|
||||
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const harnessInvocation = isHarnessInvocation(process.argv);
|
||||
let commands: Command[];
|
||||
|
||||
if (harnessInvocation) {
|
||||
commands = [harnessRoot];
|
||||
} else {
|
||||
const [{ fixUsernames }, { integrationsRoot }, { recreateAdmin }, { resetPassword }, { usersRoot }] =
|
||||
await Promise.all([
|
||||
import("./commands/fix-usernames"),
|
||||
import("./commands/integrations"),
|
||||
import("./commands/recreate-admin"),
|
||||
import("./commands/reset-password"),
|
||||
import("./commands/users"),
|
||||
]);
|
||||
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot];
|
||||
}
|
||||
|
||||
let argSource = process.argv;
|
||||
if (harnessInvocation) {
|
||||
argSource = normalizeHarnessEnvironmentArgs(process.argv);
|
||||
}
|
||||
|
||||
await run(commands, {
|
||||
argSource,
|
||||
name: "homarr-cli",
|
||||
version: "1.0.0",
|
||||
});
|
||||
}
|
||||
|
||||
void main();
|
||||
void run(commands, {
|
||||
name: "homarr-cli",
|
||||
version: "1.0.0",
|
||||
});
|
||||
@@ -336,9 +336,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/air-quality"
|
||||
| "/docs/widgets/countdown"
|
||||
| "/docs/widgets/timer"
|
||||
| "/docs/widgets/app"
|
||||
| "/docs/widgets/iframe"
|
||||
| "/docs/widgets/video"
|
||||
|
||||
@@ -3,9 +3,6 @@ import type { WidgetKind } from "../widget";
|
||||
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
clock: "clock",
|
||||
weather: "weather",
|
||||
airQuality: "air-quality",
|
||||
countdown: "countdown",
|
||||
timer: "timer",
|
||||
app: "app",
|
||||
iframe: "iframe",
|
||||
video: "video",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
export const widgetKinds = [
|
||||
"clock",
|
||||
"weather",
|
||||
"airQuality",
|
||||
"countdown",
|
||||
"timer",
|
||||
"app",
|
||||
"iframe",
|
||||
"video",
|
||||
@@ -62,9 +59,6 @@ export const widgetKinds = [
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
|
||||
airQuality: { width: 2, height: 1 },
|
||||
countdown: { width: 2, height: 1 },
|
||||
timer: { width: 2, height: 1 },
|
||||
uptimeKuma: { width: 2, height: 3 },
|
||||
audioStats: { width: 2, height: 2 },
|
||||
paperlessNgx: { width: 2, height: 2 },
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AppSelectModal } from "./app-select-modal";
|
||||
|
||||
const AppSelectModalComponent = AppSelectModal.component;
|
||||
|
||||
const sampleApps = [
|
||||
{
|
||||
id: "app-1",
|
||||
name: "Sonarr",
|
||||
description: "Manage TV series downloads",
|
||||
iconUrl: "https://example.com/sonarr.png",
|
||||
href: "http://sonarr.local",
|
||||
},
|
||||
{
|
||||
id: "app-2",
|
||||
name: "Radarr",
|
||||
description: "Manage movie collections",
|
||||
iconUrl: "https://example.com/radarr.png",
|
||||
href: "http://radarr.local",
|
||||
},
|
||||
{
|
||||
id: "app-3",
|
||||
name: "Plex",
|
||||
description: "Stream your media collection",
|
||||
iconUrl: "https://example.com/plex.png",
|
||||
href: "http://plex.local",
|
||||
},
|
||||
];
|
||||
|
||||
const mockUseQuery = vi.fn(() => ({
|
||||
data: sampleApps,
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const mockOpenQuickAddAppModal = vi.fn();
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: {
|
||||
app: {
|
||||
selectable: {
|
||||
useQuery: () => mockUseQuery(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/modals", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("@homarr/modals");
|
||||
return {
|
||||
...actual,
|
||||
useModalAction: () => ({
|
||||
openModal: mockOpenQuickAddAppModal,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string, values?: { count?: number }) => {
|
||||
const count = values?.count ?? 0;
|
||||
const translations: Record<string, string> = {
|
||||
"app.action.select.search": "Search apps",
|
||||
"app.action.select.title": "Select application",
|
||||
"app.action.select.selected": "Selected",
|
||||
"app.action.select.toggle": "Click to select",
|
||||
"app.action.select.noResults": "No apps found",
|
||||
"app.action.select.multiSelectTip": "Hold Ctrl or Command to select multiple apps",
|
||||
"app.action.select.selectedCount": `${count} selected`,
|
||||
"app.action.select.appsSelected": `${count} ${count === 1 ? "app" : "apps"} selected`,
|
||||
"app.action.select.customApplication": "Custom application",
|
||||
"app.action.select.application": "Application",
|
||||
"app.action.create.title": "Add custom app",
|
||||
"app.action.create.description": "Configure custom URL and icon",
|
||||
"common.action.discard": "Discard",
|
||||
"common.action.add": "Add",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
vi.clearAllMocks();
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: sampleApps,
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("AppSelectModal", () => {
|
||||
describe("single selection mode", () => {
|
||||
it("renders app cards with name, description, and icon", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
expect(host.textContent).toContain("Radarr");
|
||||
expect(host.textContent).toContain("Plex");
|
||||
expect(host.textContent).toContain("Manage TV series downloads");
|
||||
});
|
||||
|
||||
it("selects an app and closes modal when card is clicked", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
expect(sonarrCard).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi selection mode", () => {
|
||||
it("toggles multiple app selections and submits batch on button click", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelectMany = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
const radarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Radarr"));
|
||||
|
||||
// Select Sonarr
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
|
||||
// Modal stays open, 1 app selected
|
||||
expect(closeModal).not.toHaveBeenCalled();
|
||||
expect(host.textContent).toContain("1 app selected");
|
||||
|
||||
// Select Radarr
|
||||
await act(async () => {
|
||||
radarrCard?.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("2 apps selected");
|
||||
|
||||
// Submit multi select
|
||||
const submitBtn = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Add (2)"));
|
||||
expect(submitBtn).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
submitBtn?.click();
|
||||
});
|
||||
|
||||
expect(onSelectMany).toHaveBeenCalledWith([sampleApps[0], sampleApps[1]]);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("unselects an app when clicked a second time", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelectMany = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
|
||||
// Select Sonarr
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
expect(host.textContent).toContain("1 app selected");
|
||||
|
||||
// Unselect Sonarr
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
expect(host.textContent).not.toContain("1 app selected");
|
||||
});
|
||||
|
||||
it("clears selection when Discard button is clicked", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelectMany = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
expect(host.textContent).toContain("1 app selected");
|
||||
|
||||
const discardBtn = Array.from(host.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.includes("Discard"),
|
||||
);
|
||||
await act(async () => {
|
||||
discardBtn?.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain("app selected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search and filtering", () => {
|
||||
it("filters apps in real-time by search query (case-insensitive)", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
expect(searchInput).not.toBeNull();
|
||||
|
||||
// Type "plex"
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "plex");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("Plex");
|
||||
expect(host.textContent).not.toContain("Sonarr");
|
||||
expect(host.textContent).not.toContain("Radarr");
|
||||
});
|
||||
|
||||
it("selects the single matching app when Enter key is pressed in search input", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "sonarr");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("displays empty state message when search yields no results", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "non-matching-query");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("No apps found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom app creation card", () => {
|
||||
it("renders custom app creation card when withCreate is true", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Add custom app");
|
||||
expect(host.textContent).toContain("Configure custom URL and icon");
|
||||
});
|
||||
|
||||
it("omits custom app creation card when withCreate is false", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).not.toContain("Add custom app");
|
||||
});
|
||||
|
||||
it("opens QuickAddAppModal when custom app creation card is clicked in single select mode", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const createCard = Array.from(host.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.includes("Add custom app"),
|
||||
);
|
||||
expect(createCard).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
createCard?.click();
|
||||
});
|
||||
|
||||
expect(mockOpenQuickAddAppModal).toHaveBeenCalledOnce();
|
||||
|
||||
// Trigger the onClose callback passed to QuickAddAppModal
|
||||
const options = mockOpenQuickAddAppModal.mock.calls[0]?.[0] as { onClose: (app: (typeof sampleApps)[0]) => void };
|
||||
const newCustomApp = {
|
||||
id: "custom-app-1",
|
||||
name: "Custom Home Assistant",
|
||||
description: "Local instance",
|
||||
iconUrl: "https://example.com/ha.png",
|
||||
href: "http://ha.local",
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
options.onClose(newCustomApp);
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(newCustomApp);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Input,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { selectGridCols, SelectableCard } from "@homarr/ui";
|
||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
@@ -49,15 +36,7 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
|
||||
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
|
||||
|
||||
const handleSelect = (app: SelectableApp, event?: React.MouseEvent) => {
|
||||
const isModifierPressed = multiSelect && Boolean(event?.shiftKey || event?.ctrlKey || event?.metaKey);
|
||||
|
||||
if (innerProps.onSelect && !isModifierPressed && selectedAppIds.size === 0) {
|
||||
innerProps.onSelect(app);
|
||||
actions.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSelect = (app: SelectableApp) => {
|
||||
if (multiSelect) {
|
||||
setSelectedAppIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -65,6 +44,22 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
else next.add(app.id);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
innerProps.onSelect?.(app);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (multiSelect) {
|
||||
innerProps.onSelectMany?.(selectedApps);
|
||||
actions.closeModal();
|
||||
return;
|
||||
}
|
||||
const [first] = filteredApps;
|
||||
if (first) {
|
||||
innerProps.onSelect?.(first);
|
||||
actions.closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,145 +76,100 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
});
|
||||
};
|
||||
|
||||
const handleMultiSubmit = () => {
|
||||
innerProps.onSelectMany?.(selectedApps);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Top Search Input & Multi-Select Tip */}
|
||||
<Stack gap={6}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
aria-label={t("app.action.select.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{multiSelect && (
|
||||
<Group justify="space-between" px={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("app.action.select.multiSelectTip")}
|
||||
</Text>
|
||||
{selectedAppIds.size > 0 && (
|
||||
<Badge variant="light" color="primaryColor" size="xs">
|
||||
{t("app.action.select.selectedCount", { count: selectedAppIds.size })}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
ariaLabel={t("app.action.select.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{innerProps.withCreate && (
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={t("app.action.create.title")}
|
||||
status={t("app.action.create.action")}
|
||||
onSelect={handleAddNewApp}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
<Text size="xs" c="blue" fw={500} mt="auto">
|
||||
{t("app.action.create.action")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Scrollable Container with App Cards */}
|
||||
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
|
||||
<Stack gap="md" pt="xs" pr="xs" px={4}>
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{innerProps.withCreate && (
|
||||
<SelectableCard
|
||||
onClick={handleAddNewApp}
|
||||
style={{ borderStyle: "dashed" }}
|
||||
icon={
|
||||
<ThemeIcon variant="light" color="primaryColor" size={34} radius="md">
|
||||
<IconPlus size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
title={t("app.action.create.title")}
|
||||
description={t("app.action.create.description")}
|
||||
footerLeft={
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("app.action.select.customApplication")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{filteredApps.map((app) => (
|
||||
<CatalogItem
|
||||
key={app.id}
|
||||
height={selectGridCardHeight}
|
||||
label={app.name}
|
||||
status={
|
||||
multiSelect
|
||||
? selectedAppIds.has(app.id)
|
||||
? t("app.action.select.selected")
|
||||
: t("app.action.select.toggle")
|
||||
: t("app.action.select.action", { app: app.name })
|
||||
}
|
||||
selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
|
||||
onSelect={() => handleSelect(app)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{app.name}
|
||||
</Text>
|
||||
{multiSelect && selectedAppIds.has(app.id) && (
|
||||
<IconCheck size={18} color="var(--mantine-primary-color-filled)" />
|
||||
)}
|
||||
</Group>
|
||||
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{app.description ?? ""}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text size="xs" c="blue" fw={500} mt="auto">
|
||||
{multiSelect
|
||||
? selectedAppIds.has(app.id)
|
||||
? t("app.action.select.selected")
|
||||
: t("app.action.select.toggle")
|
||||
: t("app.action.select.action", { app: app.name })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
))}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
isSelected={selectedAppIds.has(app.id)}
|
||||
multiSelect={multiSelect}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
{/* Multi-Select Action Footer */}
|
||||
{multiSelect && selectedApps.length > 0 && (
|
||||
<Paper withBorder p="xs" radius="md" bg="light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("app.action.select.appsSelected", { count: selectedApps.length })}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button variant="default" size="xs" onClick={() => setSelectedAppIds(new Set())}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button color="primaryColor" size="xs" onClick={handleMultiSubmit}>
|
||||
{t("common.action.add")} ({selectedApps.length})
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
{multiSelect && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button leftSection={<IconApps size={16} />} disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
|
||||
{t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// AppCard: Variant 3 (Dashboard Inset) with Medium Title & Large App Icon
|
||||
// =========================================================================
|
||||
const AppCard = ({
|
||||
app,
|
||||
isSelected,
|
||||
multiSelect,
|
||||
onSelect,
|
||||
}: {
|
||||
app: SelectableApp;
|
||||
isSelected: boolean;
|
||||
multiSelect: boolean;
|
||||
onSelect: (app: SelectableApp, event?: React.MouseEvent) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<SelectableCard
|
||||
onClick={(event) => onSelect(app, event)}
|
||||
aria-label={app.name}
|
||||
selected={isSelected}
|
||||
icon={<Image src={app.iconUrl} alt={app.name} w={28} h={28} fit="contain" style={{ flexShrink: 0 }} />}
|
||||
title={app.name}
|
||||
topRight={
|
||||
isSelected ? (
|
||||
<Badge variant="dot" color="primaryColor" size="xs">
|
||||
{t("app.action.select.selected")}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
description={app.description}
|
||||
footerLeft={
|
||||
<Text size="xs" c="dimmed">
|
||||
{multiSelect && isSelected ? t("app.action.select.selected") : t("app.action.select.application")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -102,13 +102,6 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
content: presentationClasses.inspectorContent,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
const canClose = modal.reference.onCloseRequest ? modal.reference.onCloseRequest() : true;
|
||||
if (canClose) {
|
||||
handleCloseModal();
|
||||
}
|
||||
}, [handleCloseModal, modal.reference]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
key={modal.id}
|
||||
@@ -130,7 +123,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
{...otherModalProps}
|
||||
title={translateIfNecessary(t, modal.props.defaultTitle)}
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
onClose={handleCloseModal}
|
||||
>
|
||||
{modal.reference.content}
|
||||
</Modal>
|
||||
@@ -151,6 +144,7 @@ export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) =>
|
||||
return {
|
||||
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
|
||||
// void actually is undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
context.openModalInner({ modal, innerProps, options: options ?? {} });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -94,8 +94,6 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
const ModalContent = modal.modal.component;
|
||||
|
||||
const { innerProps, ...rest } = modal.props;
|
||||
let closeInterceptor: (() => boolean) | null = null;
|
||||
|
||||
const FullModal = () => {
|
||||
const context = useContext(ModalContext);
|
||||
|
||||
@@ -108,9 +106,6 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
innerProps={innerProps}
|
||||
actions={{
|
||||
closeModal: () => context.closeModal(modal.id),
|
||||
setCloseInterceptor: (interceptor: (() => boolean) | null) => {
|
||||
closeInterceptor = interceptor;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -119,11 +114,5 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
return {
|
||||
modalProps: rest,
|
||||
content: <FullModal />,
|
||||
onCloseRequest: () => {
|
||||
if (closeInterceptor) {
|
||||
return closeInterceptor();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -5,12 +5,10 @@ import type { stringOrTranslation } from "@homarr/translation";
|
||||
|
||||
import type { ModalPresentation } from "./modal-presentation";
|
||||
|
||||
export interface ModalActions {
|
||||
closeModal: () => void;
|
||||
setCloseInterceptor?: (interceptor: (() => boolean) | null) => void;
|
||||
}
|
||||
|
||||
export type ModalComponent<TInnerProps> = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode;
|
||||
export type ModalComponent<TInnerProps> = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: TInnerProps;
|
||||
}) => ReactNode;
|
||||
|
||||
export type CreateModalOptions = Pick<
|
||||
ModalOptions<unknown>,
|
||||
|
||||
@@ -104,7 +104,7 @@ const CredentialsSetup = () => {
|
||||
|
||||
return (
|
||||
<AccountShell title={t("title")} description={t("description")}>
|
||||
<form onSubmit={form.onSubmit((values) => submitAsync(values))}>
|
||||
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
|
||||
<UserCreatePasswordFields
|
||||
@@ -119,7 +119,7 @@ const CredentialsSetup = () => {
|
||||
{submitError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" size="md" loading={form.submitting} rightSection={<IconArrowRight size={18} />}>
|
||||
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { airQualityRequestHandler } from "./air-quality";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const makeAirQualityResponse = () => {
|
||||
const times = ["2026-07-01T00:00", "2026-07-01T01:00", "2026-07-02T00:00"];
|
||||
const values = [10, 20, 30];
|
||||
const fields = [
|
||||
"european_aqi",
|
||||
"us_aqi",
|
||||
"uv_index",
|
||||
"pm10",
|
||||
"pm2_5",
|
||||
"carbon_monoxide",
|
||||
"nitrogen_dioxide",
|
||||
"sulphur_dioxide",
|
||||
"ozone",
|
||||
"european_aqi_pm2_5",
|
||||
"european_aqi_pm10",
|
||||
"european_aqi_nitrogen_dioxide",
|
||||
"european_aqi_ozone",
|
||||
"european_aqi_sulphur_dioxide",
|
||||
"us_aqi_pm2_5",
|
||||
"us_aqi_pm10",
|
||||
"us_aqi_nitrogen_dioxide",
|
||||
"us_aqi_carbon_monoxide",
|
||||
"us_aqi_ozone",
|
||||
"us_aqi_sulphur_dioxide",
|
||||
"alder_pollen",
|
||||
"birch_pollen",
|
||||
"grass_pollen",
|
||||
"mugwort_pollen",
|
||||
"olive_pollen",
|
||||
"ragweed_pollen",
|
||||
] as const;
|
||||
|
||||
return {
|
||||
timezone: "Europe/Berlin",
|
||||
utc_offset_seconds: 7200,
|
||||
current: {
|
||||
time: "2026-07-01T12:00",
|
||||
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? null : 12])),
|
||||
},
|
||||
hourly: {
|
||||
time: times,
|
||||
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? [null, null, null] : values])),
|
||||
},
|
||||
fields,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
airQualityRequestHandler.invalidateCache();
|
||||
});
|
||||
|
||||
describe("air quality request handler", () => {
|
||||
it("requests and normalizes the full useful forecast field set", async () => {
|
||||
const fixture = makeAirQualityResponse();
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(fixture)));
|
||||
|
||||
const first = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
const second = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
|
||||
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
|
||||
expect(first.data.current.pollutants.pm2_5).toBe(12);
|
||||
expect(first.data.current.pollen.ragweed).toBeNull();
|
||||
expect(first.data.hourly[0]?.observedAt).toBe("2026-06-30T22:00:00.000Z");
|
||||
expect(first.data.daily).toEqual([
|
||||
{ date: "2026-07-01", europeanAqiMax: 20, usAqiMax: 20, uvIndexMax: 20, pm2_5Max: 20, pm10Max: 20 },
|
||||
{ date: "2026-07-02", europeanAqiMax: 30, usAqiMax: 30, uvIndexMax: 30, pm2_5Max: 30, pm10Max: 30 },
|
||||
]);
|
||||
expect(second.data).toStrictEqual(first.data);
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
||||
expect(requestUrl.origin).toBe("https://air-quality-api.open-meteo.com");
|
||||
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
|
||||
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
|
||||
expect(requestUrl.searchParams.get("current")).toContain("european_aqi_pm2_5");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("us_aqi_carbon_monoxide");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("ragweed_pollen");
|
||||
});
|
||||
|
||||
it("rejects unsuccessful Open-Meteo responses", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
|
||||
|
||||
await expect(
|
||||
airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,283 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const FORECAST_DAYS = 7;
|
||||
|
||||
const requestedVariables = [
|
||||
"european_aqi",
|
||||
"us_aqi",
|
||||
"uv_index",
|
||||
"pm10",
|
||||
"pm2_5",
|
||||
"carbon_monoxide",
|
||||
"nitrogen_dioxide",
|
||||
"sulphur_dioxide",
|
||||
"ozone",
|
||||
"european_aqi_pm2_5",
|
||||
"european_aqi_pm10",
|
||||
"european_aqi_nitrogen_dioxide",
|
||||
"european_aqi_ozone",
|
||||
"european_aqi_sulphur_dioxide",
|
||||
"us_aqi_pm2_5",
|
||||
"us_aqi_pm10",
|
||||
"us_aqi_nitrogen_dioxide",
|
||||
"us_aqi_carbon_monoxide",
|
||||
"us_aqi_ozone",
|
||||
"us_aqi_sulphur_dioxide",
|
||||
"alder_pollen",
|
||||
"birch_pollen",
|
||||
"grass_pollen",
|
||||
"mugwort_pollen",
|
||||
"olive_pollen",
|
||||
"ragweed_pollen",
|
||||
] as const;
|
||||
|
||||
export const airQualityRequestHandler = createWidgetRequestHandler({
|
||||
cacheTtlMs: 15 * 60 * 1000,
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 24 * 60 * 60 * 1000,
|
||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||
const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
|
||||
url.searchParams.set("latitude", input.latitude.toString());
|
||||
url.searchParams.set("longitude", input.longitude.toString());
|
||||
url.searchParams.set("current", requestedVariables.join(","));
|
||||
url.searchParams.set("hourly", requestedVariables.join(","));
|
||||
url.searchParams.set("forecast_days", FORECAST_DAYS.toString());
|
||||
url.searchParams.set("timezone", "auto");
|
||||
|
||||
const response = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
});
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const json: unknown = await response.json();
|
||||
const airQuality = await atLocationOutput.parseAsync(json);
|
||||
const hourly = airQuality.hourly.time.map((value, index) => {
|
||||
return normalizePoint(airQuality.hourly, index, toUtcInstant(value, airQuality.timezone));
|
||||
});
|
||||
|
||||
return {
|
||||
timezone: airQuality.timezone,
|
||||
utcOffsetSeconds: airQuality.utc_offset_seconds,
|
||||
current: normalizePoint(
|
||||
airQuality.current,
|
||||
undefined,
|
||||
toUtcInstant(airQuality.current.time, airQuality.timezone),
|
||||
),
|
||||
hourly,
|
||||
daily: getDailyMaximums(airQuality.hourly),
|
||||
} satisfies AirQuality;
|
||||
},
|
||||
});
|
||||
|
||||
const nullableNumber = z.number().nullable();
|
||||
const nullableNumberArray = z.array(nullableNumber);
|
||||
|
||||
const currentValues = z.object({
|
||||
time: z.string(),
|
||||
european_aqi: nullableNumber,
|
||||
us_aqi: nullableNumber,
|
||||
uv_index: nullableNumber,
|
||||
pm10: nullableNumber,
|
||||
pm2_5: nullableNumber,
|
||||
carbon_monoxide: nullableNumber,
|
||||
nitrogen_dioxide: nullableNumber,
|
||||
sulphur_dioxide: nullableNumber,
|
||||
ozone: nullableNumber,
|
||||
european_aqi_pm2_5: nullableNumber,
|
||||
european_aqi_pm10: nullableNumber,
|
||||
european_aqi_nitrogen_dioxide: nullableNumber,
|
||||
european_aqi_ozone: nullableNumber,
|
||||
european_aqi_sulphur_dioxide: nullableNumber,
|
||||
us_aqi_pm2_5: nullableNumber,
|
||||
us_aqi_pm10: nullableNumber,
|
||||
us_aqi_nitrogen_dioxide: nullableNumber,
|
||||
us_aqi_carbon_monoxide: nullableNumber,
|
||||
us_aqi_ozone: nullableNumber,
|
||||
us_aqi_sulphur_dioxide: nullableNumber,
|
||||
alder_pollen: nullableNumber,
|
||||
birch_pollen: nullableNumber,
|
||||
grass_pollen: nullableNumber,
|
||||
mugwort_pollen: nullableNumber,
|
||||
olive_pollen: nullableNumber,
|
||||
ragweed_pollen: nullableNumber,
|
||||
});
|
||||
|
||||
const hourlyValues = z.object({
|
||||
time: z.array(z.string()),
|
||||
european_aqi: nullableNumberArray,
|
||||
us_aqi: nullableNumberArray,
|
||||
uv_index: nullableNumberArray,
|
||||
pm10: nullableNumberArray,
|
||||
pm2_5: nullableNumberArray,
|
||||
carbon_monoxide: nullableNumberArray,
|
||||
nitrogen_dioxide: nullableNumberArray,
|
||||
sulphur_dioxide: nullableNumberArray,
|
||||
ozone: nullableNumberArray,
|
||||
european_aqi_pm2_5: nullableNumberArray,
|
||||
european_aqi_pm10: nullableNumberArray,
|
||||
european_aqi_nitrogen_dioxide: nullableNumberArray,
|
||||
european_aqi_ozone: nullableNumberArray,
|
||||
european_aqi_sulphur_dioxide: nullableNumberArray,
|
||||
us_aqi_pm2_5: nullableNumberArray,
|
||||
us_aqi_pm10: nullableNumberArray,
|
||||
us_aqi_nitrogen_dioxide: nullableNumberArray,
|
||||
us_aqi_carbon_monoxide: nullableNumberArray,
|
||||
us_aqi_ozone: nullableNumberArray,
|
||||
us_aqi_sulphur_dioxide: nullableNumberArray,
|
||||
alder_pollen: nullableNumberArray,
|
||||
birch_pollen: nullableNumberArray,
|
||||
grass_pollen: nullableNumberArray,
|
||||
mugwort_pollen: nullableNumberArray,
|
||||
olive_pollen: nullableNumberArray,
|
||||
ragweed_pollen: nullableNumberArray,
|
||||
});
|
||||
|
||||
const atLocationOutput = z.object({
|
||||
timezone: z.string().min(1),
|
||||
utc_offset_seconds: z.number(),
|
||||
current: currentValues,
|
||||
hourly: hourlyValues,
|
||||
});
|
||||
|
||||
type CurrentValues = z.infer<typeof currentValues>;
|
||||
type HourlyValues = z.infer<typeof hourlyValues>;
|
||||
|
||||
const getValue = (values: CurrentValues | HourlyValues, field: keyof Omit<CurrentValues, "time">, index?: number) => {
|
||||
const value = values[field];
|
||||
if (Array.isArray(value)) return value[index ?? 0] ?? null;
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizePoint = (
|
||||
values: CurrentValues | HourlyValues,
|
||||
index: number | undefined,
|
||||
observedAt: string,
|
||||
): AirQualityPoint => ({
|
||||
observedAt,
|
||||
europeanAqi: getValue(values, "european_aqi", index),
|
||||
usAqi: getValue(values, "us_aqi", index),
|
||||
uvIndex: getValue(values, "uv_index", index),
|
||||
pollutants: {
|
||||
pm2_5: getValue(values, "pm2_5", index),
|
||||
pm10: getValue(values, "pm10", index),
|
||||
ozone: getValue(values, "ozone", index),
|
||||
nitrogenDioxide: getValue(values, "nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "sulphur_dioxide", index),
|
||||
carbonMonoxide: getValue(values, "carbon_monoxide", index),
|
||||
},
|
||||
europeanSubIndices: {
|
||||
pm2_5: getValue(values, "european_aqi_pm2_5", index),
|
||||
pm10: getValue(values, "european_aqi_pm10", index),
|
||||
ozone: getValue(values, "european_aqi_ozone", index),
|
||||
nitrogenDioxide: getValue(values, "european_aqi_nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "european_aqi_sulphur_dioxide", index),
|
||||
carbonMonoxide: null,
|
||||
},
|
||||
usSubIndices: {
|
||||
pm2_5: getValue(values, "us_aqi_pm2_5", index),
|
||||
pm10: getValue(values, "us_aqi_pm10", index),
|
||||
ozone: getValue(values, "us_aqi_ozone", index),
|
||||
nitrogenDioxide: getValue(values, "us_aqi_nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "us_aqi_sulphur_dioxide", index),
|
||||
carbonMonoxide: getValue(values, "us_aqi_carbon_monoxide", index),
|
||||
},
|
||||
pollen: {
|
||||
alder: getValue(values, "alder_pollen", index),
|
||||
birch: getValue(values, "birch_pollen", index),
|
||||
grass: getValue(values, "grass_pollen", index),
|
||||
mugwort: getValue(values, "mugwort_pollen", index),
|
||||
olive: getValue(values, "olive_pollen", index),
|
||||
ragweed: getValue(values, "ragweed_pollen", index),
|
||||
},
|
||||
});
|
||||
|
||||
const getDailyMaximums = (hourly: HourlyValues): AirQuality["daily"] => {
|
||||
const grouped = new Map<string, AirQuality["daily"][number]>();
|
||||
|
||||
hourly.time.forEach((time, index) => {
|
||||
const date = time.slice(0, 10);
|
||||
const existing = grouped.get(date) ?? {
|
||||
date,
|
||||
europeanAqiMax: null,
|
||||
usAqiMax: null,
|
||||
uvIndexMax: null,
|
||||
pm2_5Max: null,
|
||||
pm10Max: null,
|
||||
};
|
||||
|
||||
existing.europeanAqiMax = maxNullable(existing.europeanAqiMax, hourly.european_aqi[index]);
|
||||
existing.usAqiMax = maxNullable(existing.usAqiMax, hourly.us_aqi[index]);
|
||||
existing.uvIndexMax = maxNullable(existing.uvIndexMax, hourly.uv_index[index]);
|
||||
existing.pm2_5Max = maxNullable(existing.pm2_5Max, hourly.pm2_5[index]);
|
||||
existing.pm10Max = maxNullable(existing.pm10Max, hourly.pm10[index]);
|
||||
grouped.set(date, existing);
|
||||
});
|
||||
|
||||
return Array.from(grouped.values()).slice(0, FORECAST_DAYS);
|
||||
};
|
||||
|
||||
const maxNullable = (current: number | null, candidate: number | null | undefined): number | null => {
|
||||
if (candidate === null || candidate === undefined) return current;
|
||||
if (current === null) return candidate;
|
||||
return Math.max(current, candidate);
|
||||
};
|
||||
|
||||
const toUtcInstant = (value: string, timeZone: string): string => {
|
||||
const parsed = dayjs.tz(value, timeZone);
|
||||
if (!parsed.isValid()) throw new Error(`Invalid air quality timestamp: ${value}`);
|
||||
return parsed.utc().toISOString();
|
||||
};
|
||||
|
||||
export interface AirQualityPollutants {
|
||||
pm2_5: number | null;
|
||||
pm10: number | null;
|
||||
ozone: number | null;
|
||||
nitrogenDioxide: number | null;
|
||||
sulphurDioxide: number | null;
|
||||
carbonMonoxide: number | null;
|
||||
}
|
||||
|
||||
export interface AirQualityPoint {
|
||||
observedAt: string;
|
||||
europeanAqi: number | null;
|
||||
usAqi: number | null;
|
||||
uvIndex: number | null;
|
||||
pollutants: AirQualityPollutants;
|
||||
europeanSubIndices: AirQualityPollutants;
|
||||
usSubIndices: AirQualityPollutants;
|
||||
pollen: {
|
||||
alder: number | null;
|
||||
birch: number | null;
|
||||
grass: number | null;
|
||||
mugwort: number | null;
|
||||
olive: number | null;
|
||||
ragweed: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AirQuality {
|
||||
timezone: string;
|
||||
utcOffsetSeconds: number;
|
||||
current: AirQualityPoint;
|
||||
hourly: AirQualityPoint[];
|
||||
daily: {
|
||||
date: string;
|
||||
europeanAqiMax: number | null;
|
||||
usAqiMax: number | null;
|
||||
uvIndexMax: number | null;
|
||||
pm2_5Max: number | null;
|
||||
pm10Max: number | null;
|
||||
}[];
|
||||
}
|
||||
@@ -2,14 +2,11 @@ import { createRequestHandler } from "./request-handler";
|
||||
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
}
|
||||
|
||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const inner = createRequestHandler<TData, TInput>(options);
|
||||
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
|
||||
return inner;
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { weatherRequestHandler } from "./weather";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const makeWeatherResponse = () => {
|
||||
const hours = Array.from({ length: 25 }, (_, index) => `2026-07-01T${String(index % 24).padStart(2, "0")}:00`);
|
||||
const days = [
|
||||
"2026-06-30",
|
||||
...Array.from({ length: 7 }, (_, index) => `2026-07-${String(index + 1).padStart(2, "0")}`),
|
||||
];
|
||||
const hourlyValues = hours.map<number | null>((_, index) => index);
|
||||
const dailyValues = days.map<number | null>((_, index) => index);
|
||||
const sunrise = days.map<string | null>((day) => `${day}T05:00`);
|
||||
|
||||
return {
|
||||
timezone: "Europe/Berlin",
|
||||
utc_offset_seconds: 7200,
|
||||
current: {
|
||||
time: "2026-07-01T12:00",
|
||||
temperature_2m: 24,
|
||||
relative_humidity_2m: 55,
|
||||
apparent_temperature: 25,
|
||||
is_day: 1,
|
||||
precipitation: 0,
|
||||
weather_code: 1,
|
||||
wind_speed_10m: 12,
|
||||
wind_gusts_10m: 20,
|
||||
},
|
||||
hourly: {
|
||||
time: hours,
|
||||
temperature_2m: hourlyValues,
|
||||
apparent_temperature: hourlyValues,
|
||||
precipitation_probability: hourlyValues,
|
||||
precipitation: hourlyValues,
|
||||
weather_code: hourlyValues,
|
||||
relative_humidity_2m: hourlyValues,
|
||||
wind_speed_10m: hourlyValues,
|
||||
wind_gusts_10m: hourlyValues,
|
||||
},
|
||||
daily: {
|
||||
time: days,
|
||||
weather_code: dailyValues,
|
||||
temperature_2m_max: dailyValues,
|
||||
temperature_2m_min: dailyValues,
|
||||
apparent_temperature_max: dailyValues,
|
||||
apparent_temperature_min: dailyValues,
|
||||
precipitation_probability_max: dailyValues,
|
||||
precipitation_sum: dailyValues,
|
||||
relative_humidity_2m_mean: dailyValues,
|
||||
wind_speed_10m_max: dailyValues,
|
||||
wind_gusts_10m_max: dailyValues,
|
||||
uv_index_max: dailyValues,
|
||||
sunrise,
|
||||
sunset: days.map((day) => `${day}T21:00`),
|
||||
daylight_duration: dailyValues,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
weatherRequestHandler.invalidateCache();
|
||||
});
|
||||
|
||||
describe("weather request handler", () => {
|
||||
it("requests bounded useful forecasts and normalizes location times", async () => {
|
||||
const response = makeWeatherResponse();
|
||||
response.hourly.temperature_2m = [null];
|
||||
Reflect.deleteProperty(response.hourly, "wind_gusts_10m");
|
||||
response.daily.sunrise[1] = null;
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(response)));
|
||||
|
||||
const first = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
const second = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
|
||||
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
|
||||
expect(first.data.hourly).toHaveLength(24);
|
||||
expect(first.data.daily).toHaveLength(7);
|
||||
expect(first.data.daily[0]?.date).toBe("2026-07-01");
|
||||
expect(first.data.hourly[0]?.temperature).toBeNull();
|
||||
expect(first.data.hourly[1]?.temperature).toBeNull();
|
||||
expect(first.data.hourly[0]?.windGusts).toBeNull();
|
||||
expect(first.data.daily[0]?.sunriseAt).toBeNull();
|
||||
expect(first.data.daily[1]?.sunriseAt).toBe("2026-07-02T03:00:00.000Z");
|
||||
expect(second.data).toStrictEqual(first.data);
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
||||
expect(requestUrl.searchParams.get("forecast_hours")).toBe("24");
|
||||
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
|
||||
expect(requestUrl.searchParams.has("past_days")).toBe(false);
|
||||
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
|
||||
expect(requestUrl.searchParams.get("current")).toContain("apparent_temperature");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("precipitation_probability");
|
||||
expect(requestUrl.searchParams.get("daily")).toContain("uv_index_max");
|
||||
});
|
||||
|
||||
it("rejects unsuccessful Open-Meteo responses", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
|
||||
|
||||
await expect(weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,255 +1,73 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const HOURLY_FORECAST_LENGTH = 24;
|
||||
const DAILY_FORECAST_LENGTH = 7;
|
||||
|
||||
const requestWeatherAsync = async (input: { latitude: number; longitude: number }): Promise<Weather> => {
|
||||
const url = new URL("https://api.open-meteo.com/v1/forecast");
|
||||
url.searchParams.set("latitude", input.latitude.toString());
|
||||
url.searchParams.set("longitude", input.longitude.toString());
|
||||
url.searchParams.set(
|
||||
"current",
|
||||
[
|
||||
"temperature_2m",
|
||||
"relative_humidity_2m",
|
||||
"apparent_temperature",
|
||||
"is_day",
|
||||
"precipitation",
|
||||
"weather_code",
|
||||
"wind_speed_10m",
|
||||
"wind_gusts_10m",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set(
|
||||
"hourly",
|
||||
[
|
||||
"temperature_2m",
|
||||
"apparent_temperature",
|
||||
"precipitation_probability",
|
||||
"precipitation",
|
||||
"weather_code",
|
||||
"relative_humidity_2m",
|
||||
"wind_speed_10m",
|
||||
"wind_gusts_10m",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set(
|
||||
"daily",
|
||||
[
|
||||
"weather_code",
|
||||
"temperature_2m_max",
|
||||
"temperature_2m_min",
|
||||
"apparent_temperature_max",
|
||||
"apparent_temperature_min",
|
||||
"precipitation_probability_max",
|
||||
"precipitation_sum",
|
||||
"relative_humidity_2m_mean",
|
||||
"wind_speed_10m_max",
|
||||
"wind_gusts_10m_max",
|
||||
"uv_index_max",
|
||||
"sunrise",
|
||||
"sunset",
|
||||
"daylight_duration",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set("timezone", "auto");
|
||||
url.searchParams.set("forecast_hours", HOURLY_FORECAST_LENGTH.toString());
|
||||
url.searchParams.set("forecast_days", DAILY_FORECAST_LENGTH.toString());
|
||||
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
});
|
||||
if (!res.ok) throw new ResponseError(res);
|
||||
|
||||
const json: unknown = await res.json();
|
||||
const weather = await atLocationOutput.parseAsync(json);
|
||||
const currentLocalDate = weather.current.time.slice(0, 10);
|
||||
const matchedCurrentDayIndex = weather.daily.time.indexOf(currentLocalDate);
|
||||
const currentDayIndex = matchedCurrentDayIndex < 0 ? 0 : matchedCurrentDayIndex;
|
||||
const dailyIndexes = weather.daily.time
|
||||
.map((_, index) => index)
|
||||
.slice(currentDayIndex, currentDayIndex + DAILY_FORECAST_LENGTH);
|
||||
return {
|
||||
timezone: weather.timezone,
|
||||
utcOffsetSeconds: weather.utc_offset_seconds,
|
||||
current: {
|
||||
observedAt: toUtcInstant(weather.current.time, weather.timezone),
|
||||
temperature: weather.current.temperature_2m,
|
||||
apparentTemperature: weather.current.apparent_temperature,
|
||||
relativeHumidity: weather.current.relative_humidity_2m,
|
||||
precipitation: weather.current.precipitation,
|
||||
weatherCode: weather.current.weather_code,
|
||||
weathercode: weather.current.weather_code,
|
||||
windSpeed: weather.current.wind_speed_10m,
|
||||
windspeed: weather.current.wind_speed_10m,
|
||||
windGusts: weather.current.wind_gusts_10m,
|
||||
isDay: weather.current.is_day === 1,
|
||||
},
|
||||
hourly: weather.hourly.time.slice(0, HOURLY_FORECAST_LENGTH).map((value, index) => {
|
||||
return {
|
||||
observedAt: toUtcInstant(value, weather.timezone),
|
||||
temperature: weather.hourly.temperature_2m[index] ?? null,
|
||||
apparentTemperature: weather.hourly.apparent_temperature[index] ?? null,
|
||||
precipitationProbability: weather.hourly.precipitation_probability[index] ?? null,
|
||||
precipitation: weather.hourly.precipitation[index] ?? null,
|
||||
weatherCode: weather.hourly.weather_code[index] ?? null,
|
||||
relativeHumidity: weather.hourly.relative_humidity_2m[index] ?? null,
|
||||
windSpeed: weather.hourly.wind_speed_10m[index] ?? null,
|
||||
windGusts: weather.hourly.wind_gusts_10m[index] ?? null,
|
||||
};
|
||||
}),
|
||||
daily: dailyIndexes.map((index) => {
|
||||
const value = weather.daily.time[index] ?? currentLocalDate;
|
||||
return {
|
||||
date: value,
|
||||
time: value,
|
||||
weatherCode: weather.daily.weather_code[index] ?? 404,
|
||||
maxTemperature: weather.daily.temperature_2m_max[index] ?? null,
|
||||
maxTemp: weather.daily.temperature_2m_max[index] ?? undefined,
|
||||
minTemperature: weather.daily.temperature_2m_min[index] ?? null,
|
||||
minTemp: weather.daily.temperature_2m_min[index] ?? undefined,
|
||||
maxApparentTemperature: weather.daily.apparent_temperature_max[index] ?? null,
|
||||
minApparentTemperature: weather.daily.apparent_temperature_min[index] ?? null,
|
||||
precipitationProbability: weather.daily.precipitation_probability_max[index] ?? null,
|
||||
precipitation: weather.daily.precipitation_sum[index] ?? null,
|
||||
averageHumidity: weather.daily.relative_humidity_2m_mean[index] ?? null,
|
||||
humidity: weather.daily.relative_humidity_2m_mean[index] ?? undefined,
|
||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index] ?? null,
|
||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index] ?? null,
|
||||
uvIndex: weather.daily.uv_index_max[index] ?? null,
|
||||
sunriseAt: toOptionalUtcInstant(weather.daily.sunrise[index], weather.timezone),
|
||||
sunrise: weather.daily.sunrise[index] ?? undefined,
|
||||
sunsetAt: toOptionalUtcInstant(weather.daily.sunset[index], weather.timezone),
|
||||
sunset: weather.daily.sunset[index] ?? undefined,
|
||||
daylightDuration: weather.daily.daylight_duration[index] ?? null,
|
||||
};
|
||||
}),
|
||||
} satisfies Weather;
|
||||
};
|
||||
|
||||
export const weatherRequestHandler = createWidgetRequestHandler({
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
requestAsync: requestWeatherAsync,
|
||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean¤t_weather=true&timezone=auto`,
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
const json: unknown = await res.json();
|
||||
const weather = await atLocationOutput.parseAsync(json);
|
||||
return {
|
||||
current: weather.current_weather,
|
||||
daily: weather.daily.time.map((value, index) => {
|
||||
return {
|
||||
time: value,
|
||||
weatherCode: weather.daily.weathercode[index] ?? 404,
|
||||
maxTemp: weather.daily.temperature_2m_max[index],
|
||||
minTemp: weather.daily.temperature_2m_min[index],
|
||||
sunrise: weather.daily.sunrise[index],
|
||||
sunset: weather.daily.sunset[index],
|
||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
||||
humidity: weather.daily.relative_humidity_2m_mean[index],
|
||||
};
|
||||
}),
|
||||
} satisfies Weather;
|
||||
},
|
||||
});
|
||||
|
||||
const nullableNumberArray = z.array(z.number().nullable()).default([]);
|
||||
const nullableStringArray = z.array(z.string().nullable()).default([]);
|
||||
|
||||
const atLocationOutput = z.object({
|
||||
timezone: z.string().min(1),
|
||||
utc_offset_seconds: z.number(),
|
||||
current: z.object({
|
||||
time: z.string(),
|
||||
temperature_2m: z.number(),
|
||||
relative_humidity_2m: z.number(),
|
||||
apparent_temperature: z.number(),
|
||||
is_day: z.number(),
|
||||
precipitation: z.number(),
|
||||
weather_code: z.number(),
|
||||
wind_speed_10m: z.number(),
|
||||
wind_gusts_10m: z.number(),
|
||||
}),
|
||||
hourly: z.object({
|
||||
time: z.array(z.string()),
|
||||
temperature_2m: nullableNumberArray,
|
||||
apparent_temperature: nullableNumberArray,
|
||||
precipitation_probability: nullableNumberArray,
|
||||
precipitation: nullableNumberArray,
|
||||
weather_code: nullableNumberArray,
|
||||
relative_humidity_2m: nullableNumberArray,
|
||||
wind_speed_10m: nullableNumberArray,
|
||||
wind_gusts_10m: nullableNumberArray,
|
||||
current_weather: z.object({
|
||||
weathercode: z.number(),
|
||||
temperature: z.number(),
|
||||
windspeed: z.number(),
|
||||
}),
|
||||
daily: z.object({
|
||||
time: z.array(z.string()),
|
||||
weather_code: nullableNumberArray,
|
||||
temperature_2m_max: nullableNumberArray,
|
||||
temperature_2m_min: nullableNumberArray,
|
||||
apparent_temperature_max: nullableNumberArray,
|
||||
apparent_temperature_min: nullableNumberArray,
|
||||
precipitation_probability_max: nullableNumberArray,
|
||||
precipitation_sum: nullableNumberArray,
|
||||
relative_humidity_2m_mean: nullableNumberArray,
|
||||
wind_speed_10m_max: nullableNumberArray,
|
||||
wind_gusts_10m_max: nullableNumberArray,
|
||||
uv_index_max: nullableNumberArray,
|
||||
sunrise: nullableStringArray,
|
||||
sunset: nullableStringArray,
|
||||
daylight_duration: nullableNumberArray,
|
||||
weathercode: z.array(z.number()),
|
||||
temperature_2m_max: z.array(z.number()),
|
||||
temperature_2m_min: z.array(z.number()),
|
||||
sunrise: z.array(z.string()),
|
||||
sunset: z.array(z.string()),
|
||||
wind_speed_10m_max: z.array(z.number()),
|
||||
wind_gusts_10m_max: z.array(z.number()),
|
||||
relative_humidity_2m_mean: z.array(z.number()),
|
||||
}),
|
||||
});
|
||||
|
||||
const toUtcInstant = (value: string, timeZone: string): string => {
|
||||
const parsed = dayjs.tz(value, timeZone);
|
||||
if (!parsed.isValid()) throw new Error(`Invalid weather timestamp: ${value}`);
|
||||
return parsed.utc().toISOString();
|
||||
};
|
||||
|
||||
const toOptionalUtcInstant = (value: string | null | undefined, timeZone: string): string | null => {
|
||||
if (!value) return null;
|
||||
return toUtcInstant(value, timeZone);
|
||||
};
|
||||
|
||||
export interface Weather {
|
||||
timezone: string;
|
||||
utcOffsetSeconds: number;
|
||||
current: {
|
||||
temperature: number;
|
||||
apparentTemperature: number;
|
||||
relativeHumidity: number;
|
||||
precipitation: number;
|
||||
weatherCode: number;
|
||||
weathercode: number;
|
||||
windSpeed: number;
|
||||
temperature: number;
|
||||
windspeed: number;
|
||||
windGusts: number;
|
||||
isDay: boolean;
|
||||
observedAt: string;
|
||||
};
|
||||
hourly: {
|
||||
observedAt: string;
|
||||
temperature: number | null;
|
||||
apparentTemperature: number | null;
|
||||
precipitationProbability: number | null;
|
||||
precipitation: number | null;
|
||||
weatherCode: number | null;
|
||||
relativeHumidity: number | null;
|
||||
windSpeed: number | null;
|
||||
windGusts: number | null;
|
||||
}[];
|
||||
daily: {
|
||||
date: string;
|
||||
time: string;
|
||||
weatherCode: number;
|
||||
maxTemperature: number | null;
|
||||
maxTemp: number | undefined;
|
||||
minTemperature: number | null;
|
||||
minTemp: number | undefined;
|
||||
maxApparentTemperature: number | null;
|
||||
minApparentTemperature: number | null;
|
||||
precipitationProbability: number | null;
|
||||
precipitation: number | null;
|
||||
averageHumidity: number | null;
|
||||
humidity: number | undefined;
|
||||
maxWindSpeed: number | null;
|
||||
maxWindGusts: number | null;
|
||||
uvIndex: number | null;
|
||||
sunriseAt: string | null;
|
||||
sunrise: string | undefined;
|
||||
sunsetAt: string | null;
|
||||
sunset: string | undefined;
|
||||
daylightDuration: number | null;
|
||||
maxWindSpeed: number | undefined;
|
||||
maxWindGusts: number | undefined;
|
||||
humidity: number | undefined;
|
||||
}[];
|
||||
}
|
||||
@@ -236,6 +236,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"universalCreate": {
|
||||
"title": "Create",
|
||||
"analyticsError": "Usage metrics could not be recorded. Your action was not affected.",
|
||||
"trigger": {
|
||||
"label": "Create something",
|
||||
"description": "Add content, connect a service, or create a board from one place."
|
||||
},
|
||||
"search": {
|
||||
"label": "Search create actions",
|
||||
"placeholder": "What do you want to create?",
|
||||
"noResults": "No matching actions"
|
||||
},
|
||||
"group": {
|
||||
"currentBoard": "Current board",
|
||||
"library": "Apps and services",
|
||||
"boards": "Boards"
|
||||
},
|
||||
"action": {
|
||||
"widget": {
|
||||
"label": "Add widget",
|
||||
"description": "Add live information or controls to this board.",
|
||||
"keywords": "item dashboard content"
|
||||
},
|
||||
"app": {
|
||||
"label": "Add or create app",
|
||||
"description": "Place an app shortcut on this board or create one for your library.",
|
||||
"keywords": "shortcut bookmark application"
|
||||
},
|
||||
"integration": {
|
||||
"label": "Connect service",
|
||||
"description": "Connect a service so widgets can use its data.",
|
||||
"keywords": "integration provider connection"
|
||||
},
|
||||
"container": {
|
||||
"label": "Add container",
|
||||
"description": "Group related board content into a resizable section.",
|
||||
"keywords": "section group layout"
|
||||
},
|
||||
"board": {
|
||||
"label": "Create board",
|
||||
"description": "Start a new dashboard and add its first widget.",
|
||||
"keywords": "dashboard page"
|
||||
},
|
||||
"workshop": {
|
||||
"label": "Browse Workshop",
|
||||
"description": "Discover community-built widgets and install them.",
|
||||
"keywords": "community discover install"
|
||||
},
|
||||
"customWidget": {
|
||||
"label": "Create custom widget",
|
||||
"description": "Build a widget tailored to your own workflow.",
|
||||
"keywords": "code jsx developer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "Users",
|
||||
"name": "User",
|
||||
@@ -821,12 +876,7 @@
|
||||
"title": "Select an app to add to this board",
|
||||
"toggle": "Click to select",
|
||||
"selected": "Selected",
|
||||
"multiple": "Add {count}",
|
||||
"multiSelectTip": "Tip: Hold Ctrl / ⌘ to select multiple apps at once",
|
||||
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
|
||||
"appsSelected": "{count, plural, one {# app selected} other {# apps selected}}",
|
||||
"customApplication": "Custom application",
|
||||
"application": "Application"
|
||||
"multiple": "Add {count}"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create new app",
|
||||
@@ -1671,11 +1721,7 @@
|
||||
"grid": {
|
||||
"widgetCount": "{count} widgets",
|
||||
"noWidgets": "No widgets",
|
||||
"detected": "Detected via Docker",
|
||||
"connected": "{count, plural, one {# connected} other {# connected}}",
|
||||
"tiedWidgets": "Tied widgets",
|
||||
"more": "+{count} more",
|
||||
"loadError": "Integrations could not be loaded. Try again before creating a new connection."
|
||||
"detected": "Detected via Docker"
|
||||
},
|
||||
"category": {
|
||||
"dnsHole": "DNS",
|
||||
@@ -2089,7 +2135,6 @@
|
||||
"success": "Success",
|
||||
"beta": "Beta",
|
||||
"error": "Error",
|
||||
"analyticsError": "Setup analytics could not be recorded.",
|
||||
"action": {
|
||||
"add": "Add",
|
||||
"apply": "Apply",
|
||||
@@ -2765,8 +2810,6 @@
|
||||
"needsSetup": "Needs setup",
|
||||
"noConnectionRequired": "No connection required"
|
||||
},
|
||||
"customWidget": "Custom widget",
|
||||
"standalone": "Standalone",
|
||||
"missingIntegration": {
|
||||
"title": "Connect a service first",
|
||||
"noPermission": "This widget needs a compatible service connection. Ask an administrator to connect one, then try again."
|
||||
@@ -2783,20 +2826,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selection": {
|
||||
"summary": "{count, plural, one {# selected item} other {# selected items}}",
|
||||
"selected": "{count, plural, one {# selected} other {# selected}}",
|
||||
"removed": "{count, plural, one {# item removed} other {# items removed}}",
|
||||
"move": "Move",
|
||||
"moveTo": "Move selected items",
|
||||
"delete": "Delete",
|
||||
"clear": "Clear selection",
|
||||
"clearShortcut": "Clear selection (Escape)",
|
||||
"saveErrorTitle": "Unable to update items",
|
||||
"saveErrorMessage": "The changes could not be saved. The board was refreshed.",
|
||||
"moveUnavailableTitle": "Unable to move selected items",
|
||||
"moveUnavailableMessage": "The destination does not have enough room for the entire selection."
|
||||
},
|
||||
"moveResize": {
|
||||
"title": "Move / resize item",
|
||||
"entryLabel": "{name}, column {column}, row {row}",
|
||||
@@ -3140,6 +3169,9 @@
|
||||
"label": "24-hour format",
|
||||
"description": "Use 24-hour format instead of 12-hour format"
|
||||
},
|
||||
"showSeconds": {
|
||||
"label": "Display seconds"
|
||||
},
|
||||
"useCustomTimezone": {
|
||||
"label": "Use a fixed timezone"
|
||||
},
|
||||
@@ -3151,12 +3183,16 @@
|
||||
"label": "Show the date"
|
||||
},
|
||||
"dateFormat": {
|
||||
"label": "Date format",
|
||||
"description": "Choose a Day.js preset. Each option shows a live example and its format tokens."
|
||||
"label": "Date Format",
|
||||
"description": "How the date should look like"
|
||||
},
|
||||
"customTimeFormat": {
|
||||
"label": "Time format",
|
||||
"description": "Choose a Day.js preset. The selected format controls 12/24-hour display and whether the clock updates every second."
|
||||
"label": "Custom time format",
|
||||
"description": "Use ISO 8601 to format time (this will override other options)"
|
||||
},
|
||||
"customDateFormat": {
|
||||
"label": "Custom date format",
|
||||
"description": "Use ISO 8601 to format date (this will override other options)"
|
||||
},
|
||||
"showWeather": {
|
||||
"label": "Show weather",
|
||||
@@ -3167,273 +3203,7 @@
|
||||
},
|
||||
"isWeatherFormatFahrenheit": {
|
||||
"label": "Use Fahrenheit"
|
||||
},
|
||||
"animateWeatherIcon": {
|
||||
"label": "Animate weather icon",
|
||||
"description": "Animate the weather icon. Disabled by default and always disabled when reduced motion is requested."
|
||||
},
|
||||
"worldClockCities": {
|
||||
"label": "World clock cities",
|
||||
"description": "Add and order up to six cities for advanced view."
|
||||
}
|
||||
},
|
||||
"timezoneList": {
|
||||
"timezone": "Timezone",
|
||||
"cityLabel": "City label",
|
||||
"searchPlaceholder": "Search timezones",
|
||||
"noResults": "No timezones found",
|
||||
"unsupportedOption": "Unsupported: {timeZone}",
|
||||
"add": "Add city",
|
||||
"remove": "Remove {city}",
|
||||
"reorder": "Reorder {city}",
|
||||
"limit": "{count} of {maximum} cities configured"
|
||||
},
|
||||
"worldClock": {
|
||||
"primary": "Primary clock",
|
||||
"local": "Local time",
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"yesterday": "Yesterday",
|
||||
"noSecondaryCities": "Add cities in widget options to compare them here.",
|
||||
"unsupportedPrimaryTitle": "Primary timezone is unavailable",
|
||||
"unsupportedPrimary": "{timeZone} is not supported in this browser. Showing UTC as a clearly marked fallback.",
|
||||
"unsupportedTimeZone": "Unsupported timezone: {timeZone}",
|
||||
"accessibleTime": "{city}: {time}, {date}, {timeZone}",
|
||||
"advancedLabel": "Advanced world clock",
|
||||
"cities": "World clocks",
|
||||
"cityCount": "{count} timezones",
|
||||
"timeBarLabel": "24-hour time-of-day position for {city}",
|
||||
"dayProgress": "{percent}% elapsed · {hours}h {minutes}m remaining",
|
||||
"phaseLegend": "Time-of-day legend",
|
||||
"tick": {
|
||||
"midnight": "00",
|
||||
"morning": "06",
|
||||
"noon": "12",
|
||||
"evening": "18",
|
||||
"end": "24"
|
||||
},
|
||||
"phaseRange": {
|
||||
"night": "00–05 · 21–24",
|
||||
"dawn": "05–08",
|
||||
"day": "08–18",
|
||||
"dusk": "18–21"
|
||||
},
|
||||
"phase": {
|
||||
"night": "Night",
|
||||
"dawn": "Dawn",
|
||||
"day": "Day",
|
||||
"dusk": "Dusk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"airQuality": {
|
||||
"name": "Air Quality & UV",
|
||||
"description": "Displays air quality, ultraviolet index, pollutants and pollen for a location.",
|
||||
"option": {
|
||||
"location": {
|
||||
"label": "Air quality location"
|
||||
},
|
||||
"aqiStandard": {
|
||||
"label": "AQI standard",
|
||||
"options": {
|
||||
"auto": "Automatic for locale",
|
||||
"european": "European AQI",
|
||||
"us": "US AQI"
|
||||
}
|
||||
},
|
||||
"showUv": {
|
||||
"label": "Show UV index"
|
||||
},
|
||||
"showPollutants": {
|
||||
"label": "Show pollutant concentrations"
|
||||
},
|
||||
"showPollen": {
|
||||
"label": "Show pollen concentrations"
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"good": "Good",
|
||||
"fair": "Fair",
|
||||
"moderate": "Moderate",
|
||||
"unhealthySensitive": "Unhealthy for sensitive groups",
|
||||
"unhealthy": "Unhealthy",
|
||||
"poor": "Poor",
|
||||
"veryPoor": "Very poor",
|
||||
"veryUnhealthy": "Very unhealthy",
|
||||
"extremelyPoor": "Extremely poor",
|
||||
"hazardous": "Hazardous",
|
||||
"unknown": "No AQI reading"
|
||||
},
|
||||
"uv": {
|
||||
"label": "UV index",
|
||||
"category": {
|
||||
"low": "Low",
|
||||
"moderate": "Moderate",
|
||||
"high": "High",
|
||||
"veryHigh": "Very high",
|
||||
"extreme": "Extreme",
|
||||
"unknown": "No reading"
|
||||
}
|
||||
},
|
||||
"dominantPollutant": "Dominant pollutant",
|
||||
"aqiLabel": "AQI",
|
||||
"strongestPollen": "Highest pollen",
|
||||
"unit": {
|
||||
"concentration": "{value} µg/m³",
|
||||
"pollen": "{value} grains/m³"
|
||||
},
|
||||
"pollutant": {
|
||||
"pm2_5": "PM2.5",
|
||||
"pm10": "PM10",
|
||||
"ozone": "Ozone",
|
||||
"nitrogenDioxide": "Nitrogen dioxide",
|
||||
"sulphurDioxide": "Sulphur dioxide",
|
||||
"carbonMonoxide": "Carbon monoxide"
|
||||
},
|
||||
"pollen": {
|
||||
"alder": "Alder",
|
||||
"birch": "Birch",
|
||||
"grass": "Grass",
|
||||
"mugwort": "Mugwort",
|
||||
"olive": "Olive",
|
||||
"ragweed": "Ragweed"
|
||||
},
|
||||
"compact": {
|
||||
"sixHourTrend": "AQI trend for the next six hours"
|
||||
},
|
||||
"advanced": {
|
||||
"observedAt": "Updated at {time}",
|
||||
"next24Hours": "Next 24 hours",
|
||||
"pollutants": "Pollutant concentrations",
|
||||
"pollen": "Pollen concentrations",
|
||||
"sevenDayForecast": "7-day maximum forecast",
|
||||
"aqiValue": "AQI {value}",
|
||||
"uvValue": "UV {value}",
|
||||
"uvShort": "UV {value}",
|
||||
"noForecast": "No forecast data available"
|
||||
}
|
||||
},
|
||||
"countdown": {
|
||||
"name": "Countdowns",
|
||||
"description": "Tracks upcoming events and recurring anniversaries.",
|
||||
"option": {
|
||||
"events": {
|
||||
"label": "Events",
|
||||
"description": "Add and order up to 20 one-time or yearly countdowns. Dates are entered in the selected timezone."
|
||||
},
|
||||
"showProgress": {
|
||||
"label": "Show progress"
|
||||
},
|
||||
"showSeconds": {
|
||||
"label": "Show seconds",
|
||||
"description": "Keep seconds visible for every upcoming event."
|
||||
}
|
||||
},
|
||||
"dateTimeEventList": {
|
||||
"newEvent": "New event",
|
||||
"add": "Add event",
|
||||
"remove": "Remove {event}",
|
||||
"reorder": "Reorder {event}",
|
||||
"eventLabel": "Event label",
|
||||
"timezone": "Timezone",
|
||||
"unsupportedOption": "Unsupported: {timeZone}",
|
||||
"recurrence": "Repeats",
|
||||
"recurrenceOption": {
|
||||
"none": "Does not repeat",
|
||||
"yearly": "Every year"
|
||||
},
|
||||
"target": "Event date and time",
|
||||
"trackProgress": "Track progress from a start date",
|
||||
"start": "Progress start date and time",
|
||||
"confirmDateTime": "Confirm date and time",
|
||||
"limit": "{count} of {maximum} events configured"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No countdowns yet",
|
||||
"description": "Add an event in widget options to start counting down."
|
||||
},
|
||||
"completed": "Completed",
|
||||
"yearly": "Yearly",
|
||||
"progress": "Event progress",
|
||||
"upcoming": "Upcoming",
|
||||
"completedEvents": "Completed events",
|
||||
"invalid": {
|
||||
"title": "Some events cannot be displayed",
|
||||
"description": "{count, plural, one {One event has an invalid date or unsupported timezone.} other {# events have an invalid date or unsupported timezone.}}",
|
||||
"value": "Unavailable"
|
||||
},
|
||||
"alert": {
|
||||
"notifications": "Browser notifications",
|
||||
"permissionDenied": "Notifications are blocked in this browser.",
|
||||
"reached": "The countdown has reached its target."
|
||||
}
|
||||
},
|
||||
"timer": {
|
||||
"name": "Timer / Pomodoro",
|
||||
"description": "Runs a simple timer or a configurable Pomodoro sequence.",
|
||||
"option": {
|
||||
"mode": {
|
||||
"label": "Mode",
|
||||
"options": {
|
||||
"timer": "Timer",
|
||||
"pomodoro": "Pomodoro"
|
||||
}
|
||||
},
|
||||
"timerMinutes": {
|
||||
"label": "Timer minutes"
|
||||
},
|
||||
"focusMinutes": {
|
||||
"label": "Focus minutes"
|
||||
},
|
||||
"shortBreakMinutes": {
|
||||
"label": "Short break minutes"
|
||||
},
|
||||
"longBreakMinutes": {
|
||||
"label": "Long break minutes"
|
||||
},
|
||||
"sessionsBeforeLongBreak": {
|
||||
"label": "Focus sessions before a long break"
|
||||
},
|
||||
"autoStartBreaks": {
|
||||
"label": "Automatically start breaks"
|
||||
},
|
||||
"autoStartFocus": {
|
||||
"label": "Automatically start focus sessions"
|
||||
}
|
||||
},
|
||||
"phase": {
|
||||
"timer": "Timer",
|
||||
"focus": "Focus",
|
||||
"shortBreak": "Short break",
|
||||
"longBreak": "Long break"
|
||||
},
|
||||
"action": {
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"attention": {
|
||||
"timerComplete": "Timer complete",
|
||||
"phaseReady": "{phase} ready"
|
||||
},
|
||||
"progress": "Timer progress: {progress}%",
|
||||
"compact": {
|
||||
"completedFocus": "{count, plural, =0 {No focus sessions completed} one {# focus session completed} other {# focus sessions completed}}"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Timer complete",
|
||||
"completed": "{phase} completed."
|
||||
},
|
||||
"advanced": {
|
||||
"sequence": "Pomodoro sequence",
|
||||
"focusSession": "Focus {session}",
|
||||
"settings": "Permanent settings",
|
||||
"settingsHint": "Use the widget context menu to edit these board settings.",
|
||||
"localAlerts": "Local alerts",
|
||||
"notifications": "Browser notifications",
|
||||
"sound": "Timer sounds",
|
||||
"localAlertsHint": "Timer sounds are enabled by default. Sounds and notifications apply only to this widget in this browser.",
|
||||
"notificationsDenied": "Notifications are blocked in this browser."
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
@@ -3829,13 +3599,6 @@
|
||||
"disableTemperatureDecimals": {
|
||||
"label": "Disable temperature decimals"
|
||||
},
|
||||
"animateIcons": {
|
||||
"label": "Animate weather icons"
|
||||
},
|
||||
"showHumidity": {
|
||||
"label": "Show humidity",
|
||||
"description": "Display humidity in current conditions, hourly details and daily forecasts"
|
||||
},
|
||||
"showCurrentWindSpeed": {
|
||||
"label": "Show current wind speed",
|
||||
"description": "Only on current weather"
|
||||
@@ -3889,40 +3652,11 @@
|
||||
"detailsFor": "Show weather details for {date}",
|
||||
"advanced": {
|
||||
"forecast": "7-day forecast",
|
||||
"hourlyForecast": "Next 24 hours",
|
||||
"day": "Day",
|
||||
"time": "Time",
|
||||
"conditions": "Conditions",
|
||||
"temperature": "Temperature",
|
||||
"feelsLike": "Feels like {temperature}",
|
||||
"feelsLikeLabel": "Feels like",
|
||||
"feelsLikeRange": "Feels like: {maximum} / {minimum}",
|
||||
"highLow": "High {maximum} · low {minimum}",
|
||||
"observedAt": "Updated at {time}",
|
||||
"temperature": "High / low",
|
||||
"humidity": "Humidity",
|
||||
"averageHumidity": "Average humidity: {humidity}%",
|
||||
"currentHumidity": "Current humidity",
|
||||
"wind": "Wind / gusts ({unit})",
|
||||
"currentWind": "Current wind / gusts",
|
||||
"windValue": "{speed} / {gusts} {unit}",
|
||||
"windDetail": "Max wind / gusts: {speed} / {gusts} {unit}",
|
||||
"nextHourPrecipitation": "Precipitation next hour",
|
||||
"precipitationChance": "Precipitation chance",
|
||||
"precipitationValue": "{probability}% · {amount} mm",
|
||||
"precipitationDetail": "Precipitation: {probability}% · {amount} mm",
|
||||
"todayUv": "Today's max UV index",
|
||||
"uvIndex": "Max UV index: {value}",
|
||||
"daylight": "Daylight: {duration}",
|
||||
"hourlyTooltip": "Feels like {feelsLike}{temperatureUnit} · {amount} mm · {humidity}% humidity · {speed} {speedUnit} wind",
|
||||
"hourlyTooltipWithoutHumidity": "Feels like {feelsLike}{temperatureUnit} · {amount} mm · {speed} {speedUnit} wind",
|
||||
"detailLabel": {
|
||||
"feelsLike": "Feels like",
|
||||
"precipitation": "Precipitation",
|
||||
"humidity": "Average humidity",
|
||||
"wind": "Max wind / gusts",
|
||||
"uv": "Max UV index",
|
||||
"daylight": "Daylight"
|
||||
},
|
||||
"sun": "Sunrise / sunset",
|
||||
"noForecast": "No forecast data available"
|
||||
}
|
||||
|
||||
@@ -13,13 +13,6 @@ export { CustomPasswordInput } from "./password-input/password-input";
|
||||
export { IntegrationAvatar } from "./integration-avatar";
|
||||
export { BetaBadge } from "./beta-badge";
|
||||
export { CatalogItem } from "./catalog-item";
|
||||
export { SelectableCard } from "./selectable-card";
|
||||
export type {
|
||||
SelectableCardProps,
|
||||
SelectableCardStylesNames,
|
||||
SelectableCardCssVariables,
|
||||
SelectableCardFactory,
|
||||
} from "./selectable-card";
|
||||
export { MaskedImage } from "./masked-image";
|
||||
export { MaskedOrNormalImage } from "./masked-or-normal-image";
|
||||
export { LanguageIcon } from "./language-icon";
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export { SelectableCard } from "./selectable-card";
|
||||
export type {
|
||||
SelectableCardProps,
|
||||
SelectableCardStylesNames,
|
||||
SelectableCardCssVariables,
|
||||
SelectableCardFactory,
|
||||
} from "./selectable-card";
|
||||
@@ -1,79 +0,0 @@
|
||||
.root {
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 175px;
|
||||
height: 100%;
|
||||
padding: var(--mantine-spacing-sm);
|
||||
border-radius: var(--selectable-card-radius, var(--mantine-radius-md));
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
border-color 150ms ease;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.root:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.root:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.root[data-selected] {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.root:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
margin: calc(var(--mantine-spacing-sm) * -1);
|
||||
margin-bottom: 0;
|
||||
padding-left: var(--mantine-spacing-sm);
|
||||
padding-right: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px dashed var(--mantine-color-default-border);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
margin: calc(var(--mantine-spacing-sm) * -1);
|
||||
margin-top: auto;
|
||||
padding-left: var(--mantine-spacing-sm);
|
||||
padding-right: var(--mantine-spacing-sm);
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
declare const classes: {
|
||||
readonly root: string;
|
||||
readonly header: string;
|
||||
readonly body: string;
|
||||
readonly footer: string;
|
||||
};
|
||||
export default classes;
|
||||
@@ -1,370 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { Badge, MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SelectableCard } from "./selectable-card";
|
||||
import classes from "./selectable-card.module.css";
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("SelectableCard", () => {
|
||||
describe("rendering slots", () => {
|
||||
it("renders as a native button with default button type and accessible slots", async () => {
|
||||
const onClick = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
aria-label="Sonarr Card"
|
||||
icon={<span data-testid="card-icon">Icon</span>}
|
||||
title="Sonarr"
|
||||
topRight={<Badge data-testid="card-topright">Media</Badge>}
|
||||
description="Manage TV series downloads"
|
||||
footerLeft={<span data-testid="card-footer-left">Left Meta</span>}
|
||||
footerRight={<span data-testid="card-footer-right">Right Meta</span>}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.type).toBe("button");
|
||||
expect(button?.getAttribute("aria-label")).toBe("Sonarr Card");
|
||||
expect(host.querySelector("[data-testid='card-icon']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
expect(host.querySelector("[data-testid='card-topright']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Manage TV series downloads");
|
||||
expect(host.querySelector("[data-testid='card-footer-left']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='card-footer-right']")).not.toBeNull();
|
||||
|
||||
await act(async () => button?.click());
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders only header when only header slots (icon, title, topRight) are provided", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
icon={<span data-testid="only-icon">I</span>}
|
||||
title="Only Header"
|
||||
topRight={<span data-testid="only-topright">TR</span>}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.querySelector(`.${classes.header}`)).not.toBeNull();
|
||||
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
|
||||
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
|
||||
expect(host.querySelector("[data-testid='only-icon']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='only-topright']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders footer when only footerLeft or only footerRight is provided", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Test" footerLeft={<span data-testid="left-only">Left</span>} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='left-only']")).not.toBeNull();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Test" footerRight={<span data-testid="right-only">Right</span>} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='right-only']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("applies Styles API classes for root, header, body, and footer", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Plex"
|
||||
description="Media Server"
|
||||
footerLeft={<span>Footer</span>}
|
||||
classNames={{
|
||||
root: "custom-root",
|
||||
header: "custom-header",
|
||||
body: "custom-body",
|
||||
footer: "custom-footer",
|
||||
}}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.classList.contains(classes.root)).toBe(true);
|
||||
expect(button?.classList.contains("custom-root")).toBe(true);
|
||||
|
||||
const header = button?.querySelector(`.${classes.header}`);
|
||||
expect(header).not.toBeNull();
|
||||
expect(header?.classList.contains("custom-header")).toBe(true);
|
||||
|
||||
const body = button?.querySelector(`.${classes.body}`);
|
||||
expect(body).not.toBeNull();
|
||||
expect(body?.classList.contains("custom-body")).toBe(true);
|
||||
|
||||
const footer = button?.querySelector(`.${classes.footer}`);
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer?.classList.contains("custom-footer")).toBe(true);
|
||||
});
|
||||
|
||||
it("prioritizes title without wrapping and constrains topRight badge with ellipsis", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Very Long Title Name That Should Not Wrap"
|
||||
topRight={<Badge>Category Badge</Badge>}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
const header = button?.querySelector(`.${classes.header}`);
|
||||
expect(header).not.toBeNull();
|
||||
|
||||
const titleText = header?.querySelector("p, span, div");
|
||||
expect(titleText?.textContent).toContain("Very Long Title Name That Should Not Wrap");
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title={<span data-testid="custom-title-node">Custom Title Element</span>}
|
||||
topRight={<span data-testid="custom-top-right">Top Right</span>}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[data-testid='custom-title-node']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='custom-top-right']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders custom children in the body slot over description", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Test" description="This should not appear">
|
||||
<div data-testid="custom-body-children">Custom Child Content</div>
|
||||
</SelectableCard>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[data-testid='custom-body-children']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Custom Child Content");
|
||||
expect(host.textContent).not.toContain("This should not appear");
|
||||
});
|
||||
|
||||
it("renders an empty description without fallback copy", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="No Desc Item" description="" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("No Desc Item");
|
||||
expect(host.textContent).not.toContain("No description provided");
|
||||
});
|
||||
|
||||
it("omits header, body, or footer when corresponding props are omitted", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.querySelector(`.${classes.header}`)).toBeNull();
|
||||
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
|
||||
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("states", () => {
|
||||
it("handles selected state via data-selected attribute", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Selected Item" selected />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.getAttribute("data-selected")).toBe("true");
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Unselected Item" selected={false} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(button?.hasAttribute("data-selected")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles disabled state and prevents click handlers", async () => {
|
||||
const onClick = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Disabled Item" disabled onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.disabled).toBe(true);
|
||||
|
||||
await act(async () => button?.click());
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles loading state by disabling the button and showing LoadingOverlay", async () => {
|
||||
const onClick = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Loading Item" loading onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.disabled).toBe(true);
|
||||
|
||||
await act(async () => button?.click());
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactions and accessibility", () => {
|
||||
it("supports focus and click activation", async () => {
|
||||
const onClick = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Interactive Card" onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
button?.focus();
|
||||
expect(document.activeElement).toBe(button);
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("forwards ref to the underlying HTMLButtonElement", async () => {
|
||||
const ref = createRef<HTMLButtonElement>();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard ref={ref} title="Ref Card" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
|
||||
expect(ref.current?.tagName).toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("resolves radius CSS variables via varsResolver", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Radius Card" radius="lg" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)");
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Radius Card Sm" radius="sm" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-sm)");
|
||||
});
|
||||
|
||||
it("passes arbitrary HTML attributes down to the button element", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Custom Props Card"
|
||||
data-testid="my-selectable-card"
|
||||
data-custom="custom-value"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.getAttribute("data-testid")).toBe("my-selectable-card");
|
||||
expect(button?.getAttribute("data-custom")).toBe("custom-value");
|
||||
expect(button?.tabIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Box,
|
||||
createVarsResolver,
|
||||
factory,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Text,
|
||||
Tooltip,
|
||||
useProps,
|
||||
useStyles,
|
||||
} from "@mantine/core";
|
||||
import type { BoxProps, ElementProps, Factory, MantineRadius, StylesApiProps } from "@mantine/core";
|
||||
|
||||
import classes from "./selectable-card.module.css";
|
||||
|
||||
export type SelectableCardStylesNames = "root" | "header" | "body" | "footer";
|
||||
export type SelectableCardCssVariables = {
|
||||
root: "--selectable-card-radius";
|
||||
};
|
||||
|
||||
export interface SelectableCardProps
|
||||
extends BoxProps, StylesApiProps<SelectableCardFactory>, Omit<ElementProps<"button">, "title"> {
|
||||
icon?: ReactNode;
|
||||
title?: ReactNode;
|
||||
topRight?: ReactNode;
|
||||
description?: string | null;
|
||||
footerLeft?: ReactNode;
|
||||
footerRight?: ReactNode;
|
||||
selected?: boolean;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
radius?: MantineRadius;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export type SelectableCardFactory = Factory<{
|
||||
props: SelectableCardProps;
|
||||
ref: HTMLButtonElement;
|
||||
stylesNames: SelectableCardStylesNames;
|
||||
vars: SelectableCardCssVariables;
|
||||
}>;
|
||||
|
||||
const defaultProps: Partial<SelectableCardProps> = {
|
||||
type: "button",
|
||||
};
|
||||
|
||||
const varsResolver = createVarsResolver<SelectableCardFactory>((_theme, { radius }) => ({
|
||||
root: {
|
||||
"--selectable-card-radius": radius ? `var(--mantine-radius-${radius})` : undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
export const SelectableCard = factory<SelectableCardFactory>((_props) => {
|
||||
const props = useProps("SelectableCard", defaultProps, _props);
|
||||
const {
|
||||
ref,
|
||||
classNames,
|
||||
className,
|
||||
style,
|
||||
styles,
|
||||
unstyled,
|
||||
vars,
|
||||
attributes,
|
||||
icon,
|
||||
title,
|
||||
topRight,
|
||||
description,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
selected,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
children,
|
||||
type = "button",
|
||||
...others
|
||||
} = props;
|
||||
|
||||
const getStyles = useStyles<SelectableCardFactory>({
|
||||
name: "SelectableCard",
|
||||
classes,
|
||||
props,
|
||||
className,
|
||||
style,
|
||||
classNames,
|
||||
styles,
|
||||
unstyled,
|
||||
vars,
|
||||
attributes,
|
||||
varsResolver,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
{...others}
|
||||
type={type}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
data-selected={selected || undefined}
|
||||
aria-pressed={selected !== undefined ? selected : undefined}
|
||||
{...getStyles("root")}
|
||||
>
|
||||
<LoadingOverlay visible={loading} loaderProps={{ size: "sm" }} />
|
||||
|
||||
{/* Header Section: Inset background with Title that never elides before the category */}
|
||||
{(icon || title || topRight) && (
|
||||
<Box {...getStyles("header")}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
style={{ width: "100%", overflow: "hidden" }}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}>
|
||||
{icon}
|
||||
{typeof title === "string" ? (
|
||||
<Text
|
||||
fw={700}
|
||||
size="md"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Group>
|
||||
{topRight && (
|
||||
<Box
|
||||
style={{
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{topRight}
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Body: Custom Children or Default Description */}
|
||||
{children ? (
|
||||
<Box {...getStyles("body")}>{children}</Box>
|
||||
) : description !== undefined ? (
|
||||
<Box {...getStyles("body")}>
|
||||
<Tooltip label={description} multiline w={260} disabled={!description}>
|
||||
<Text size="sm" c="dimmed" lineClamp={2} lh={1.35}>
|
||||
{description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Footer Section: Inset Dashed Bottom Meta */}
|
||||
{(footerLeft || footerRight) && (
|
||||
<Box {...getStyles("footer")}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="xs" style={{ width: "100%" }}>
|
||||
<Box style={{ minWidth: 0, flexShrink: 1 }}>{footerLeft}</Box>
|
||||
<Box style={{ minWidth: 0, flexShrink: 0 }}>{footerRight}</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
SelectableCard.displayName = "@homarr/ui/SelectableCard";
|
||||
SelectableCard.classes = classes;
|
||||
@@ -1,279 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { IntegrationSelectGrid } from "./integration-select-grid";
|
||||
|
||||
vi.mock("@homarr/definitions", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@homarr/definitions")>();
|
||||
return {
|
||||
...actual,
|
||||
getWidgetKindsForIntegration: (kind: IntegrationKind) => {
|
||||
if (kind === "wud") return [];
|
||||
return actual.getWidgetKindsForIntegration(kind);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string, values?: { count?: number }) => {
|
||||
const translations: Record<string, string> = {
|
||||
"integration.page.list.search": "Search integrations",
|
||||
"common.noResults": "No results found",
|
||||
"integration.grid.noWidgets": "No widgets tied to this service",
|
||||
"integration.grid.tiedWidgets": "Tied Widgets",
|
||||
"integration.grid.connected": `${values?.count ?? 0} connected`,
|
||||
"integration.grid.more": `+${values?.count ?? 0} more`,
|
||||
"widget.mediaServer.name": "Media Server",
|
||||
"widget.calendar.name": "Calendar",
|
||||
"widget.downloads.name": "Downloads",
|
||||
"integration.category.mediaService": "Media Service",
|
||||
"integration.category.downloadClient": "Download Client",
|
||||
"integration.category.calendar": "Calendar",
|
||||
"integration.category.smartHomeServer": "Smart Home",
|
||||
"integration.category.usenet": "Usenet",
|
||||
"integration.category.torrent": "Torrent",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("IntegrationSelectGrid", () => {
|
||||
it("renders a 4-column responsive grid of selectable integration cards", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "sabNzbd"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const cards = host.querySelectorAll("button");
|
||||
expect(cards.length).toBe(4);
|
||||
expect(host.textContent).toContain("Jellyfin");
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
expect(host.textContent).toContain("Radarr");
|
||||
expect(host.textContent).toContain("SABnzbd");
|
||||
});
|
||||
|
||||
it("renders tied widgets with icons and labels inside integration cards", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Tied Widgets");
|
||||
// Jellyfin is tied to Media Server widget
|
||||
expect(host.textContent).toContain("Media Server");
|
||||
});
|
||||
|
||||
it("displays fallback message when integration has no tied widgets", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["wud"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("button")).not.toBeNull();
|
||||
expect(host.textContent).toContain("No widgets tied to this service");
|
||||
});
|
||||
|
||||
it("displays '{count} connected' in footerLeft when integration instances exist", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const integrationData = [
|
||||
{ kind: "jellyfin" as IntegrationKind, name: "Home Jellyfin" },
|
||||
{ kind: "jellyfin" as IntegrationKind, name: "Remote Jellyfin" },
|
||||
{ kind: "sonarr" as IntegrationKind, name: "Sonarr Instance" },
|
||||
];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid
|
||||
onSelect={onSelect}
|
||||
allowedKinds={["jellyfin", "radarr"]}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Jellyfin has 2 connected
|
||||
expect(host.textContent).toContain("2 connected");
|
||||
|
||||
// Radarr has 0 connected, so it doesn't show "0 connected"
|
||||
expect(host.textContent).not.toContain("0 connected");
|
||||
});
|
||||
|
||||
it("filters integrations dynamically by search input", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "plex"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
expect(searchInput).not.toBeNull();
|
||||
|
||||
// Type "jelly"
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "jelly");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
const cards = host.querySelectorAll("button");
|
||||
expect(cards.length).toBe(1);
|
||||
expect(host.textContent).toContain("Jellyfin");
|
||||
expect(host.textContent).not.toContain("Sonarr");
|
||||
expect(host.textContent).not.toContain("Radarr");
|
||||
});
|
||||
|
||||
it("displays empty state when no integrations match search query", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "nonexistent-integration-query");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("No results found");
|
||||
expect(host.querySelectorAll("button").length).toBe(0);
|
||||
});
|
||||
|
||||
it("calls onSelect when Enter is pressed with exactly 1 search match", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "jelly");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("jellyfin");
|
||||
});
|
||||
|
||||
it("calls onSelect when an integration card is clicked", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const cardButton = host.querySelector("button");
|
||||
await act(async () => cardButton?.click());
|
||||
expect(onSelect).toHaveBeenCalledWith("sonarr");
|
||||
});
|
||||
|
||||
it("includes mock integration when enableMockIntegration is true", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration allowedKinds={["mock", "sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Mock");
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
});
|
||||
|
||||
it("excludes mock integration when enableMockIntegration is false", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration={false} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const buttons = Array.from(host.querySelectorAll("button"));
|
||||
const mockButton = buttons.find((btn) => btn.getAttribute("aria-label")?.toLowerCase() === "mock");
|
||||
expect(mockButton).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,68 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
IconAd,
|
||||
IconApps,
|
||||
IconBinaryTree,
|
||||
IconBookmark,
|
||||
IconBrandDocker,
|
||||
IconBrandMinecraft,
|
||||
IconBrowser,
|
||||
IconBuildingBank,
|
||||
IconBusStop,
|
||||
IconCalendar,
|
||||
IconChartBar,
|
||||
IconClock,
|
||||
IconClockPlay,
|
||||
IconCloud,
|
||||
IconDeviceCctv,
|
||||
IconDeviceGamepad,
|
||||
IconDownload,
|
||||
IconGraphFilled,
|
||||
IconHeartRateMonitor,
|
||||
IconHourglass,
|
||||
IconMessage,
|
||||
IconMovie,
|
||||
IconNotes,
|
||||
IconPuzzle,
|
||||
IconReportSearch,
|
||||
IconRobot,
|
||||
IconRocket,
|
||||
IconRss,
|
||||
IconSearch,
|
||||
IconServer2,
|
||||
IconTicket,
|
||||
IconTopologyFull,
|
||||
IconTransform,
|
||||
IconVideo,
|
||||
IconWall,
|
||||
IconWind,
|
||||
IconZoomQuestion,
|
||||
} from "@tabler/icons-react";
|
||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconPuzzle } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { IntegrationAvatar } from "../components/integration-avatar";
|
||||
import { SelectableCard } from "../components/selectable-card";
|
||||
import { selectGridCols } from "../select-grid-layout";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout";
|
||||
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||
import type { IntegrationGridItem } from "./integration-grid-shared";
|
||||
import classes from "./integration-select-grid.module.css";
|
||||
|
||||
export interface IntegrationSelectGridProps {
|
||||
onSelect: (kind: IntegrationKind) => void;
|
||||
enableMockIntegration?: boolean;
|
||||
allowedKinds?: readonly IntegrationKind[];
|
||||
integrationData?: { kind: IntegrationKind; name?: string }[];
|
||||
}
|
||||
|
||||
export const IntegrationSelectGrid = ({
|
||||
onSelect,
|
||||
enableMockIntegration = false,
|
||||
allowedKinds,
|
||||
integrationData,
|
||||
}: IntegrationSelectGridProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
@@ -73,182 +32,75 @@ export const IntegrationSelectGrid = ({
|
||||
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Top Search Input */}
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
aria-label={t("integration.page.list.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filtered.length === 1 && filtered[0]) {
|
||||
onSelect(filtered[0].kind);
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
ariaLabel={t("integration.page.list.search")}
|
||||
>
|
||||
{filtered.map((integration) => (
|
||||
<UnstyledButton
|
||||
className={classes.choice}
|
||||
key={integration.kind}
|
||||
onClick={() => onSelect(integration.kind)}
|
||||
aria-label={integration.name}
|
||||
>
|
||||
<Card h={selectGridCardHeight} className={classes.card} withBorder>
|
||||
<Stack justify="space-between" h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
|
||||
{integration.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<IntegrationGridMeta integration={integration} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Center p="xl" style={{ gridColumn: "1 / -1" }}>
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const IntegrationGridMeta = ({ integration }: { integration: ReturnType<typeof buildSortedIntegrations>[number] }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Stack gap={4} mt="auto">
|
||||
<Group gap={4} wrap="wrap">
|
||||
{integration.categories.slice(0, 2).map((category) => (
|
||||
<Badge key={category} variant="light" size="xs">
|
||||
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{integration.widgets.length > 0 ? (
|
||||
<Tooltip
|
||||
multiline
|
||||
w={200}
|
||||
label={
|
||||
<Stack gap={4}>
|
||||
{integration.widgets.map((widgetKind) => (
|
||||
<Text key={widgetKind} size="xs">
|
||||
{t(`widget.${widgetKind}.name`)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scrollable Container with Integration Cards */}
|
||||
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
|
||||
<Stack gap="md" pt="xs" pr="xs" px={4}>
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{filtered.map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.kind}
|
||||
integration={integration}
|
||||
onSelect={onSelect}
|
||||
connectedCount={(integrationData ?? []).filter((i) => i.kind === integration.kind).length}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
>
|
||||
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
|
||||
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{t("integration.grid.noWidgets")}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// IntegrationCard: Shared Modular SelectableCard with Tied Widgets
|
||||
// =========================================================================
|
||||
const IntegrationCard = ({
|
||||
integration,
|
||||
onSelect,
|
||||
connectedCount = 0,
|
||||
}: {
|
||||
integration: IntegrationGridItem;
|
||||
onSelect: (kind: IntegrationKind) => void;
|
||||
connectedCount?: number;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<SelectableCard
|
||||
onClick={() => onSelect(integration.kind)}
|
||||
aria-label={integration.name}
|
||||
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
|
||||
title={integration.name}
|
||||
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />}
|
||||
footerLeft={
|
||||
connectedCount > 0 ? (
|
||||
<Badge variant="light" color="teal" size="xs" radius="xs">
|
||||
{t("integration.grid.connected", { count: connectedCount })}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Text size="10px" tt="uppercase" fw={700} c="dimmed">
|
||||
{t("integration.grid.tiedWidgets")}
|
||||
</Text>
|
||||
<IntegrationTiedWidgets widgets={integration.widgets} />
|
||||
</SelectableCard>
|
||||
);
|
||||
};
|
||||
|
||||
const widgetIconsMap: Partial<Record<WidgetKind, typeof IconPuzzle>> = {
|
||||
clock: IconClock,
|
||||
weather: IconCloud,
|
||||
airQuality: IconWind,
|
||||
countdown: IconHourglass,
|
||||
timer: IconClockPlay,
|
||||
app: IconApps,
|
||||
assistant: IconRobot,
|
||||
iframe: IconBrowser,
|
||||
video: IconDeviceCctv,
|
||||
notebook: IconNotes,
|
||||
anchorNote: IconNotes,
|
||||
dnsHoleSummary: IconAd,
|
||||
dnsHoleControls: IconDeviceGamepad,
|
||||
"smartHome-entityState": IconBinaryTree,
|
||||
"smartHome-executeAutomation": IconBinaryTree,
|
||||
stockPrice: IconBuildingBank,
|
||||
mediaServer: IconVideo,
|
||||
calendar: IconCalendar,
|
||||
downloads: IconDownload,
|
||||
"mediaRequests-requestList": IconZoomQuestion,
|
||||
"mediaRequests-requestStats": IconChartBar,
|
||||
mediaTranscoding: IconTransform,
|
||||
mediaMissing: IconMovie,
|
||||
minecraftServerStatus: IconBrandMinecraft,
|
||||
networkControllerSummary: IconTopologyFull,
|
||||
networkControllerStatus: IconTopologyFull,
|
||||
rssFeed: IconRss,
|
||||
bookmarks: IconBookmark,
|
||||
indexerManager: IconReportSearch,
|
||||
healthMonitoring: IconHeartRateMonitor,
|
||||
releases: IconRocket,
|
||||
mediaReleases: IconTicket,
|
||||
dockerContainers: IconBrandDocker,
|
||||
firewall: IconWall,
|
||||
notifications: IconMessage,
|
||||
systemResources: IconGraphFilled,
|
||||
coolify: IconCloud,
|
||||
systemDisks: IconServer2,
|
||||
timetable: IconBusStop,
|
||||
};
|
||||
|
||||
const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => {
|
||||
const t = useI18n();
|
||||
if (widgets.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("integration.grid.noWidgets")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const items = limit ? widgets.slice(0, limit) : widgets;
|
||||
const moreCount = limit ? widgets.length - limit : 0;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{items.map((widgetKind) => {
|
||||
const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle;
|
||||
return (
|
||||
<Badge
|
||||
key={widgetKind}
|
||||
variant="default"
|
||||
radius="xs"
|
||||
size="xs"
|
||||
leftSection={<WidgetIcon size={12} />}
|
||||
style={{ fontWeight: 500 }}
|
||||
>
|
||||
{t(`widget.${widgetKind}.name`)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{moreCount > 0 && (
|
||||
<Badge variant="subtle" color="gray" size="xs" radius="xs">
|
||||
{t("integration.grid.more", { count: moreCount })}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const IntegrationCategoryBadges = ({
|
||||
categories,
|
||||
limit,
|
||||
}: {
|
||||
categories: IntegrationGridItem["categories"];
|
||||
limit?: number;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const items = limit ? categories.slice(0, limit) : categories;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{items.map((category) => (
|
||||
<Badge key={category} variant="light" size="xs" color="gray" radius="xs">
|
||||
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -13,9 +13,7 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./inputs": "./src/_inputs/index.ts",
|
||||
"./inputs/form": "./src/_inputs/form.ts",
|
||||
"./inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
|
||||
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
|
||||
"./custom-api/component": "./src/custom-api/component.tsx",
|
||||
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
|
||||
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
|
||||
@@ -67,7 +65,6 @@
|
||||
"@mantine/dates": "catalog:",
|
||||
"@mantine/form": "catalog:",
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@rexa-developer/tiks": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tiptap/extension-color": "catalog:",
|
||||
"@tiptap/extension-highlight": "catalog:",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
|
||||
import { WidgetAppInput } from "./widget-app-input";
|
||||
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
|
||||
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
|
||||
import { WidgetDateTimeEventListInput } from "./widget-date-time-event-list-input";
|
||||
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
|
||||
import { WidgetDynamicMultiSelectInput } from "./widget-dynamic-multiselect-input";
|
||||
import { WidgetLocationInput } from "./widget-location-input";
|
||||
@@ -16,7 +15,6 @@ import { WidgetSliderInput } from "./widget-slider-input";
|
||||
import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input";
|
||||
import { WidgetSwitchInput } from "./widget-switch-input";
|
||||
import { WidgetTextInput } from "./widget-text-input";
|
||||
import { WidgetTimezoneListInput } from "./widget-timezone-list-input";
|
||||
import { WidgetUmamiEventInput } from "./widget-umami-event-input";
|
||||
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
|
||||
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
|
||||
@@ -28,8 +26,6 @@ const mapping = {
|
||||
internal: WidgetInternalInput,
|
||||
anchorNote: WidgetAnchorNoteInput,
|
||||
text: WidgetTextInput,
|
||||
dateTimeEventList: WidgetDateTimeEventListInput,
|
||||
timezoneList: WidgetTimezoneListInput,
|
||||
location: WidgetLocationInput,
|
||||
multiSelect: WidgetMultiSelectInput,
|
||||
multiText: WidgetMultiTextInput,
|
||||
|
||||
@@ -17,12 +17,6 @@ import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
const renderHereLink = (children: React.ReactNode) => (
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
||||
{children}
|
||||
</Anchor>
|
||||
);
|
||||
|
||||
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
|
||||
const t = useI18n();
|
||||
const tInput = useWidgetInputTranslation(kind, property);
|
||||
@@ -55,9 +49,13 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
|
||||
}
|
||||
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||
description={
|
||||
<Text component="span" size="xs">
|
||||
<Text size="xs">
|
||||
{t.rich("widget.common.app.description", {
|
||||
here: renderHereLink,
|
||||
here: () => (
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
||||
{t("common.here")}
|
||||
</Anchor>
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
}
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { ActionIcon, Button, Card, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { formatWallDateTime, resolveWallDateTimeString } from "../countdown/recurrence";
|
||||
import type { DateTimeEventRecurrence, OptionDateTimeEvent } from "../options";
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetDateTimeEventListInput = ({
|
||||
property,
|
||||
kind,
|
||||
options,
|
||||
}: CommonWidgetInputProps<"dateTimeEventList">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const tEditor = useEventListTranslation(kind);
|
||||
const form = useFormContext();
|
||||
const fieldPath = `options.${property}`;
|
||||
const currentValue = form.values.options[property];
|
||||
const values = Array.isArray(currentValue) ? (currentValue as OptionDateTimeEvent[]) : options.defaultValue;
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor),
|
||||
useSensor(TouchSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Array.isArray(currentValue)) return;
|
||||
form.setFieldValue(fieldPath, options.defaultValue);
|
||||
}, [currentValue, fieldPath, form, options.defaultValue]);
|
||||
|
||||
const setValues = (nextValues: OptionDateTimeEvent[]) => form.setFieldValue(fieldPath, nextValues);
|
||||
const addEvent = () => {
|
||||
if (values.length >= options.maxValues) return;
|
||||
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
const target = dayjs().add(1, "day").minute(0).second(0).millisecond(0);
|
||||
setValues([
|
||||
...values,
|
||||
{
|
||||
id: createId(),
|
||||
label: tEditor("newEvent"),
|
||||
targetUtc: target.toISOString(),
|
||||
timeZone: localTimeZone,
|
||||
recurrence: "none",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fieldset legend={t("label")}>
|
||||
<Stack gap="sm">
|
||||
{options.withDescription && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size="var(--mantine-font-size-md)" />}
|
||||
onClick={addEvent}
|
||||
disabled={values.length >= options.maxValues}
|
||||
>
|
||||
{tEditor("add")}
|
||||
</Button>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={({ active, over }) => {
|
||||
if (!over || active.id === over.id) return;
|
||||
const activeIndex = values.findIndex(({ id }) => id === active.id);
|
||||
const overIndex = values.findIndex(({ id }) => id === over.id);
|
||||
if (activeIndex < 0 || overIndex < 0) return;
|
||||
setValues(arrayMove(values, activeIndex, overIndex));
|
||||
}}
|
||||
>
|
||||
<SortableContext items={values.map(({ id }) => id)} strategy={verticalListSortingStrategy}>
|
||||
<Stack gap="xs">
|
||||
{values.map((event) => (
|
||||
<SortableEvent
|
||||
key={event.id}
|
||||
kind={kind}
|
||||
event={event}
|
||||
timeZoneOptions={options.timeZoneOptions}
|
||||
onChange={(nextEvent) =>
|
||||
setValues(values.map((candidate) => (candidate.id === event.id ? nextEvent : candidate)))
|
||||
}
|
||||
onRemove={() => setValues(values.filter((candidate) => candidate.id !== event.id))}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{tEditor("limit", { count: values.length, maximum: options.maxValues })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
interface SortableEventProps {
|
||||
kind: WidgetKind;
|
||||
event: OptionDateTimeEvent;
|
||||
timeZoneOptions: readonly { value: string; label: string }[];
|
||||
onChange: (event: OptionDateTimeEvent) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const SortableEvent = ({ kind, event, timeZoneOptions, onChange, onRemove }: SortableEventProps) => {
|
||||
const t = useEventListTranslation(kind);
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: event.id,
|
||||
});
|
||||
const style = {
|
||||
transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : undefined,
|
||||
zIndex: isDragging ? 1 : undefined,
|
||||
};
|
||||
const hasCurrentTimeZone = timeZoneOptions.some(({ value }) => value === event.timeZone);
|
||||
const selectableTimeZones = hasCurrentTimeZone
|
||||
? timeZoneOptions
|
||||
: [{ value: event.timeZone, label: t("unsupportedOption", { timeZone: event.timeZone }) }, ...timeZoneOptions];
|
||||
const targetValue = formatWallDateTime(event.targetUtc, event.timeZone);
|
||||
const startValue = event.startUtc ? formatWallDateTime(event.startUtc, event.timeZone) : null;
|
||||
|
||||
const changeTimeZone = (timeZone: string) => {
|
||||
const target = resolveWallDateTimeString(targetValue, timeZone);
|
||||
const start = startValue ? resolveWallDateTimeString(startValue, timeZone) : null;
|
||||
onChange({
|
||||
...event,
|
||||
timeZone,
|
||||
targetUtc: target?.toISOString() ?? event.targetUtc,
|
||||
startUtc: event.startUtc ? (start?.toISOString() ?? event.startUtc) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const updateWallDateTime = (value: string | null, field: "targetUtc" | "startUtc") => {
|
||||
if (!value) return;
|
||||
const resolved = resolveWallDateTimeString(value, event.timeZone);
|
||||
if (!resolved) return;
|
||||
onChange({ ...event, [field]: resolved.toISOString() });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card ref={setNodeRef} withBorder p="sm" style={style}>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" wrap="nowrap" align="end">
|
||||
<ActionIcon
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
aria-label={t("reorder", { event: event.label })}
|
||||
style={{ cursor: "grab" }}
|
||||
>
|
||||
<IconGripVertical size="var(--mantine-font-size-lg)" />
|
||||
</ActionIcon>
|
||||
<TextInput
|
||||
flex={1}
|
||||
label={t("eventLabel")}
|
||||
value={event.label}
|
||||
maxLength={64}
|
||||
onChange={(changeEvent) => onChange({ ...event, label: changeEvent.currentTarget.value })}
|
||||
/>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("remove", { event: event.label })}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<IconTrash size="var(--mantine-font-size-lg)" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Group align="end" grow>
|
||||
<Select
|
||||
label={t("timezone")}
|
||||
searchable
|
||||
data={selectableTimeZones}
|
||||
value={event.timeZone}
|
||||
onChange={(timeZone) => {
|
||||
if (timeZone) changeTimeZone(timeZone);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={t("recurrence")}
|
||||
value={event.recurrence}
|
||||
data={(["none", "yearly"] as const).map((value) => ({ value, label: t(`recurrenceOption.${value}`) }))}
|
||||
onChange={(value) => {
|
||||
if (value) onChange({ ...event, recurrence: value as DateTimeEventRecurrence });
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<ConfirmedDateTimePicker
|
||||
label={t("target")}
|
||||
value={targetValue || null}
|
||||
onChange={(value) => updateWallDateTime(value, "targetUtc")}
|
||||
confirmLabel={t("confirmDateTime")}
|
||||
/>
|
||||
|
||||
{event.recurrence === "none" && (
|
||||
<>
|
||||
<Switch
|
||||
label={t("trackProgress")}
|
||||
checked={event.startUtc !== undefined}
|
||||
onChange={(changeEvent) => {
|
||||
if (!changeEvent.currentTarget.checked) {
|
||||
const nextEvent = { ...event };
|
||||
delete nextEvent.startUtc;
|
||||
onChange(nextEvent);
|
||||
return;
|
||||
}
|
||||
onChange({ ...event, startUtc: new Date().toISOString() });
|
||||
}}
|
||||
/>
|
||||
{event.startUtc && (
|
||||
<ConfirmedDateTimePicker
|
||||
label={t("start")}
|
||||
value={startValue}
|
||||
onChange={(value) => updateWallDateTime(value, "startUtc")}
|
||||
confirmLabel={t("confirmDateTime")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConfirmedDateTimePickerProps {
|
||||
label: string;
|
||||
value: string | null;
|
||||
confirmLabel: string;
|
||||
onChange: (value: string | null) => void;
|
||||
}
|
||||
|
||||
const ConfirmedDateTimePicker = ({ label, value, confirmLabel, onChange }: ConfirmedDateTimePickerProps) => {
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
|
||||
useEffect(() => setDraftValue(value), [value]);
|
||||
|
||||
const resetDraft = () => setDraftValue(value);
|
||||
const confirmDraft = () => {
|
||||
if (!draftValue) return;
|
||||
onChange(draftValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<DateTimePicker
|
||||
label={label}
|
||||
value={draftValue}
|
||||
valueFormat="YYYY-MM-DD HH:mm"
|
||||
timePickerProps={{ withDropdown: true, popoverProps: { withinPortal: false } }}
|
||||
popoverProps={{ withinPortal: true }}
|
||||
submitButtonProps={{ "aria-label": confirmLabel, onClick: confirmDraft }}
|
||||
onChange={setDraftValue}
|
||||
onDropdownClose={resetDraft}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type EventListTranslationKey =
|
||||
| "newEvent"
|
||||
| "add"
|
||||
| "remove"
|
||||
| "reorder"
|
||||
| "eventLabel"
|
||||
| "timezone"
|
||||
| "unsupportedOption"
|
||||
| "recurrence"
|
||||
| "recurrenceOption.none"
|
||||
| "recurrenceOption.yearly"
|
||||
| "target"
|
||||
| "trackProgress"
|
||||
| "start"
|
||||
| "confirmDateTime"
|
||||
| "limit";
|
||||
|
||||
type EventListTranslation = (key: EventListTranslationKey, values?: Record<string, string | number>) => string;
|
||||
|
||||
const useEventListTranslation = (kind: WidgetKind) =>
|
||||
useScopedI18n(`widget.${kind}.dateTimeEventList` as never) as unknown as EventListTranslation;
|
||||
@@ -72,7 +72,6 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
|
||||
);
|
||||
}}
|
||||
description={options.withDescription ? tWidget("description") : undefined}
|
||||
placeholder={options.withPlaceholder ? tWidget("placeholder") : undefined}
|
||||
searchable={options.searchable}
|
||||
{...inputProps}
|
||||
/>
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { ActionIcon, Button, Card, Fieldset, Group, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { OptionTimezone } from "../options";
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetTimezoneListInput = ({ property, kind, options }: CommonWidgetInputProps<"timezoneList">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const tEditor = useTimezoneListTranslation(kind);
|
||||
const form = useFormContext();
|
||||
const fieldPath = `options.${property}`;
|
||||
const currentValue = form.values.options[property];
|
||||
const values = Array.isArray(currentValue) ? (currentValue as OptionTimezone[]) : options.defaultValue;
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<string | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor),
|
||||
useSensor(TouchSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Array.isArray(currentValue)) return;
|
||||
form.setFieldValue(fieldPath, options.defaultValue);
|
||||
}, [currentValue, fieldPath, form, options.defaultValue]);
|
||||
|
||||
const usedTimeZones = useMemo(() => new Set(values.map(({ timeZone }) => timeZone)), [values]);
|
||||
const availableOptions = options.timeZoneOptions.map((option) => ({
|
||||
...option,
|
||||
disabled: usedTimeZones.has(option.value),
|
||||
}));
|
||||
const isFull = values.length >= options.maxValues;
|
||||
|
||||
const setValues = (nextValues: OptionTimezone[]) => form.setFieldValue(fieldPath, nextValues);
|
||||
const addCity = (city: OptionTimezone) => {
|
||||
if (isFull || usedTimeZones.has(city.timeZone)) return;
|
||||
setValues([...values, city]);
|
||||
};
|
||||
const addSelectedTimeZone = () => {
|
||||
if (!selectedTimeZone) return;
|
||||
const option = options.timeZoneOptions.find(({ value }) => value === selectedTimeZone);
|
||||
if (!option) return;
|
||||
const label = option.label.replace(` (${option.value})`, "");
|
||||
addCity({ id: createId(), label, timeZone: option.value });
|
||||
setSelectedTimeZone(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fieldset legend={t("label")}>
|
||||
<Stack gap="sm">
|
||||
{options.withDescription && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="xs" align="end" wrap="nowrap">
|
||||
<Select
|
||||
flex={1}
|
||||
label={tEditor("timezone")}
|
||||
placeholder={tEditor("searchPlaceholder")}
|
||||
searchable
|
||||
clearable
|
||||
data={availableOptions}
|
||||
value={selectedTimeZone}
|
||||
onChange={setSelectedTimeZone}
|
||||
disabled={isFull}
|
||||
nothingFoundMessage={tEditor("noResults")}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<IconPlus size="var(--mantine-font-size-md)" />}
|
||||
onClick={addSelectedTimeZone}
|
||||
disabled={isFull || selectedTimeZone === null}
|
||||
>
|
||||
{tEditor("add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
{options.presets.map((preset) => (
|
||||
<Button
|
||||
key={preset.timeZone}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
disabled={isFull || usedTimeZones.has(preset.timeZone)}
|
||||
onClick={() => addCity({ ...preset, id: createId() })}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={({ active, over }) => {
|
||||
if (!over || active.id === over.id) return;
|
||||
const activeIndex = values.findIndex(({ id }) => id === active.id);
|
||||
const overIndex = values.findIndex(({ id }) => id === over.id);
|
||||
if (activeIndex < 0 || overIndex < 0) return;
|
||||
setValues(arrayMove(values, activeIndex, overIndex));
|
||||
}}
|
||||
>
|
||||
<SortableContext items={values.map(({ id }) => id)} strategy={verticalListSortingStrategy}>
|
||||
<Stack gap="xs">
|
||||
{values.map((city) => (
|
||||
<SortableTimezone
|
||||
key={city.id}
|
||||
kind={kind}
|
||||
city={city}
|
||||
timeZoneOptions={options.timeZoneOptions}
|
||||
usedTimeZones={usedTimeZones}
|
||||
onChange={(nextCity) =>
|
||||
setValues(values.map((candidate) => (candidate.id === city.id ? nextCity : candidate)))
|
||||
}
|
||||
onRemove={() => setValues(values.filter((candidate) => candidate.id !== city.id))}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{tEditor("limit", { count: values.length, maximum: options.maxValues })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
interface SortableTimezoneProps {
|
||||
kind: WidgetKind;
|
||||
city: OptionTimezone;
|
||||
timeZoneOptions: readonly { value: string; label: string }[];
|
||||
usedTimeZones: ReadonlySet<string>;
|
||||
onChange: (city: OptionTimezone) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const SortableTimezone = ({
|
||||
kind,
|
||||
city,
|
||||
timeZoneOptions,
|
||||
usedTimeZones,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: SortableTimezoneProps) => {
|
||||
const t = useTimezoneListTranslation(kind);
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: city.id,
|
||||
});
|
||||
const style = {
|
||||
transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : undefined,
|
||||
zIndex: isDragging ? 1 : undefined,
|
||||
};
|
||||
const hasCurrentTimeZone = timeZoneOptions.some(({ value }) => value === city.timeZone);
|
||||
const selectableTimeZones = hasCurrentTimeZone
|
||||
? timeZoneOptions
|
||||
: [{ value: city.timeZone, label: t("unsupportedOption", { timeZone: city.timeZone }) }, ...timeZoneOptions];
|
||||
|
||||
return (
|
||||
<Card ref={setNodeRef} withBorder p="xs" style={style}>
|
||||
<Group gap="xs" wrap="wrap" align="end">
|
||||
<ActionIcon
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
aria-label={t("reorder", { city: city.label })}
|
||||
style={{ cursor: "grab" }}
|
||||
>
|
||||
<IconGripVertical size="var(--mantine-font-size-lg)" />
|
||||
</ActionIcon>
|
||||
<TextInput
|
||||
flex={1}
|
||||
label={t("cityLabel")}
|
||||
value={city.label}
|
||||
onChange={(event) => onChange({ ...city, label: event.currentTarget.value })}
|
||||
/>
|
||||
<Select
|
||||
flex={2}
|
||||
label={t("timezone")}
|
||||
searchable
|
||||
data={selectableTimeZones.map((option) => ({
|
||||
...option,
|
||||
disabled: option.value !== city.timeZone && usedTimeZones.has(option.value),
|
||||
}))}
|
||||
value={city.timeZone}
|
||||
onChange={(timeZone) => {
|
||||
if (timeZone) onChange({ ...city, timeZone });
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("remove", { city: city.label })}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<IconTrash size="var(--mantine-font-size-lg)" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
type TimezoneListTranslationKey =
|
||||
| "timezone"
|
||||
| "cityLabel"
|
||||
| "searchPlaceholder"
|
||||
| "noResults"
|
||||
| "unsupportedOption"
|
||||
| "add"
|
||||
| "remove"
|
||||
| "reorder"
|
||||
| "limit";
|
||||
|
||||
type TimezoneListTranslation = (key: TimezoneListTranslationKey, values?: Record<string, string | number>) => string;
|
||||
|
||||
const useTimezoneListTranslation = (kind: WidgetKind) =>
|
||||
useScopedI18n(`widget.${kind}.timezoneList` as never) as unknown as TimezoneListTranslation;
|
||||
@@ -1,399 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { CompositeChart, Sparkline } from "@mantine/charts";
|
||||
import { Box, Group, Paper, ScrollArea, SimpleGrid, Stack, Text, VisuallyHidden } from "@mantine/core";
|
||||
import { IconMapPin } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
|
||||
import {
|
||||
getAqiCategory,
|
||||
getAqiColor,
|
||||
getAqiStandard,
|
||||
getAqiValue,
|
||||
getCompactAirQualityLayout,
|
||||
getDailyAqiValue,
|
||||
getDominantPollutant,
|
||||
getStrongestPollen,
|
||||
getUpcomingHours,
|
||||
getUvCategory,
|
||||
} from "./display";
|
||||
|
||||
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
|
||||
const t = useScopedI18n("widget.airQuality");
|
||||
const query = clientApi.widget.airQuality.atLocation.useQuery({
|
||||
latitude: options.location.latitude,
|
||||
longitude: options.location.longitude,
|
||||
});
|
||||
const airQuality = getUsableWidgetQueryData(query);
|
||||
|
||||
if (isInitialWidgetQueryPending(query)) return <WidgetQueryLoadingState />;
|
||||
if (!airQuality) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Box h="100%" w="100%" pos="relative">
|
||||
{displayMode === "advanced" ? (
|
||||
<AdvancedAirQuality airQuality={airQuality} options={options} width={width} />
|
||||
) : (
|
||||
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
|
||||
)}
|
||||
{query.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={query.error} label={t("name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface AirQualityViewProps {
|
||||
airQuality: AirQualityData;
|
||||
options: WidgetComponentProps<"airQuality">["options"];
|
||||
}
|
||||
|
||||
const CompactAirQuality = ({
|
||||
airQuality,
|
||||
height,
|
||||
options,
|
||||
width,
|
||||
}: AirQualityViewProps & { height: number; width: number }) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("widget.airQuality");
|
||||
const layout = getCompactAirQualityLayout(width, height);
|
||||
const standard = getAqiStandard(options.aqiStandard, locale);
|
||||
const value = getAqiValue(airQuality.current, standard);
|
||||
const category = getAqiCategory(value, standard);
|
||||
const dominant = getDominantPollutant(airQuality.current, standard);
|
||||
const strongestPollen = getStrongestPollen(airQuality.current);
|
||||
const labels = useAirQualityLabels();
|
||||
const sixHours = getUpcomingHours(airQuality, 6)
|
||||
.map((hour) => getAqiValue(hour, standard))
|
||||
.filter((hour): hour is number => hour !== null);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
h="100%"
|
||||
w="100%"
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={layout.tiny ? 2 : "xs"}
|
||||
p="xs"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Text fz={layout.tiny ? 34 : 42} fw={700} lh={1} c={getAqiColor(category)}>
|
||||
{formatAqi(value, locale)}
|
||||
</Text>
|
||||
<Stack gap={1} align="flex-start">
|
||||
<Text size={layout.tiny ? "xs" : "sm"} fw={600}>
|
||||
{t(`category.${category}`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{!layout.tiny && (
|
||||
<Group gap="md" justify="center" wrap="wrap">
|
||||
{options.showUv && (
|
||||
<MetricLine
|
||||
label={t("uv.label")}
|
||||
value={`${formatDecimal(airQuality.current.uvIndex, locale)} · ${t(`uv.category.${getUvCategory(airQuality.current.uvIndex)}`)}`}
|
||||
/>
|
||||
)}
|
||||
{options.showPollutants && dominant && (
|
||||
<MetricLine label={t("dominantPollutant")} value={labels.pollutants[dominant.key]} />
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!layout.tiny && layout.showParticulatesAndPollen && (
|
||||
<Group gap="md" justify="center" wrap="wrap">
|
||||
{options.showPollutants && (
|
||||
<>
|
||||
<MetricLine
|
||||
label={labels.pollutants.pm2_5}
|
||||
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale, t)}
|
||||
/>
|
||||
<MetricLine
|
||||
label={labels.pollutants.pm10}
|
||||
value={formatConcentration(airQuality.current.pollutants.pm10, locale, t)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{options.showPollen && strongestPollen && (
|
||||
<MetricLine
|
||||
label={t("strongestPollen")}
|
||||
value={`${labels.pollen[strongestPollen.key]} · ${formatPollen(strongestPollen.value, locale, t)}`}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!layout.tiny && layout.showSparkline && sixHours.length > 1 && (
|
||||
<Box component="figure" w="100%" h={34} m={0}>
|
||||
<Sparkline
|
||||
aria-hidden
|
||||
h="100%"
|
||||
w="100%"
|
||||
data={sixHours}
|
||||
color={getAqiColor(category)}
|
||||
curveType="monotone"
|
||||
fillOpacity={0.2}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<VisuallyHidden component="figcaption">{t("compact.sixHourTrend")}</VisuallyHidden>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps & { width: number }) => {
|
||||
const chartId = useId();
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("widget.airQuality");
|
||||
const standard = getAqiStandard(options.aqiStandard, locale);
|
||||
const value = getAqiValue(airQuality.current, standard);
|
||||
const category = getAqiCategory(value, standard);
|
||||
const labels = useAirQualityLabels();
|
||||
const upcoming = getUpcomingHours(airQuality, 24);
|
||||
const chartData = upcoming.map((hour) => ({
|
||||
observedAt: hour.observedAt,
|
||||
aqi: getAqiValue(hour, standard) ?? Number.NaN,
|
||||
uv: hour.uvIndex ?? Number.NaN,
|
||||
}));
|
||||
const series = [
|
||||
{ name: "aqi", label: t("aqiLabel"), color: getAqiColor(category), type: "line" as const },
|
||||
...(options.showUv
|
||||
? [{ name: "uv", label: t("uv.label"), color: "yellow.7", type: "line" as const, yAxisId: "right" as const }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
|
||||
<Stack gap="md" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2}>
|
||||
<Text fz={38} fw={700} lh={1} c={getAqiColor(category)}>
|
||||
{formatAqi(value, locale)}
|
||||
</Text>
|
||||
<Text fw={600}>{t(`category.${category}`)}</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<IconMapPin size="var(--mantine-font-size-md)" aria-hidden />
|
||||
<Text size="sm" fw={600}>
|
||||
{options.location.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("advanced.observedAt", {
|
||||
time: formatLocalizedTime(airQuality.current.observedAt, locale, { timeZone: airQuality.timezone }),
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
|
||||
<MetricCard label={t("aqiLabel")} value={formatAqi(value, locale)} />
|
||||
{options.showUv && (
|
||||
<MetricCard label={t("uv.label")} value={formatDecimal(airQuality.current.uvIndex, locale)} />
|
||||
)}
|
||||
{options.showPollutants && (
|
||||
<>
|
||||
<MetricCard
|
||||
label={labels.pollutants.pm2_5}
|
||||
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale, t)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={labels.pollutants.pm10}
|
||||
value={formatConcentration(airQuality.current.pollutants.pm10, locale, t)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={width >= 800 ? 2 : 1} spacing="md">
|
||||
<Paper withBorder p="sm">
|
||||
<Text id={chartId} size="sm" fw={600} mb="xs">
|
||||
{t("advanced.next24Hours")}
|
||||
</Text>
|
||||
{chartData.length > 0 ? (
|
||||
<>
|
||||
<Box aria-hidden>
|
||||
<CompositeChart
|
||||
h={220}
|
||||
data={chartData}
|
||||
dataKey="observedAt"
|
||||
series={series}
|
||||
curveType="monotone"
|
||||
gridAxis="y"
|
||||
tickLine="none"
|
||||
withDots={false}
|
||||
withLegend
|
||||
withRightYAxis={options.showUv}
|
||||
rightYAxisProps={{ width: 34 }}
|
||||
xAxisProps={{
|
||||
tickFormatter: (time: string) =>
|
||||
formatLocalizedTime(time, locale, { timeZone: airQuality.timezone }),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<VisuallyHidden>
|
||||
<ul aria-labelledby={chartId}>
|
||||
{upcoming.map((hour) => (
|
||||
<li key={hour.observedAt}>
|
||||
{formatLocalizedTime(hour.observedAt, locale, { timeZone: airQuality.timezone })}:{" "}
|
||||
{t("advanced.aqiValue", {
|
||||
value: formatAqi(getAqiValue(hour, standard), locale),
|
||||
})}
|
||||
{options.showUv
|
||||
? `, ${t("advanced.uvValue", { value: formatDecimal(hour.uvIndex, locale) })}`
|
||||
: ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</VisuallyHidden>
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("advanced.noForecast")}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Stack gap="md">
|
||||
{options.showPollutants && (
|
||||
<Paper withBorder p="sm">
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("advanced.pollutants")}
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{pollutantKeys.map((key) => (
|
||||
<MetricLine
|
||||
key={key}
|
||||
label={labels.pollutants[key]}
|
||||
value={formatConcentration(airQuality.current.pollutants[key], locale, t)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{options.showPollen && hasAvailableValue(airQuality.current.pollen) && (
|
||||
<Paper withBorder p="sm">
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("advanced.pollen")}
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{pollenKeys.map((key) => {
|
||||
const pollen = airQuality.current.pollen[key];
|
||||
if (pollen === null) return null;
|
||||
return <MetricLine key={key} label={labels.pollen[key]} value={formatPollen(pollen, locale, t)} />;
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder p="sm">
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("advanced.sevenDayForecast")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4, md: 7 }} spacing="xs">
|
||||
{airQuality.daily.map((day) => {
|
||||
const dailyAqi = getDailyAqiValue(day, standard);
|
||||
return (
|
||||
<Stack key={day.date} gap={2} align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatLocalizedDate(day.date, locale, { weekday: "short" })}
|
||||
</Text>
|
||||
<Text fw={700} c={getAqiColor(getAqiCategory(dailyAqi, standard))}>
|
||||
{formatAqi(dailyAqi, locale)}
|
||||
</Text>
|
||||
{options.showUv && (
|
||||
<Text size="xs">{t("advanced.uvShort", { value: formatDecimal(day.uvIndexMax, locale) })}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const MetricCard = ({ label, value }: { label: string; value: string }) => (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="lg" fw={700}>
|
||||
{value}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
const MetricLine = ({ label, value }: { label: string; value: string }) => (
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const pollutantKeys: PollutantKey[] = ["pm2_5", "pm10", "ozone", "nitrogenDioxide", "sulphurDioxide", "carbonMonoxide"];
|
||||
const pollenKeys: PollenKey[] = ["alder", "birch", "grass", "mugwort", "olive", "ragweed"];
|
||||
|
||||
const useAirQualityLabels = () => {
|
||||
const t = useScopedI18n("widget.airQuality");
|
||||
return {
|
||||
pollutants: {
|
||||
pm2_5: t("pollutant.pm2_5"),
|
||||
pm10: t("pollutant.pm10"),
|
||||
ozone: t("pollutant.ozone"),
|
||||
nitrogenDioxide: t("pollutant.nitrogenDioxide"),
|
||||
sulphurDioxide: t("pollutant.sulphurDioxide"),
|
||||
carbonMonoxide: t("pollutant.carbonMonoxide"),
|
||||
} satisfies Record<PollutantKey, string>,
|
||||
pollen: {
|
||||
alder: t("pollen.alder"),
|
||||
birch: t("pollen.birch"),
|
||||
grass: t("pollen.grass"),
|
||||
mugwort: t("pollen.mugwort"),
|
||||
olive: t("pollen.olive"),
|
||||
ragweed: t("pollen.ragweed"),
|
||||
} satisfies Record<PollenKey, string>,
|
||||
};
|
||||
};
|
||||
|
||||
const formatAqi = (value: number | null, locale: string) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(value);
|
||||
};
|
||||
|
||||
const formatDecimal = (value: number | null, locale: string) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
|
||||
};
|
||||
|
||||
type ScopedAirQualityTranslator = ReturnType<typeof useScopedI18n<"widget.airQuality">>;
|
||||
|
||||
const formatConcentration = (value: number | null, locale: string, t: ScopedAirQualityTranslator) =>
|
||||
t("unit.concentration", { value: formatDecimal(value, locale) });
|
||||
const formatPollen = (value: number | null, locale: string, t: ScopedAirQualityTranslator) =>
|
||||
t("unit.pollen", { value: formatDecimal(value, locale) });
|
||||
const hasAvailableValue = (values: Record<string, number | null>) =>
|
||||
Object.values(values).some((value) => value !== null);
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getAqiCategory,
|
||||
getAqiStandard,
|
||||
getCompactAirQualityLayout,
|
||||
getDominantPollutant,
|
||||
getStrongestPollen,
|
||||
} from "./display";
|
||||
|
||||
const point = {
|
||||
observedAt: "2026-07-01T10:00:00.000Z",
|
||||
europeanAqi: 42,
|
||||
usAqi: 60,
|
||||
uvIndex: 4,
|
||||
pollutants: { pm2_5: 12, pm10: 20, ozone: 30, nitrogenDioxide: 4, sulphurDioxide: 3, carbonMonoxide: 100 },
|
||||
europeanSubIndices: {
|
||||
pm2_5: 20,
|
||||
pm10: 31,
|
||||
ozone: 42,
|
||||
nitrogenDioxide: 10,
|
||||
sulphurDioxide: 5,
|
||||
carbonMonoxide: null,
|
||||
},
|
||||
usSubIndices: {
|
||||
pm2_5: 50,
|
||||
pm10: 40,
|
||||
ozone: 30,
|
||||
nitrogenDioxide: 20,
|
||||
sulphurDioxide: 10,
|
||||
carbonMonoxide: 60,
|
||||
},
|
||||
pollen: { alder: null, birch: 12, grass: 20, mugwort: 5, olive: null, ragweed: 2 },
|
||||
};
|
||||
|
||||
describe("air quality display", () => {
|
||||
it("uses the US scale only for US locales in automatic mode", () => {
|
||||
expect(getAqiStandard("auto", "en-US")).toBe("us");
|
||||
expect(getAqiStandard("auto", "fr-FR")).toBe("european");
|
||||
expect(getAqiStandard("auto", "en")).toBe("european");
|
||||
expect(getAqiStandard("us", "fr-FR")).toBe("us");
|
||||
});
|
||||
|
||||
it("uses the defined AQI band boundaries", () => {
|
||||
expect(getAqiCategory(20, "european")).toBe("good");
|
||||
expect(getAqiCategory(21, "european")).toBe("fair");
|
||||
expect(getAqiCategory(101, "european")).toBe("extremelyPoor");
|
||||
expect(getAqiCategory(50, "us")).toBe("good");
|
||||
expect(getAqiCategory(51, "us")).toBe("moderate");
|
||||
expect(getAqiCategory(301, "us")).toBe("hazardous");
|
||||
});
|
||||
|
||||
it("finds dominant subindices and raw pollen without combining them", () => {
|
||||
expect(getDominantPollutant(point, "european")).toEqual({ key: "ozone", value: 42 });
|
||||
expect(getDominantPollutant(point, "us")).toEqual({ key: "carbonMonoxide", value: 60 });
|
||||
expect(getStrongestPollen(point)).toEqual({ key: "grass", value: 20 });
|
||||
});
|
||||
|
||||
it("progressively reveals compact details", () => {
|
||||
expect(getCompactAirQualityLayout(149, 200)).toMatchObject({ tiny: true });
|
||||
expect(getCompactAirQualityLayout(250, 150)).toMatchObject({ showParticulatesAndPollen: true });
|
||||
expect(getCompactAirQualityLayout(300, 170)).toMatchObject({ showSparkline: true });
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
export interface AirQualityPollutants {
|
||||
pm2_5: number | null;
|
||||
pm10: number | null;
|
||||
ozone: number | null;
|
||||
nitrogenDioxide: number | null;
|
||||
sulphurDioxide: number | null;
|
||||
carbonMonoxide: number | null;
|
||||
}
|
||||
|
||||
export interface AirQualityPoint {
|
||||
observedAt: string;
|
||||
europeanAqi: number | null;
|
||||
usAqi: number | null;
|
||||
uvIndex: number | null;
|
||||
pollutants: AirQualityPollutants;
|
||||
europeanSubIndices: AirQualityPollutants;
|
||||
usSubIndices: AirQualityPollutants;
|
||||
pollen: Record<"alder" | "birch" | "grass" | "mugwort" | "olive" | "ragweed", number | null>;
|
||||
}
|
||||
|
||||
export interface AirQualityData {
|
||||
timezone: string;
|
||||
utcOffsetSeconds: number;
|
||||
current: AirQualityPoint;
|
||||
hourly: AirQualityPoint[];
|
||||
daily: {
|
||||
date: string;
|
||||
europeanAqiMax: number | null;
|
||||
usAqiMax: number | null;
|
||||
uvIndexMax: number | null;
|
||||
pm2_5Max: number | null;
|
||||
pm10Max: number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type AqiStandard = "european" | "us";
|
||||
export type AqiStandardOption = "auto" | AqiStandard;
|
||||
export type AqiCategory =
|
||||
| "good"
|
||||
| "fair"
|
||||
| "moderate"
|
||||
| "unhealthySensitive"
|
||||
| "unhealthy"
|
||||
| "poor"
|
||||
| "veryPoor"
|
||||
| "veryUnhealthy"
|
||||
| "extremelyPoor"
|
||||
| "hazardous"
|
||||
| "unknown";
|
||||
export type UvCategory = "low" | "moderate" | "high" | "veryHigh" | "extreme" | "unknown";
|
||||
export type PollutantKey = keyof AirQualityPoint["pollutants"];
|
||||
export type PollenKey = keyof AirQualityPoint["pollen"];
|
||||
|
||||
export const getAqiStandard = (option: AqiStandardOption, locale: string): AqiStandard => {
|
||||
if (option !== "auto") return option;
|
||||
try {
|
||||
return new Intl.Locale(locale).region === "US" ? "us" : "european";
|
||||
} catch {
|
||||
return "european";
|
||||
}
|
||||
};
|
||||
|
||||
export const getAqiValue = (point: AirQualityPoint, standard: AqiStandard): number | null => {
|
||||
if (standard === "us") return point.usAqi;
|
||||
return point.europeanAqi;
|
||||
};
|
||||
|
||||
export const getDailyAqiValue = (day: AirQualityData["daily"][number], standard: AqiStandard): number | null => {
|
||||
if (standard === "us") return day.usAqiMax;
|
||||
return day.europeanAqiMax;
|
||||
};
|
||||
|
||||
export const getAqiCategory = (value: number | null, standard: AqiStandard): AqiCategory => {
|
||||
if (value === null) return "unknown";
|
||||
|
||||
if (standard === "us") {
|
||||
if (value <= 50) return "good";
|
||||
if (value <= 100) return "moderate";
|
||||
if (value <= 150) return "unhealthySensitive";
|
||||
if (value <= 200) return "unhealthy";
|
||||
if (value <= 300) return "veryUnhealthy";
|
||||
return "hazardous";
|
||||
}
|
||||
|
||||
if (value <= 20) return "good";
|
||||
if (value <= 40) return "fair";
|
||||
if (value <= 60) return "moderate";
|
||||
if (value <= 80) return "poor";
|
||||
if (value <= 100) return "veryPoor";
|
||||
return "extremelyPoor";
|
||||
};
|
||||
|
||||
export const getAqiColor = (category: AqiCategory): string => {
|
||||
if (category === "good") return "green.6";
|
||||
if (category === "fair") return "lime.7";
|
||||
if (category === "moderate") return "yellow.7";
|
||||
if (category === "unhealthySensitive" || category === "poor") return "orange.7";
|
||||
if (category === "unhealthy" || category === "veryPoor") return "red.7";
|
||||
if (category === "veryUnhealthy" || category === "extremelyPoor") return "grape.7";
|
||||
if (category === "hazardous") return "dark.6";
|
||||
return "gray.6";
|
||||
};
|
||||
|
||||
export const getUvCategory = (value: number | null): UvCategory => {
|
||||
if (value === null) return "unknown";
|
||||
if (value < 3) return "low";
|
||||
if (value < 6) return "moderate";
|
||||
if (value < 8) return "high";
|
||||
if (value < 11) return "veryHigh";
|
||||
return "extreme";
|
||||
};
|
||||
|
||||
export const getDominantPollutant = (
|
||||
point: AirQualityPoint,
|
||||
standard: AqiStandard,
|
||||
): { key: PollutantKey; value: number } | null => {
|
||||
const values = standard === "us" ? point.usSubIndices : point.europeanSubIndices;
|
||||
return getHighestValue(values);
|
||||
};
|
||||
|
||||
export const getStrongestPollen = (point: AirQualityPoint): { key: PollenKey; value: number } | null =>
|
||||
getHighestValue(point.pollen);
|
||||
|
||||
const getHighestValue = <TKey extends string>(
|
||||
values: Record<TKey, number | null>,
|
||||
): { key: TKey; value: number } | null => {
|
||||
let highest: { key: TKey; value: number } | null = null;
|
||||
for (const [key, value] of Object.entries(values) as [TKey, number | null][]) {
|
||||
if (value === null) continue;
|
||||
if (highest === null || value > highest.value) highest = { key, value };
|
||||
}
|
||||
return highest;
|
||||
};
|
||||
|
||||
export const getCompactAirQualityLayout = (width: number, height: number) => ({
|
||||
tiny: width < 150 || height < 96,
|
||||
showParticulatesAndPollen: width >= 240 && height >= 140,
|
||||
showSparkline: width >= 300 && height >= 170,
|
||||
});
|
||||
|
||||
export const getUpcomingHours = (data: AirQualityData, count: number): AirQualityData["hourly"] => {
|
||||
const currentTime = Date.parse(data.current.observedAt);
|
||||
return data.hourly.filter((hour) => Date.parse(hour.observedAt) >= currentTime).slice(0, count);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { IconWind } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("airQuality", {
|
||||
icon: IconWind,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "airQuality", "atLocation"]],
|
||||
refetchInterval: 900,
|
||||
queryMatcher: ({ input }, scope) => {
|
||||
const location = scope.options.location;
|
||||
if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) {
|
||||
return false;
|
||||
}
|
||||
return widgetQueryInputMatches(input, {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
});
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
location: factory.location({
|
||||
defaultValue: {
|
||||
name: "Paris",
|
||||
latitude: 48.85341,
|
||||
longitude: 2.3488,
|
||||
},
|
||||
}),
|
||||
aqiStandard: factory.select({
|
||||
defaultValue: "auto",
|
||||
options: [
|
||||
{ value: "auto", label: (t) => t("widget.airQuality.option.aqiStandard.options.auto") },
|
||||
{ value: "european", label: (t) => t("widget.airQuality.option.aqiStandard.options.european") },
|
||||
{ value: "us", label: (t) => t("widget.airQuality.option.aqiStandard.options.us") },
|
||||
],
|
||||
}),
|
||||
showUv: factory.switch({ defaultValue: true }),
|
||||
showPollutants: factory.switch({ defaultValue: true }),
|
||||
showPollen: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -26,7 +26,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
|
||||
const app = getUsableWidgetQueryData(appQuery);
|
||||
const href = getSafeAppHref(app?.href);
|
||||
useRegisterSpotlightContextResults(
|
||||
@@ -50,7 +50,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
[app, href, options.openInNewTab],
|
||||
);
|
||||
|
||||
if (!options.appId) return <WidgetEmptyState />;
|
||||
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!app) return <WidgetEmptyState />;
|
||||
|
||||
|
||||
@@ -18,14 +18,12 @@ import {
|
||||
IconChartAreaLine,
|
||||
IconChartBar,
|
||||
IconClock,
|
||||
IconClockPlay,
|
||||
IconCloud,
|
||||
IconDeviceCctv,
|
||||
IconDeviceGamepad,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconGraphFilled,
|
||||
IconHourglass,
|
||||
IconHeadphones,
|
||||
IconHeartRateMonitor,
|
||||
IconHeartbeat,
|
||||
@@ -49,7 +47,6 @@ import {
|
||||
IconTransform,
|
||||
IconVideo,
|
||||
IconWall,
|
||||
IconWind,
|
||||
IconZoomQuestion,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
@@ -59,9 +56,6 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
clock: IconClock,
|
||||
weather: IconCloud,
|
||||
airQuality: IconWind,
|
||||
countdown: IconHourglass,
|
||||
timer: IconClockPlay,
|
||||
app: IconApps,
|
||||
assistant: IconRobot,
|
||||
iframe: IconBrowser,
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconClock } from "@tabler/icons-react";
|
||||
import type { Dayjs } from "dayjs";
|
||||
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { clockTimeFormatShowsSeconds, clockTimeFormatUses12Hours, resolveClockTimeFormat } from "./format";
|
||||
import { TimeOfDayBar } from "./time-of-day-bar";
|
||||
import { ClockWeatherSummary } from "./weather-summary";
|
||||
import {
|
||||
createWorldClockTime,
|
||||
formatRelativeOffset,
|
||||
formatUtcOffset,
|
||||
getTimeOfDayPhase,
|
||||
humanizeTimeZone,
|
||||
maximumWorldClockCities,
|
||||
} from "./world-clock";
|
||||
import type { TimeOfDayPhase, WorldClockTime } from "./world-clock";
|
||||
|
||||
interface AdvancedClockViewProps {
|
||||
now: Date;
|
||||
options: WidgetComponentProps<"clock">["options"];
|
||||
primaryTime: Dayjs;
|
||||
primaryTimeZone: string;
|
||||
primaryTimeZoneInvalid: boolean;
|
||||
}
|
||||
|
||||
export const AdvancedClockView = ({
|
||||
now,
|
||||
options,
|
||||
primaryTime,
|
||||
primaryTimeZone,
|
||||
primaryTimeZoneInvalid,
|
||||
}: AdvancedClockViewProps) => {
|
||||
const t = useScopedI18n("widget.clock");
|
||||
const primaryLabel = getPrimaryLabel(options, t);
|
||||
const primary: WorldClockTime = {
|
||||
id: "primary",
|
||||
label: primaryLabel,
|
||||
timeZone: primaryTimeZone,
|
||||
zonedTime: primaryTime,
|
||||
phase: getTimeOfDayPhase(primaryTime.hour()),
|
||||
minuteOfDay: primaryTime.hour() * 60 + primaryTime.minute() + primaryTime.second() / 60,
|
||||
utcOffset: primaryTime.utcOffset(),
|
||||
dayDifference: 0,
|
||||
};
|
||||
const cities = options.worldClockCities
|
||||
.slice(0, maximumWorldClockCities)
|
||||
.filter(({ timeZone }) => timeZone !== primaryTimeZone)
|
||||
.map((city) => createWorldClockTime({ city, now, primaryTime }));
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
w="100%"
|
||||
type="auto"
|
||||
offsetScrollbars
|
||||
scrollbarSize={6}
|
||||
aria-label={t("worldClock.advancedLabel")}
|
||||
>
|
||||
<Stack gap="md" p={{ base: "sm", sm: "md" }}>
|
||||
{primaryTimeZoneInvalid && (
|
||||
<Alert icon={<IconAlertTriangle />} color="yellow" title={t("worldClock.unsupportedPrimaryTitle")}>
|
||||
{t("worldClock.unsupportedPrimary", { timeZone: options.timezone })}
|
||||
</Alert>
|
||||
)}
|
||||
<PrimaryClock now={now} options={options} primary={primary} />
|
||||
<OverviewMode now={now} options={options} primary={primary} cities={cities} />
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModeProps {
|
||||
now: Date;
|
||||
options: WidgetComponentProps<"clock">["options"];
|
||||
primary: WorldClockTime;
|
||||
cities: WorldClockTime[];
|
||||
}
|
||||
|
||||
const PrimaryClock = ({ now, options, primary }: Pick<ModeProps, "now" | "options" | "primary">) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("widget.clock");
|
||||
const primaryTime = primary.zonedTime;
|
||||
if (!primaryTime || primary.phase === null || primary.minuteOfDay === null) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" align="flex-start" gap="lg" wrap="wrap">
|
||||
<Stack gap={5} miw={0}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon color={getPhaseColor(primary.phase)} variant="light" radius="xl" size="md">
|
||||
<IconClock size={16} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} size="h4" fw={600}>
|
||||
{primary.label}
|
||||
</Title>
|
||||
</Group>
|
||||
<ClockTime entry={primary} now={now} options={options} primary />
|
||||
{options.showDate && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatLocalizedDate(now, locale, { dateStyle: "full", timeZone: primary.timeZone })}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Badge color="gray" variant="light" tt="none">
|
||||
{primary.timeZone}
|
||||
</Badge>
|
||||
<Badge color="gray" variant="outline" tt="none">
|
||||
{formatUtcOffset(primaryTime.utcOffset())}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
{options.showWeather && (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<ClockWeatherSummary
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
animateIcon={options.animateWeatherIcon}
|
||||
detailed
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Group>
|
||||
<Divider my="sm" />
|
||||
<TimeOfDayBar
|
||||
minuteOfDay={primary.minuteOfDay}
|
||||
phase={primary.phase}
|
||||
showLegend
|
||||
showTicks
|
||||
label={t("worldClock.timeBarLabel", { city: primary.label })}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const OverviewMode = ({ now, options, primary, cities }: ModeProps) => {
|
||||
const t = useScopedI18n("widget.clock");
|
||||
if (cities.length === 0) return <EmptyCities />;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="baseline">
|
||||
<Title order={3} size="h5">
|
||||
{t("worldClock.cities")}
|
||||
</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("worldClock.cityCount", { count: cities.length })}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid component="ul" cols={{ base: 1, sm: 2 }} spacing="sm" p={0} m={0} style={{ listStyle: "none" }}>
|
||||
{cities.map((city) => (
|
||||
<WorldClockCard
|
||||
key={city.id}
|
||||
entry={city}
|
||||
now={now}
|
||||
options={options}
|
||||
primaryOffset={primary.utcOffset ?? 0}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface ClockEntryProps {
|
||||
entry: WorldClockTime;
|
||||
now: Date;
|
||||
options: WidgetComponentProps<"clock">["options"];
|
||||
primaryOffset: number;
|
||||
}
|
||||
|
||||
const WorldClockCard = ({ entry, now, options, primaryOffset }: ClockEntryProps) => {
|
||||
if (!entry.zonedTime || entry.utcOffset === null) {
|
||||
return <InvalidTimeZone entry={entry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper component="li" withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text fw={600} truncate>
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{entry.timeZone}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ClockTime entry={entry} now={now} options={options} compact />
|
||||
<DayAndOffset entry={entry} now={now} primaryOffset={primaryOffset} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const ClockTime = ({
|
||||
entry,
|
||||
now,
|
||||
options,
|
||||
primary = false,
|
||||
compact = false,
|
||||
}: Omit<ClockEntryProps, "primaryOffset"> & { primary?: boolean; compact?: boolean }) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("widget.clock");
|
||||
if (!entry.zonedTime) return null;
|
||||
let formattedTime = formatLocalizedTime(now, locale, {
|
||||
hour12: clockTimeFormatUses12Hours(options.customTimeFormat, options.is24HourFormat),
|
||||
includeSeconds: clockTimeFormatShowsSeconds(options.customTimeFormat),
|
||||
timeZone: entry.timeZone,
|
||||
});
|
||||
if (primary) {
|
||||
formattedTime = entry.zonedTime.format(resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat));
|
||||
}
|
||||
const accessibleDate = formatLocalizedDate(now, locale, {
|
||||
dateStyle: "medium",
|
||||
timeZone: entry.timeZone,
|
||||
});
|
||||
const fontSize = compact ? { base: 30, sm: 34 } : { base: 44, sm: 56 };
|
||||
|
||||
return (
|
||||
<Text
|
||||
component="time"
|
||||
dateTime={now.toISOString()}
|
||||
fz={fontSize}
|
||||
fw={700}
|
||||
ff="var(--mantine-font-family-monospace)"
|
||||
lh={1}
|
||||
lts="-0.04em"
|
||||
aria-label={t("worldClock.accessibleTime", {
|
||||
city: entry.label,
|
||||
time: formattedTime,
|
||||
date: accessibleDate,
|
||||
timeZone: entry.timeZone,
|
||||
})}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const DayAndOffset = ({ entry, now, primaryOffset }: Pick<ClockEntryProps, "entry" | "now" | "primaryOffset">) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("widget.clock");
|
||||
if (entry.dayDifference === null || entry.utcOffset === null) return null;
|
||||
const dayRelation = getDayRelation(entry.dayDifference, t);
|
||||
const date = formatLocalizedDate(now, locale, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: entry.timeZone,
|
||||
});
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Badge color="gray" variant="light" size="sm" tt="none">
|
||||
{date} · {dayRelation}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatRelativeOffset(entry.utcOffset - primaryOffset)} · {formatUtcOffset(entry.utcOffset)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const InvalidTimeZone = ({ entry }: { entry: WorldClockTime }) => {
|
||||
const t = useScopedI18n("widget.clock");
|
||||
return (
|
||||
<Paper component="li" withBorder radius="md" p="md">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconAlertTriangle color="var(--mantine-color-yellow-6)" aria-hidden />
|
||||
<Box>
|
||||
<Text fw={600}>{entry.label}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("worldClock.unsupportedTimeZone", { timeZone: entry.timeZone })}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyCities = () => {
|
||||
const t = useScopedI18n("widget.clock");
|
||||
return (
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("worldClock.noSecondaryCities")}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
type ScopedClockTranslator = ReturnType<typeof useScopedI18n<"widget.clock">>;
|
||||
|
||||
const getDayRelation = (difference: number, t: ScopedClockTranslator) => {
|
||||
if (difference < 0) return t("worldClock.yesterday");
|
||||
if (difference > 0) return t("worldClock.tomorrow");
|
||||
return t("worldClock.today");
|
||||
};
|
||||
|
||||
const getPrimaryLabel = (options: ModeProps["options"], t: ScopedClockTranslator) => {
|
||||
if (options.customTitleToggle) return options.customTitle || t("worldClock.primary");
|
||||
if (options.useCustomTimezone) return humanizeTimeZone(options.timezone);
|
||||
return t("worldClock.local");
|
||||
};
|
||||
|
||||
const getPhaseColor = (phase: TimeOfDayPhase) => {
|
||||
if (phase === "night") return "indigo";
|
||||
if (phase === "dawn") return "orange";
|
||||
if (phase === "day") return "cyan";
|
||||
return "grape";
|
||||
};
|
||||
@@ -1,62 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Group, Loader, Stack, Text, Title } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||
import timezones from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { metricToImperial } from "@homarr/common";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AdvancedClockView } from "./advanced-view";
|
||||
import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format";
|
||||
import { useCurrentTime } from "./use-current-time";
|
||||
import { ClockWeatherSummary } from "./weather-summary";
|
||||
import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
import { WeatherDescription } from "../weather/icon";
|
||||
|
||||
dayjs.extend(advancedFormat);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezones);
|
||||
|
||||
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
|
||||
const time = useCurrentTime({ showSeconds });
|
||||
const requestedTimeZone = options.useCustomTimezone ? options.timezone : getResolvedLocalTimeZone();
|
||||
const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone);
|
||||
const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone;
|
||||
const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone);
|
||||
const secondsFormat = options.showSeconds || isAdvanced ? ":ss" : "";
|
||||
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
|
||||
const dateFormat = options.dateFormat;
|
||||
const customTimeFormat = options.customTimeFormat;
|
||||
const customDateFormat = options.customDateFormat;
|
||||
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const time = useCurrentTime({ showSeconds: options.showSeconds || isAdvanced });
|
||||
const zonedTime = time === null ? null : dayjs(time).tz(timezone);
|
||||
|
||||
if (isAdvanced) {
|
||||
if (time && zonedTime) {
|
||||
return (
|
||||
<AdvancedClockView
|
||||
now={time}
|
||||
options={options}
|
||||
primaryTime={zonedTime}
|
||||
primaryTimeZone={primaryTimeZone}
|
||||
primaryTimeZoneInvalid={primaryTimeZoneInvalid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Center h="100%">--:--</Center>;
|
||||
}
|
||||
|
||||
const resolvedTimeFormat = resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat);
|
||||
const minimumAxis = Math.min(width, height * 1.5);
|
||||
let sizing: "xs" | "sm" | "md" = "md";
|
||||
if (minimumAxis < 128) sizing = "xs";
|
||||
else if (minimumAxis < 196) sizing = "sm";
|
||||
const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md";
|
||||
const showWeatherCorner = options.showWeather && sizing !== "xs";
|
||||
|
||||
return (
|
||||
<Box className="clock-widget-container" h="100%" pos="relative">
|
||||
{showWeatherCorner && (
|
||||
<Box pos="absolute" top={4} left={4}>
|
||||
<ClockWeatherSummary
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
animateIcon={options.animateWeatherIcon}
|
||||
detailed={false}
|
||||
/>
|
||||
</Box>
|
||||
<ClockWeatherCorner
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
showDetails={isAdvanced}
|
||||
/>
|
||||
)}
|
||||
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
|
||||
{options.customTitleToggle && (
|
||||
@@ -64,14 +53,19 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
||||
{options.customTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Title className="clock-time-text" fw={700} order={getTitleOrder(sizing)} lh="1">
|
||||
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
|
||||
<time dateTime={zonedTime?.toISOString()}>
|
||||
{zonedTime === null ? "--:--" : zonedTime.format(resolvedTimeFormat)}
|
||||
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
|
||||
</time>
|
||||
</Title>
|
||||
{options.showDate && (
|
||||
{(isAdvanced || options.showDate) && (
|
||||
<Text className="clock-date-text" size={sizing} lineClamp={1}>
|
||||
{zonedTime?.format(options.dateFormat)}
|
||||
{zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -79,8 +73,117 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
||||
);
|
||||
}
|
||||
|
||||
const getTitleOrder = (sizing: "xs" | "sm" | "md") => {
|
||||
if (sizing === "md") return 2;
|
||||
if (sizing === "sm") return 4;
|
||||
return 6;
|
||||
interface ClockWeatherCornerProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locationName: string;
|
||||
isFahrenheit: boolean;
|
||||
showDetails: boolean;
|
||||
}
|
||||
|
||||
const ClockWeatherCorner = ({
|
||||
latitude,
|
||||
longitude,
|
||||
locationName,
|
||||
isFahrenheit,
|
||||
showDetails,
|
||||
}: ClockWeatherCornerProps) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const weather = weatherQuery.data;
|
||||
|
||||
if (!weather) {
|
||||
return (
|
||||
<Box pos="absolute" top={4} left={4}>
|
||||
{isInitialWidgetQueryPending(weatherQuery) ? (
|
||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
||||
) : (
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||
const unit = isFahrenheit ? "°F" : "°C";
|
||||
const today = weather.daily[0];
|
||||
const formatTemperature = (value: number | undefined) => {
|
||||
if (value === undefined) return "—";
|
||||
const preferred = isFahrenheit ? value * (9 / 5) + 32 : value;
|
||||
return `${Math.round(preferred)}${unit}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="clock-weather-corner"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
gap={showDetails ? 4 : 0}
|
||||
align="flex-start"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
|
||||
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
|
||||
{Math.round(temp)}
|
||||
{unit}
|
||||
</Text>
|
||||
{showDetails && <WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />}
|
||||
</Group>
|
||||
{showDetails && (
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" fw={600}>
|
||||
{locationName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatTemperature(today?.maxTemp)} / {formatTemperature(today?.minTemp)} · {today?.humidity ?? "—"}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
|
||||
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.weather.dailyForecast.sunrise")}: {formatClockWeatherTime(today?.sunrise)} ·{" "}
|
||||
{t("widget.weather.dailyForecast.sunset")}: {formatClockWeatherTime(today?.sunset)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const formatClockWeatherTime = (value: string | undefined) => value?.split("T")[1] ?? "—";
|
||||
|
||||
interface UseCurrentTimeProps {
|
||||
showSeconds: boolean;
|
||||
}
|
||||
|
||||
const useCurrentTime = ({ showSeconds }: UseCurrentTimeProps) => {
|
||||
const [time, setTime] = useState<Date | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout>(null);
|
||||
const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
setTime(new Date());
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
setTime(new Date());
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setTime(new Date());
|
||||
}, intervalMultiplier * 1000);
|
||||
},
|
||||
intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [intervalMultiplier, showSeconds]);
|
||||
|
||||
return time;
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
export const automaticTimeFormat = "automatic";
|
||||
|
||||
export const resolveClockTimeFormat = (format: string, legacyIs24HourFormat: boolean) => {
|
||||
if (format && format !== automaticTimeFormat) return format;
|
||||
if (legacyIs24HourFormat) return "HH:mm";
|
||||
return "hh:mm A";
|
||||
};
|
||||
|
||||
export const clockTimeFormatUses12Hours = (format: string, legacyIs24HourFormat: boolean) => {
|
||||
if (!format || format === automaticTimeFormat) return !legacyIs24HourFormat;
|
||||
const formatWithoutLiterals = format.replace(/\[[^\]]*\]/g, "");
|
||||
return /h{1,2}/.test(formatWithoutLiterals);
|
||||
};
|
||||
|
||||
export const clockTimeFormatShowsSeconds = (format: string) => {
|
||||
if (!format || format === automaticTimeFormat) return false;
|
||||
const formatWithoutLiterals = format.replace(/\[[^\]]*\]/g, "");
|
||||
return /s{1,2}/.test(formatWithoutLiterals);
|
||||
};
|
||||
@@ -3,18 +3,9 @@ import dayjs from "dayjs";
|
||||
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import {
|
||||
defaultWorldClockCities,
|
||||
getTimeZoneOptions,
|
||||
maximumWorldClockCities,
|
||||
worldClockCityPresets,
|
||||
} from "./world-clock";
|
||||
|
||||
const timeZoneOptions = getTimeZoneOptions();
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "weather", "atLocation"]],
|
||||
queryMatcher: ({ input }, scope) => {
|
||||
const location = scope.options.weatherLocation;
|
||||
@@ -46,9 +37,12 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showSeconds: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
useCustomTimezone: factory.switch({ defaultValue: false }),
|
||||
timezone: factory.select({
|
||||
options: timeZoneOptions,
|
||||
options: Intl.supportedValuesOf("timeZone").map((value) => value),
|
||||
defaultValue: "Europe/London",
|
||||
searchable: true,
|
||||
withDescription: true,
|
||||
@@ -58,29 +52,24 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
}),
|
||||
dateFormat: factory.select({
|
||||
options: [
|
||||
{ value: "YYYY-MM-DD", label: `${dayjs().format("YYYY-MM-DD")} · YYYY-MM-DD` },
|
||||
{ value: "DD/MM/YYYY", label: `${dayjs().format("DD/MM/YYYY")} · DD/MM/YYYY` },
|
||||
{ value: "MM/DD/YYYY", label: `${dayjs().format("MM/DD/YYYY")} · MM/DD/YYYY` },
|
||||
{ value: "dddd, MMMM D", label: `${dayjs().format("dddd, MMMM D")} · dddd, MMMM D` },
|
||||
{ value: "dddd, D MMMM", label: `${dayjs().format("dddd, D MMMM")} · dddd, D MMMM` },
|
||||
{ value: "MMM D, YYYY", label: `${dayjs().format("MMM D, YYYY")} · MMM D, YYYY` },
|
||||
{ value: "D MMM YYYY", label: `${dayjs().format("D MMM YYYY")} · D MMM YYYY` },
|
||||
{ value: "MMM D", label: `${dayjs().format("MMM D")} · MMM D` },
|
||||
{ value: "D MMM", label: `${dayjs().format("D MMM")} · D MMM` },
|
||||
{ value: "DD/MM", label: `${dayjs().format("DD/MM")} · DD/MM` },
|
||||
{ value: "MM/DD", label: `${dayjs().format("MM/DD")} · MM/DD` },
|
||||
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
|
||||
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
|
||||
{ value: "MMM D", label: dayjs().format("MMM D") },
|
||||
{ value: "D MMM", label: dayjs().format("D MMM") },
|
||||
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
|
||||
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
|
||||
{ value: "DD/MM", label: dayjs().format("DD/MM") },
|
||||
{ value: "MM/DD", label: dayjs().format("MM/DD") },
|
||||
],
|
||||
defaultValue: "dddd, MMMM D",
|
||||
withDescription: true,
|
||||
}),
|
||||
customTimeFormat: factory.select({
|
||||
options: [
|
||||
{ value: "HH:mm", label: `${dayjs().format("HH:mm")} · HH:mm` },
|
||||
{ value: "HH:mm:ss", label: `${dayjs().format("HH:mm:ss")} · HH:mm:ss` },
|
||||
{ value: "h:mm A", label: `${dayjs().format("h:mm A")} · h:mm A` },
|
||||
{ value: "h:mm:ss A", label: `${dayjs().format("h:mm:ss A")} · h:mm:ss A` },
|
||||
],
|
||||
defaultValue: "HH:mm",
|
||||
customTimeFormat: factory.text({
|
||||
defaultValue: "",
|
||||
withDescription: true,
|
||||
}),
|
||||
customDateFormat: factory.text({
|
||||
defaultValue: "",
|
||||
withDescription: true,
|
||||
}),
|
||||
showWeather: factory.switch({
|
||||
@@ -97,22 +86,8 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
isWeatherFormatFahrenheit: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
animateWeatherIcon: factory.switch({
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
}),
|
||||
worldClockCities: factory.timezoneList({
|
||||
defaultValue: defaultWorldClockCities.map((city) => ({ ...city })),
|
||||
maxValues: maximumWorldClockCities,
|
||||
presets: worldClockCityPresets,
|
||||
timeZoneOptions,
|
||||
withDescription: true,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
is24HourFormat: {
|
||||
shouldHide: () => true,
|
||||
},
|
||||
customTitle: {
|
||||
shouldHide: (options) => !options.customTitleToggle,
|
||||
},
|
||||
@@ -128,9 +103,6 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
isWeatherFormatFahrenheit: {
|
||||
shouldHide: (options) => !options.showWeather,
|
||||
},
|
||||
animateWeatherIcon: {
|
||||
shouldHide: (options) => !options.showWeather,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Badge, Box, Group, Progress, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { TimeOfDayPhase } from "./world-clock";
|
||||
|
||||
interface TimeOfDayBarProps {
|
||||
minuteOfDay: number;
|
||||
phase: TimeOfDayPhase;
|
||||
showLegend?: boolean;
|
||||
showTicks?: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const phaseSections = [
|
||||
{ key: "nightStart", value: 20.833, color: "indigo.8" },
|
||||
{ key: "dawn", value: 12.5, color: "orange.6" },
|
||||
{ key: "day", value: 41.667, color: "cyan.6" },
|
||||
{ key: "dusk", value: 12.5, color: "grape.6" },
|
||||
{ key: "nightEnd", value: 12.5, color: "indigo.8" },
|
||||
] as const;
|
||||
|
||||
const phaseLegend = [
|
||||
{ key: "night", color: "indigo.8" },
|
||||
{ key: "dawn", color: "orange.6" },
|
||||
{ key: "day", color: "cyan.6" },
|
||||
{ key: "dusk", color: "grape.6" },
|
||||
] as const;
|
||||
|
||||
const timeTicks = ["midnight", "morning", "noon", "evening", "end"] as const;
|
||||
|
||||
export const TimeOfDayBar = ({
|
||||
minuteOfDay,
|
||||
phase,
|
||||
showLegend = false,
|
||||
showTicks = false,
|
||||
label,
|
||||
}: TimeOfDayBarProps) => {
|
||||
const t = useScopedI18n("widget.clock");
|
||||
const progress = Math.max(0, Math.min(100, (minuteOfDay / 1440) * 100));
|
||||
const remainingMinutes = Math.max(0, 1440 - Math.floor(minuteOfDay));
|
||||
const remainingHours = Math.floor(remainingMinutes / 60);
|
||||
const remainder = remainingMinutes % 60;
|
||||
|
||||
return (
|
||||
<Stack component="figure" gap={6} m={0} aria-label={label}>
|
||||
<Group justify="space-between" align="center" gap="xs" wrap="nowrap">
|
||||
<Badge color={getPhaseColor(phase)} variant="dot" size="sm">
|
||||
{t(`worldClock.phase.${phase}`)}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
{t("worldClock.dayProgress", {
|
||||
percent: Math.round(progress),
|
||||
hours: remainingHours,
|
||||
minutes: remainder.toString().padStart(2, "0"),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box pos="relative">
|
||||
<Progress.Root size="md" radius="xl" aria-hidden>
|
||||
{phaseSections.map((section) => (
|
||||
<Progress.Section key={section.key} value={section.value} color={section.color} withAria={false} />
|
||||
))}
|
||||
</Progress.Root>
|
||||
<Box
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left={`${progress}%`}
|
||||
w={4}
|
||||
h={24}
|
||||
bg="var(--mantine-color-text)"
|
||||
style={{ borderRadius: "var(--mantine-radius-xl)", transform: "translate(-50%, -50%)" }}
|
||||
/>
|
||||
</Box>
|
||||
{showTicks && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
{timeTicks.map((tick) => (
|
||||
<Text key={tick} size="xs" c="dimmed" aria-hidden>
|
||||
{t(`worldClock.tick.${tick}`)}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{showLegend && (
|
||||
<Group gap="md" mt={4} align="center" wrap="wrap">
|
||||
<Text size="xs" fw={600}>
|
||||
{t("worldClock.phaseLegend")}
|
||||
</Text>
|
||||
{phaseLegend.map((item) => (
|
||||
<Group key={item.key} gap={6} wrap="nowrap">
|
||||
<Box w={9} h={9} bg={item.color} style={{ borderRadius: "var(--mantine-radius-xl)" }} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(`worldClock.phase.${item.key}`)} · {t(`worldClock.phaseRange.${item.key}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const getPhaseColor = (phase: TimeOfDayPhase) => {
|
||||
if (phase === "night") return "indigo";
|
||||
if (phase === "dawn") return "orange";
|
||||
if (phase === "day") return "cyan";
|
||||
return "grape";
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export const useCurrentTime = ({ showSeconds }: { showSeconds: boolean }) => {
|
||||
const [time, setTime] = useState<Date | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(null);
|
||||
const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
setTime(new Date());
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
setTime(new Date());
|
||||
intervalRef.current = setInterval(() => setTime(new Date()), intervalMultiplier * 1000);
|
||||
},
|
||||
intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [intervalMultiplier, showSeconds]);
|
||||
|
||||
return time;
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
import { WeatherDescription } from "../weather/icon";
|
||||
|
||||
interface ClockWeatherSummaryProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locationName: string;
|
||||
isFahrenheit: boolean;
|
||||
animateIcon: boolean;
|
||||
detailed: boolean;
|
||||
}
|
||||
|
||||
export const ClockWeatherSummary = ({
|
||||
latitude,
|
||||
longitude,
|
||||
locationName,
|
||||
isFahrenheit,
|
||||
animateIcon,
|
||||
detailed,
|
||||
}: ClockWeatherSummaryProps) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const weather = weatherQuery.data;
|
||||
|
||||
if (!weather) {
|
||||
return isInitialWidgetQueryPending(weatherQuery) ? (
|
||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
||||
) : (
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
);
|
||||
}
|
||||
|
||||
const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||
const unit = isFahrenheit ? "°F" : "°C";
|
||||
const today = weather.daily[0];
|
||||
const formatTemperature = (value: number | null | undefined) => {
|
||||
if (value === undefined || value === null) return "—";
|
||||
const preferred = isFahrenheit ? value * (9 / 5) + 32 : value;
|
||||
return `${Math.round(preferred)}${unit}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={detailed ? 3 : 0} align="flex-start">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<AnimatedWeatherIcon
|
||||
animated={animateIcon}
|
||||
code={weather.current.weatherCode}
|
||||
isDay={weather.current.isDay}
|
||||
size={detailed ? 28 : 22}
|
||||
/>
|
||||
<Text size={detailed ? "sm" : "xs"} fw={detailed ? 600 : undefined} c={detailed ? undefined : "dimmed"}>
|
||||
{Math.round(temperature)}
|
||||
{unit}
|
||||
</Text>
|
||||
{detailed && <WeatherDescription weatherOnly weatherCode={weather.current.weatherCode} />}
|
||||
</Group>
|
||||
{detailed && (
|
||||
<Box>
|
||||
<Text size="xs" fw={600} lineClamp={1}>
|
||||
{locationName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatTemperature(today?.maxTemperature)} / {formatTemperature(today?.minTemperature)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
createWorldClockTime,
|
||||
formatRelativeOffset,
|
||||
formatUtcOffset,
|
||||
getTimeOfDayPhase,
|
||||
getTimeZoneOptions,
|
||||
} from "./world-clock";
|
||||
|
||||
describe("world clock helpers", () => {
|
||||
test("adds UTC to the available timezone options", () => {
|
||||
expect(getTimeZoneOptions()).toContainEqual({ value: "UTC", label: "UTC (UTC)" });
|
||||
});
|
||||
|
||||
test("classifies the fixed time-of-day phases at their boundaries", () => {
|
||||
expect([0, 4, 5, 7, 8, 17, 18, 20, 21, 23].map(getTimeOfDayPhase)).toEqual([
|
||||
"night",
|
||||
"night",
|
||||
"dawn",
|
||||
"dawn",
|
||||
"day",
|
||||
"day",
|
||||
"dusk",
|
||||
"dusk",
|
||||
"night",
|
||||
"night",
|
||||
]);
|
||||
});
|
||||
|
||||
test("formats whole-hour and fractional offsets", () => {
|
||||
expect(formatUtcOffset(345)).toBe("UTC+05:45");
|
||||
expect(formatRelativeOffset(-210)).toBe("−3h 30m");
|
||||
expect(formatRelativeOffset(0)).toBe("±0h");
|
||||
});
|
||||
|
||||
test("compares civil dates across the date line from one instant", () => {
|
||||
const now = new Date("2026-01-01T02:30:00.000Z");
|
||||
const primaryTime = dayjs(now).tz("America/New_York");
|
||||
const tokyo = createWorldClockTime({
|
||||
city: { id: "tokyo", label: "Tokyo", timeZone: "Asia/Tokyo" },
|
||||
now,
|
||||
primaryTime,
|
||||
});
|
||||
|
||||
expect(primaryTime.format("YYYY-MM-DD")).toBe("2025-12-31");
|
||||
expect(tokyo.zonedTime?.format("YYYY-MM-DD")).toBe("2026-01-01");
|
||||
expect(tokyo.dayDifference).toBe(1);
|
||||
});
|
||||
|
||||
test("isolates unsupported timezones", () => {
|
||||
const now = new Date("2026-01-01T00:00:00.000Z");
|
||||
const result = createWorldClockTime({
|
||||
city: { id: "invalid", label: "Invalid", timeZone: "Invalid/Timezone" },
|
||||
now,
|
||||
primaryTime: dayjs(now).tz("UTC"),
|
||||
});
|
||||
|
||||
expect(result.zonedTime).toBeNull();
|
||||
expect(result.utcOffset).toBeNull();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user