Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b6f08a36 | ||
|
|
5cd627d66c | ||
|
|
002381d294 | ||
|
|
9b406d5d83 | ||
|
|
4c475b8c0a | ||
|
|
f3014fe271 | ||
|
|
bd4f6a92c6 | ||
|
|
7e8c139590 | ||
|
|
d241bac55d | ||
|
|
58a24569ce |
No files matched your search
@@ -374,21 +374,31 @@ jobs:
|
||||
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||
run: |
|
||||
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
|
||||
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
||||
|
||||
- name: Verify preview manifest platforms
|
||||
if: "!env.ACT"
|
||||
run: |
|
||||
image="${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
tags=("${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=("pr-6545")
|
||||
fi
|
||||
for tag in "${tags[@]}"; do
|
||||
image="${PREVIEW_IMAGE}:${tag}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
|
||||
cleanup:
|
||||
name: Clean up preview image
|
||||
|
||||
@@ -27,37 +27,103 @@ jobs:
|
||||
- run: pnpm test:workshop
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish:
|
||||
publish-amd64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v6
|
||||
id: metadata
|
||||
with:
|
||||
images: ghcr.io/homarr-labs/workshop
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=v2,enable=${{ github.ref_name == 'v2' || github.ref_name == 'release/v2' }}
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=workshop-linux-amd64
|
||||
cache-to: type=gha,scope=workshop-linux-amd64,mode=max
|
||||
|
||||
publish-arm64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/arm64
|
||||
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=workshop-linux-arm64
|
||||
cache-to: type=gha,scope=workshop-linux-arm64,mode=max
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: [publish-amd64, publish-arm64]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
WORKSHOP_IMAGE: ghcr.io/homarr-labs/workshop
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish multi-platform image
|
||||
env:
|
||||
AMD64_DIGEST: ${{ needs.publish-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.publish-arm64.outputs.digest }}
|
||||
run: |
|
||||
tags=(-t "${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}")
|
||||
if [[ "${GITHUB_REF_NAME}" == "v2" || "${GITHUB_REF_NAME}" == "release/v2" ]]; then
|
||||
tags+=(-t "${WORKSHOP_IMAGE}:v2")
|
||||
fi
|
||||
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
|
||||
tags+=(-t "${WORKSHOP_IMAGE}:latest")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${WORKSHOP_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${WORKSHOP_IMAGE}@${ARM64_DIGEST}"
|
||||
- name: Verify multi-platform image
|
||||
run: |
|
||||
image="${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
@@ -204,10 +204,13 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
||||
Spotlight. It lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
||||
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
|
||||
destinations before the widget is created, so the common flow does not require a second move step.
|
||||
While edit mode is active, **Add board content** opens a menu for adding a widget, app, integration, or Container. The
|
||||
available actions respect the current user's permissions. Widget setup offers the main canvas, enabled rails, and existing
|
||||
Containers as destinations before the widget is created, so the common flow does not require a second move step.
|
||||
|
||||
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
|
||||
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
|
||||
selection. A move is applied only when every selected item fits in the destination.
|
||||
|
||||
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
|
||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||
@@ -227,11 +230,7 @@ During a pointer or touch interaction, Escape cancels the move or resize and res
|
||||
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
|
||||
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
||||
|
||||
<img
|
||||
src={keyboardTileEditingImage}
|
||||
alt="A focused board tile in keyboard editing mode"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
<img src={keyboardTileEditingImage} alt="A focused board tile in keyboard editing mode" style={{ maxWidth: "100%" }} />
|
||||
|
||||
#### Preview
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "Air Quality & UV"
|
||||
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { airQualityWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={airQualityWidget} categories={["Health", "Weather"]} />
|
||||
|
||||
The Air Quality & UV widget shows the current air quality index for a selected location. It adds the UV index,
|
||||
dominant pollutant, particulate matter and the highest available pollen reading as space allows. Open the advanced
|
||||
view for a 24-hour AQI and UV chart, all available pollutant and pollen readings, and seven-day daily maxima.
|
||||
|
||||
Select the European or US AQI scale, or use the automatic setting. Automatic uses the US scale for viewers whose
|
||||
locale region is the United States and the European scale everywhere else. The selected scale stays in widget options
|
||||
instead of being repeated beside every reading. Pollen values are individual measured concentrations; the widget does
|
||||
not combine them into a single pollen index.
|
||||
|
||||
The categories and colors summarize published index bands and are not medical advice. Pollutant concentrations use
|
||||
micrograms per cubic meter (`µg/m³`), and pollen uses grains per cubic meter.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={airQualityWidget.configuration} />
|
||||
|
||||
### Data source
|
||||
|
||||
Homarr uses the free Open-Meteo Air Quality API. The widget refreshes active data every 15 minutes and requests a
|
||||
seven-day forecast for European AQI, US AQI, UV, particulate matter, ozone, nitrogen dioxide, sulphur dioxide, carbon
|
||||
monoxide, and available alder, birch, grass, mugwort, olive and ragweed pollen. Identical location requests reuse the
|
||||
server response for 15 minutes. If a refresh fails, Homarr can keep a recent successful response visible for up to 24
|
||||
hours instead of replacing it with an empty state.
|
||||
|
||||
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. If you need to allowlist the
|
||||
service, use `https://air-quality-api.open-meteo.com`.
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { WidgetDefinition } from "@site/src/types";
|
||||
import { IconWind } from "@tabler/icons-react";
|
||||
|
||||
export const airQualityWidget: WidgetDefinition = {
|
||||
icon: IconWind,
|
||||
name: "Air Quality & UV",
|
||||
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location.",
|
||||
path: "../../widgets/air-quality",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Air quality location",
|
||||
description: "Location used for air quality, UV and pollen data",
|
||||
values: "Select a location through search or longitude/latitude",
|
||||
defaultValue: "Paris / 48.85341, 2.3488",
|
||||
},
|
||||
{
|
||||
name: "AQI standard",
|
||||
description: "Choose the European or US air quality scale, or select one from the viewer's locale",
|
||||
values: { type: "select", options: ["Automatic for locale", "European AQI", "US AQI"] },
|
||||
defaultValue: "Automatic for locale",
|
||||
},
|
||||
{
|
||||
name: "Show UV index",
|
||||
description: "Display the current UV index and its forecast",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show pollutant concentrations",
|
||||
description: "Display particulate matter and gaseous pollutant concentrations",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show pollen concentrations",
|
||||
description: "Display available pollen concentrations",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import beszelSystemGrid from "./img/beszel-system-grid.png";
|
||||
|
||||
<WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} />
|
||||
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Resizing the widget adjusts its rows and columns to keep the cards balanced. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
|
||||
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||
|
||||
|
||||
@@ -15,6 +15,26 @@ import { clockWidget } from ".";
|
||||
|
||||
The clock widget displays the current date and time in a customizable format.
|
||||
|
||||
Advanced view turns it into a World Clock. The configured primary timezone stays prominent while New York, Paris, and
|
||||
Tokyo are included as secondary defaults. You can add, rename, remove, and reorder up to six cities in widget options;
|
||||
London, Sydney, and UTC are also available as quick presets.
|
||||
|
||||
The digital advanced view keeps the primary timezone prominent and presents every secondary city as a concise,
|
||||
comparable card. The primary clock includes a segmented 24-hour rail with a current-time marker, elapsed and remaining
|
||||
time, six-hour ticks, and a legend for night, dawn, day, and dusk. These phases are based on local clock time and are not
|
||||
astronomical sunrise or sunset calculations. Secondary cards stop after their localized date and timezone offsets.
|
||||
|
||||
The compact widget remains a single numeric clock. The time-format selector shows common Day.js presets with live
|
||||
examples and applies to the primary clock. Choosing a format with seconds enables second-level updates; 12-hour and
|
||||
24-hour display are selected through those same formats. The date-format selector likewise
|
||||
shows live Day.js examples and their tokens. Secondary cities always include a short localized date and a
|
||||
Yesterday, Today, or Tomorrow label relative to the primary timezone. UTC and relative offsets follow current timezone
|
||||
rules, including daylight-saving changes.
|
||||
|
||||
Weather remains optional and uses its own configured location rather than a World Clock city. Its icon is static by
|
||||
default and can be animated explicitly; reduced-motion preferences always disable animation. An unsupported saved
|
||||
timezone is identified without preventing valid clocks from rendering.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import localClock from "./img/local.png";
|
||||
@@ -27,7 +47,7 @@ import tokyoClock from "./img/tokyo-12-hour.png";
|
||||
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
|
||||
</div>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -20,18 +20,6 @@ export const clockWidget: WidgetDefinition = {
|
||||
values: { type: "string" },
|
||||
defaultValue: "-",
|
||||
},
|
||||
{
|
||||
name: "24-hour format",
|
||||
description: "Use 24-hour format instead of 12-hour format.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Display seconds",
|
||||
description: "Whether to show seconds in the clock.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Use fixed timezone",
|
||||
description: "Select custom timezone to display instead of the client timezone.",
|
||||
@@ -52,21 +40,46 @@ export const clockWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Date format",
|
||||
description: "Format of the date displayed below the clock.",
|
||||
values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.",
|
||||
description: "Choose a common Day.js date format with a live example and its tokens.",
|
||||
values: "YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, or common long and short date formats",
|
||||
defaultValue: "dddd, MMMM D",
|
||||
},
|
||||
{
|
||||
name: "Custom time format",
|
||||
description: "Format of the time displayed in the clock.",
|
||||
values: 'ISO 8601 format, e.g. "HH:mm:ss", "hh:mm A", etc.',
|
||||
defaultValue: "-",
|
||||
name: "Time format",
|
||||
description:
|
||||
"Choose a common Day.js time format with a live example. The format controls 12/24-hour display and seconds.",
|
||||
values: "HH:mm, HH:mm:ss, h:mm A, or h:mm:ss A",
|
||||
defaultValue: "HH:mm",
|
||||
},
|
||||
{
|
||||
name: "Custom date format",
|
||||
description: "Format of the date displayed below the clock.",
|
||||
values: 'ISO 8601 format, e.g. "YYYY-MM-DD", "DD/MM/YYYY", etc.',
|
||||
defaultValue: "-",
|
||||
name: "Show weather",
|
||||
description: "Show a compact weather summary from an independently configured location.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Weather location",
|
||||
description: 'Location used by the weather summary. Only shown if "Show weather" is enabled.',
|
||||
values: "A searched city or custom latitude and longitude",
|
||||
defaultValue: "Brisbane",
|
||||
},
|
||||
{
|
||||
name: "Use Fahrenheit",
|
||||
description: 'Use Fahrenheit in the weather summary. Only shown if "Show weather" is enabled.',
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Animate weather icon",
|
||||
description: "Animate the weather icon unless the device requests reduced motion.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "World clock cities",
|
||||
description: "Add, label, and order up to six IANA timezones for advanced view.",
|
||||
values: "Up to six labeled IANA timezones",
|
||||
defaultValue: "New York, Paris, and Tokyo",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: "Countdowns"
|
||||
description: "Tracks upcoming events and recurring anniversaries."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { countdownWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={countdownWidget} categories={["Time"]} />
|
||||
|
||||
The Countdowns widget tracks up to 20 events without an integration. Add each event in widget options, choose its local
|
||||
date and time, and select an IANA timezone. Events can be reordered by dragging them.
|
||||
|
||||
The compact widget shows the first configured event, so dragging an event to the top selects the countdown displayed on
|
||||
the board. Advanced view uses that same event for its larger summary and preserves the configured order within its
|
||||
upcoming and completed groups. List cards keep their occurrence date at the bottom, below the optional progress bar.
|
||||
|
||||
One-time events remain completed after their target. A yearly event keeps the same local date and wall-clock time in its
|
||||
configured timezone, including when daylight-saving offsets change. A February 29 event waits until the next leap year.
|
||||
If a configured local time falls in a daylight-saving gap, the first valid time afterward is used; if it occurs twice,
|
||||
the earlier occurrence is used.
|
||||
|
||||
Progress is optional. One-time events need a start date before a percentage can be calculated. Yearly events show
|
||||
progress from the previous occurrence to the next. When enabled, seconds remain visible for every upcoming event.
|
||||
|
||||
Browser notifications are an optional local preference in advanced view and are disabled by default. Permission is
|
||||
requested only after you enable notifications, and alerts can fire once per occurrence while Homarr is open. Browser
|
||||
storage uses the board and widget identifiers to keep preferences separate and prevent duplicate alerts; clearing site
|
||||
data resets them. Widget previews do not persist alert preferences.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={countdownWidget.configuration} />
|
||||
@@ -0,0 +1,31 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconHourglass } from "@tabler/icons-react";
|
||||
|
||||
export const countdownWidget: WidgetDefinition = {
|
||||
icon: IconHourglass,
|
||||
name: "Countdowns",
|
||||
description: "Tracks upcoming events and recurring anniversaries.",
|
||||
path: "../../widgets/countdown",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Events",
|
||||
description: "Add, edit, and order one-time or yearly events with a date, time, and IANA timezone.",
|
||||
values: "Up to 20 events",
|
||||
defaultValue: "No events",
|
||||
},
|
||||
{
|
||||
name: "Show progress",
|
||||
description: "Show progress from an optional start date, or between yearly occurrences.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show seconds",
|
||||
description: "Keep seconds visible for every upcoming event, including events more than a day away.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: "Timer / Pomodoro"
|
||||
description: "Runs a simple timer or a configurable Pomodoro sequence."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { timerWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={timerWidget} categories={["Productivity", "Time"]} />
|
||||
|
||||
The Timer / Pomodoro widget runs entirely in your browser and does not require an integration. The compact widget
|
||||
always keeps its start or pause button and reset button visible. A running timer uses a saved deadline, so reopening
|
||||
the board or returning to a background tab restores the correct remaining time instead of restarting the countdown.
|
||||
|
||||
Timer mode uses one configured duration. Pomodoro mode starts with a focus session, follows most completed focus
|
||||
sessions with a short break, and uses a long break after the configured number of focus sessions. Automatic break and
|
||||
focus starts are separate options and are disabled by default. When automatic starts are enabled, elapsed phases are
|
||||
reconciled from their original deadlines after the board returns to the foreground.
|
||||
|
||||
Advanced view shows the current sequence, configured durations, and local alert preferences. Browser notifications are
|
||||
requested only when enabled. Notification and sound preferences, live progress, deadlines, and cycle position are
|
||||
stored locally using the board and widget identifiers. They are separate for each browser or device, are available to
|
||||
guest viewers, and are removed by clearing site data. Widget previews are ephemeral and do not write local timer state.
|
||||
|
||||
Timer sounds are enabled by default and use the same low-volume Tiks sound system as onboarding: start and pause use
|
||||
rising and falling soft-theme cues, reset uses a short click, and phase completion uses the glass theme's resonant
|
||||
notification ding. You can disable them in advanced view. The cues respect the device's reduced-motion preference and
|
||||
supplement the visible timer state rather than replacing it.
|
||||
|
||||
When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the
|
||||
board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at
|
||||
300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border.
|
||||
The current phase stays pinned to the compact widget's top-left corner and wraps when needed. Timer text and controls
|
||||
use the dashboard theme's responsive size scale, with secondary details hidden when the widget preview is narrow or
|
||||
short.
|
||||
|
||||
Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them.
|
||||
The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited
|
||||
from the widget context menu.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={timerWidget.configuration} />
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { WidgetDefinition } from "@site/src/types";
|
||||
import { IconClockPlay } from "@tabler/icons-react";
|
||||
|
||||
export const timerWidget: WidgetDefinition = {
|
||||
icon: IconClockPlay,
|
||||
name: "Timer / Pomodoro",
|
||||
description: "Runs a simple timer or a configurable Pomodoro sequence.",
|
||||
path: "../../widgets/timer",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Mode",
|
||||
description: "Run one reusable timer or a repeating focus and break sequence",
|
||||
values: { type: "select", options: ["Timer", "Pomodoro"] },
|
||||
defaultValue: "Pomodoro",
|
||||
},
|
||||
{
|
||||
name: "Timer minutes",
|
||||
description: "Duration of the reusable timer",
|
||||
values: "1-1440 minutes",
|
||||
defaultValue: "10",
|
||||
},
|
||||
{
|
||||
name: "Focus minutes",
|
||||
description: "Duration of each Pomodoro focus session",
|
||||
values: "1-180 minutes",
|
||||
defaultValue: "25",
|
||||
},
|
||||
{
|
||||
name: "Short break minutes",
|
||||
description: "Duration of breaks between most focus sessions",
|
||||
values: "1-60 minutes",
|
||||
defaultValue: "5",
|
||||
},
|
||||
{
|
||||
name: "Long break minutes",
|
||||
description: "Duration of the break at the end of a Pomodoro cycle",
|
||||
values: "1-180 minutes",
|
||||
defaultValue: "15",
|
||||
},
|
||||
{
|
||||
name: "Focus sessions before a long break",
|
||||
description: "Number of completed focus sessions in one Pomodoro cycle",
|
||||
values: "1-12 sessions",
|
||||
defaultValue: "4",
|
||||
},
|
||||
{
|
||||
name: "Automatically start breaks",
|
||||
description: "Start the next short or long break when a focus session completes",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Automatically start focus sessions",
|
||||
description: "Start the next focus session when a break completes",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -13,23 +13,21 @@ import { weatherWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
|
||||
|
||||
The Weather Widget uses your location to display the current, highest and lowest temperature.
|
||||
In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch.
|
||||
The compact forecast shows up to the configured number of days. The advanced view combines current conditions with the
|
||||
available daily forecast, including humidity, wind, gusts, sunrise and sunset. Both views reuse the same cached Open
|
||||
Meteo response.
|
||||
The Weather Widget displays current conditions and a responsive forecast for a selected location. New widgets start
|
||||
with the city and a three-day forecast visible. As the widget gets smaller, it keeps the current condition and
|
||||
temperature readable before removing secondary details or forecast days.
|
||||
|
||||
### Screenshots
|
||||
Select a forecast day to inspect precipitation, apparent temperature, humidity, wind, UV index, sunrise, sunset and
|
||||
daylight duration with a mouse, keyboard or touch. Open the advanced view for a current-condition summary, a 24-hour
|
||||
temperature and precipitation chart, and the complete seven-day forecast. The chart has an equivalent data table for
|
||||
assistive technologies.
|
||||
|
||||
import forecastWeather from "./img/forecast.png";
|
||||
import currentWeather from "./img/current.png";
|
||||
import advancedWeather from "./img/advanced-view.png";
|
||||
The **Show humidity** option controls humidity across the compact current-conditions view, advanced summary, hourly
|
||||
details, and daily forecast details. Forecast cards use a roomier adaptive grid in advanced view, with four columns on
|
||||
the standard desktop surface and fewer columns as the surface narrows.
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<img src={forecastWeather} alt="Weather compact forecast" width={238} height={112} />
|
||||
<img src={currentWeather} alt="Weather compact current conditions" width={198} height={93} />
|
||||
<img src={advancedWeather} alt="Weather advanced view with seven-day forecast details" width={800} height={484} />
|
||||
</div>
|
||||
Weather icons are static by default. Animation can be enabled in the widget configuration, but the operating system's
|
||||
reduced-motion preference always disables it.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
@@ -43,5 +41,10 @@ import advancedWeather from "./img/advanced-view.png";
|
||||
|
||||
### Data source
|
||||
|
||||
We use the Open Meteo API to fetch the weather and locations.
|
||||
We use the Open Meteo API to fetch the weather and locations. Active weather data refreshes every ten minutes, while
|
||||
identical location requests can reuse a server response for five minutes. A manual refresh may therefore return the
|
||||
same still-current server response. If a refresh fails, Homarr keeps the last successful data visible and shows an
|
||||
error indicator.
|
||||
|
||||
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests.
|
||||
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
|
||||
@@ -28,8 +28,24 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show humidity",
|
||||
description: "Display humidity in current conditions, hourly details, and daily forecasts",
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Animate weather icons",
|
||||
description: "Animate condition icons unless the operating system requests reduced motion",
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Use imperial speed units",
|
||||
description: "Display wind speed in mph instead of km/h",
|
||||
@@ -59,7 +75,7 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Has forecast",
|
||||
@@ -67,13 +83,13 @@ export const weatherWidget: WidgetDefinition = {
|
||||
values: {
|
||||
type: "boolean",
|
||||
},
|
||||
defaultValue: "no",
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Amount of forecast days",
|
||||
description: "Number of days to show in the forecast",
|
||||
values: "1-7",
|
||||
defaultValue: "5",
|
||||
defaultValue: "3",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
|
||||
import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
|
||||
import { CommonWidgetProps, WidgetCard } from "./card";
|
||||
import clsx from "clsx";
|
||||
|
||||
@@ -6,21 +6,27 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => {
|
||||
return (
|
||||
<WidgetCard width={1} className={clsx("gap-2", className)}>
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
<IconCloud size={20} />
|
||||
<span className="text-xl font-bold">10.8°C</span>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
<IconWind size={14} />
|
||||
<span className="text-sm">5 km/h</span>
|
||||
</div>
|
||||
<div className="flex gap-1 justify-center items-center">
|
||||
<div className="flex justify-center items-center">
|
||||
<IconArrowUpRight size={14} />
|
||||
<span className="text-sm">13.5°C</span>
|
||||
<IconSun size={22} />
|
||||
<div className="flex flex-col leading-none">
|
||||
<span className="text-xl font-bold">18.4°C</span>
|
||||
<span className="text-xs text-muted-foreground">Paris</span>
|
||||
</div>
|
||||
<div className="flex justify-center items-center">
|
||||
<IconArrowDownRight size={14} />
|
||||
<span className="text-sm">6.4°C</span>
|
||||
</div>
|
||||
<div className="flex justify-evenly items-center w-full">
|
||||
<div className="flex flex-col items-center text-xs">
|
||||
<span>Mon</span>
|
||||
<IconSun size={16} />
|
||||
<span>21°</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-xs">
|
||||
<span>Tue</span>
|
||||
<IconCloud size={16} />
|
||||
<span>19°</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-xs">
|
||||
<span>Wed</span>
|
||||
<IconCloudRain size={16} />
|
||||
<span>16°</span>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetCard>
|
||||
|
||||
+3
@@ -2106,6 +2106,9 @@
|
||||
"enum": [
|
||||
"clock",
|
||||
"weather",
|
||||
"airQuality",
|
||||
"countdown",
|
||||
"timer",
|
||||
"app",
|
||||
"iframe",
|
||||
"video",
|
||||
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
||||
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
|
||||
import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
|
||||
import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
@@ -63,71 +65,73 @@ export const ClientBoard = () => {
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
<BoardSelectionProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
<BoardSelectionToolbar />
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</BoardSelectionProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
|
||||
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useContainerActions } from "~/components/board/sections/container/container-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export default function BoardEditActions() {
|
||||
@@ -17,9 +22,13 @@ export default function BoardEditActions() {
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -29,29 +38,69 @@ const AddMenu = () => {
|
||||
useEffect(() => {
|
||||
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
||||
handledInitialAdd.current = true;
|
||||
addWidget();
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("add");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [addWidget, board.id, pathname, router, searchParams]);
|
||||
}, [board.id, openItemSelectModal, pathname, router, searchParams]);
|
||||
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
};
|
||||
|
||||
const handleSelectApp = () => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
onSelectMany: (apps) => {
|
||||
for (const app of apps) {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
}
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddIntegration = () => {
|
||||
openIntegrationSelectModal({ completionBoardId: board.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<HeaderButton
|
||||
aria-label={t("board.action.addContent")}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
boardActions: {
|
||||
widget: addWidget,
|
||||
app: addApp,
|
||||
integration: canConnectService ? connectService : undefined,
|
||||
container: addContainer,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("integration-create") && (
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
||||
{t("section.container.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Procedural UI sounds for Homarr using the Web Audio API.
|
||||
* Zero audio files shipped. Pure runtime synthesis with oscillators, noise buffers, and gain envelopes.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
const getAudioContext = (): AudioContext | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
if (!audioContext) {
|
||||
const AudioContextClass =
|
||||
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioContextClass) audioContext = new AudioContextClass();
|
||||
}
|
||||
if (audioContext?.state === "suspended") void audioContext.resume().catch(() => undefined);
|
||||
return audioContext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a white noise audio buffer for percussive textures
|
||||
*/
|
||||
const createNoiseBuffer = (ctx: AudioContext, durationSeconds = 0.2): AudioBuffer => {
|
||||
const bufferSize = Math.floor(ctx.sampleRate * durationSeconds);
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Procedural trash / delete crunch sound.
|
||||
* Synthesizes a crunchy bin/paper crumple noise burst and downward pitch thud.
|
||||
*/
|
||||
export const playTrashSound = (volume = 0.35) => {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Master Gain
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.gain.setValueAtTime(volume, now);
|
||||
masterGain.connect(ctx.destination);
|
||||
|
||||
// 1. Downward sweep sub-oscillator (physical thud)
|
||||
const osc = ctx.createOscillator();
|
||||
const oscGain = ctx.createGain();
|
||||
osc.type = "triangle";
|
||||
osc.frequency.setValueAtTime(260, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(35, now + 0.18);
|
||||
oscGain.gain.setValueAtTime(0.7, now);
|
||||
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.2);
|
||||
|
||||
// 2. High-crunch noise burst (crush / crumple texture)
|
||||
try {
|
||||
const noiseBuffer = createNoiseBuffer(ctx, 0.22);
|
||||
const noiseSource = ctx.createBufferSource();
|
||||
noiseSource.buffer = noiseBuffer;
|
||||
|
||||
const noiseFilter = ctx.createBiquadFilter();
|
||||
noiseFilter.type = "bandpass";
|
||||
noiseFilter.frequency.setValueAtTime(1400, now);
|
||||
noiseFilter.frequency.exponentialRampToValueAtTime(320, now + 0.2);
|
||||
noiseFilter.Q.setValueAtTime(2.2, now);
|
||||
|
||||
const noiseGain = ctx.createGain();
|
||||
noiseGain.gain.setValueAtTime(0.9, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
|
||||
|
||||
noiseSource.connect(noiseFilter);
|
||||
noiseFilter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
|
||||
noiseSource.start(now);
|
||||
noiseSource.stop(now + 0.22);
|
||||
} catch {
|
||||
// The noise layer is optional; retain the oscillator when buffers are constrained.
|
||||
}
|
||||
} catch {
|
||||
// Sound feedback must never interrupt deleting board items.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Procedural select / pop sound.
|
||||
*/
|
||||
export const playPopSound = (volume = 0.25) => {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
const now = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(460, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(780, now + 0.05);
|
||||
|
||||
gain.gain.setValueAtTime(volume, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.07);
|
||||
} catch {
|
||||
// Sound feedback must never interrupt selecting board items.
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,19 @@
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.settingsButton {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
:global([data-type="item"]:hover) .settingsButton,
|
||||
:global([data-type="item"]:focus-within) .settingsButton,
|
||||
.settingsButton[data-menu-open="true"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.editModeInertContent {
|
||||
display: contents;
|
||||
}
|
||||
@@ -39,3 +52,9 @@
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settingsButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,14 @@ import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
|
||||
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
|
||||
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
||||
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
||||
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
@@ -103,9 +105,11 @@ const WidgetDefinitionLoadError = ({
|
||||
);
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
|
||||
const { ref, width: measuredWidth, height: measuredHeight } = useElementSize<HTMLDivElement>();
|
||||
const { getEntryRuntime } = useBoardGridPortalHost();
|
||||
const { widgetStateRef, widgetRuntimeRef } = getEntryRuntime(item.id, createBoardItemRuntime);
|
||||
const width = measuredWidth || getLogicalTrackSize(item.width);
|
||||
const height = measuredHeight || getLogicalTrackSize(item.height);
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
@@ -134,6 +138,11 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const createBoardItemRuntime = () => ({
|
||||
widgetStateRef: { current: null } as MutableRefObject<Record<string, unknown> | null>,
|
||||
widgetRuntimeRef: { current: createWidgetRuntimeState() } as WidgetRuntimeRef,
|
||||
});
|
||||
|
||||
interface LoadedBoardItemContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
@@ -227,7 +236,9 @@ const LoadedBoardItemContent = ({
|
||||
(host: HTMLDivElement | null) => {
|
||||
if (!host || !surfacePortalTarget || isAdvanced) return;
|
||||
cancelPreviewEntrance();
|
||||
host.append(surfacePortalTarget);
|
||||
if (host.childNodes.length !== 1 || host.firstChild !== surfacePortalTarget) {
|
||||
host.replaceChildren(surfacePortalTarget);
|
||||
}
|
||||
},
|
||||
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-ite
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
@@ -105,9 +106,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={12}
|
||||
right={8}
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 30 }}
|
||||
className={itemContentClasses.settingsButton}
|
||||
data-menu-open={isMenuOpen || undefined}
|
||||
data-board-widget-settings
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import {
|
||||
getWidgetConnectionStatus,
|
||||
resolveMatchingIntegrationsAsync,
|
||||
@@ -8,95 +10,254 @@ import {
|
||||
} from "./item-select-data";
|
||||
|
||||
describe("getWidgetConnectionStatus", () => {
|
||||
const availableKinds = new Set(["sonarr" as const]);
|
||||
const availableKinds = new Set<IntegrationKind>(["sonarr", "plex"]);
|
||||
|
||||
it("reports widgets with a configured compatible integration as ready", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("ready");
|
||||
describe("ready status", () => {
|
||||
it("returns 'ready' when at least one supported integration is in availableKinds", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns 'ready' when all supported integrations are in availableKinds", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr", "plex"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns 'ready' even if connectionOptional is true as long as a matching integration exists", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr"],
|
||||
availableKinds,
|
||||
connectionOptional: true,
|
||||
}),
|
||||
).toBe("ready");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports required integrations that are not configured as needing setup", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("needsSetup");
|
||||
describe("needsSetup status", () => {
|
||||
it("returns 'needsSetup' when supported integrations are non-empty, none are available, and connectionOptional is false", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["radarr", "sabNzbd"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("needsSetup");
|
||||
});
|
||||
|
||||
it("returns 'needsSetup' when availableKinds is empty and connection is required", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr"],
|
||||
availableKinds: new Set<IntegrationKind>(),
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("needsSetup");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ supportedIntegrations: [], connectionOptional: false },
|
||||
{ supportedIntegrations: ["radarr" as const], connectionOptional: true },
|
||||
])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => {
|
||||
expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe(
|
||||
"noConnectionRequired",
|
||||
);
|
||||
describe("noConnectionRequired status", () => {
|
||||
it("returns 'noConnectionRequired' when supportedIntegrations is empty regardless of connectionOptional", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: [],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("noConnectionRequired");
|
||||
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: [],
|
||||
availableKinds: new Set(),
|
||||
connectionOptional: true,
|
||||
}),
|
||||
).toBe("noConnectionRequired");
|
||||
});
|
||||
|
||||
it("returns 'noConnectionRequired' when connectionOptional is true and no supported integrations are available", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["radarr", "jellyfin"],
|
||||
availableKinds,
|
||||
connectionOptional: true,
|
||||
}),
|
||||
).toBe("noConnectionRequired");
|
||||
});
|
||||
|
||||
it("returns 'noConnectionRequired' when connectionOptional is true and availableKinds is empty", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr"],
|
||||
availableKinds: new Set(),
|
||||
connectionOptional: true,
|
||||
}),
|
||||
).toBe("noConnectionRequired");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("widget selection lock", () => {
|
||||
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
||||
describe("selection concurrency locks", () => {
|
||||
it("acquires lock on unlocked ref and sets current to true", () => {
|
||||
const lock = { current: false };
|
||||
|
||||
const acquired = tryLockSelection(lock);
|
||||
expect(acquired).toBe(true);
|
||||
expect(lock.current).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects lock acquisition on an already locked ref", () => {
|
||||
const lock = { current: true };
|
||||
|
||||
const acquired = tryLockSelection(lock);
|
||||
expect(acquired).toBe(false);
|
||||
expect(lock.current).toBe(true);
|
||||
});
|
||||
|
||||
it("unlocks lock and allows subsequent acquisition", () => {
|
||||
const lock = { current: false };
|
||||
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
expect(tryLockSelection(lock)).toBe(false);
|
||||
|
||||
unlockSelection(lock);
|
||||
expect(lock.current).toBe(false);
|
||||
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles repeated unlock safely without throwing", () => {
|
||||
const lock = { current: false };
|
||||
expect(() => {
|
||||
unlockSelection(lock);
|
||||
unlockSelection(lock);
|
||||
}).not.toThrow();
|
||||
expect(lock.current).toBe(false);
|
||||
});
|
||||
|
||||
it("prevents multiple concurrent callers from proceeding simultaneously", () => {
|
||||
const lock = { current: false };
|
||||
const results: boolean[] = [];
|
||||
|
||||
// Simulate 5 simultaneous selection attempts
|
||||
for (let i = 0; i < 5; i++) {
|
||||
results.push(tryLockSelection(lock));
|
||||
}
|
||||
|
||||
expect(results).toEqual([true, false, false, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatchingIntegrationsAsync", () => {
|
||||
it("waits for the shared integration query before a cold integration-backed selection", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [
|
||||
{ id: "matching", kind: "sonarr" as const },
|
||||
{ id: "other", kind: "radarr" as const },
|
||||
]);
|
||||
it("returns empty array immediately when widget does not support integrations without calling ensureDataAsync", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [{ id: "1", kind: "sonarr" as IntegrationKind }]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: false,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
it("does not wait for integrations when the widget has no integration support", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: false,
|
||||
supportedIntegrations: [],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(result).toEqual([]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("excludes matching integrations the user cannot use", async () => {
|
||||
it("uses currentData synchronously when available without calling ensureDataAsync", async () => {
|
||||
const currentData = [
|
||||
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
|
||||
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
|
||||
{ id: "1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
{ id: "2", kind: "radarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
{ id: "3", kind: "plex" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
];
|
||||
const ensureDataAsync = vi.fn();
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
currentData,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual([currentData[0], currentData[1]]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls ensureDataAsync when currentData is undefined and filters by supported kinds", async () => {
|
||||
const fetchedData = [
|
||||
{ id: "1", kind: "sonarr" as IntegrationKind },
|
||||
{ id: "2", kind: "radarr" as IntegrationKind },
|
||||
{ id: "3", kind: "sabNzbd" as IntegrationKind },
|
||||
];
|
||||
const ensureDataAsync = vi.fn(async () => fetchedData);
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["radarr", "sabNzbd"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual([fetchedData[1], fetchedData[2]]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("excludes integrations where permissions.hasUseAccess is explicitly false", async () => {
|
||||
const currentData = [
|
||||
{ id: "allowed-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
{ id: "blocked-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: false } },
|
||||
{ id: "allowed-no-perms", kind: "sonarr" as IntegrationKind },
|
||||
];
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData,
|
||||
ensureDataAsync: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toEqual([currentData[0], currentData[2]]);
|
||||
});
|
||||
|
||||
it("returns empty array when none of the fetched integrations match supported kinds", async () => {
|
||||
const fetchedData = [
|
||||
{ id: "1", kind: "plex" as IntegrationKind },
|
||||
{ id: "2", kind: "jellyfin" as IntegrationKind },
|
||||
];
|
||||
const ensureDataAsync = vi.fn(async () => fetchedData);
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("propagates error when ensureDataAsync rejects", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => {
|
||||
throw new Error("Network timeout");
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData,
|
||||
ensureDataAsync: vi.fn(),
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([currentData[0]]);
|
||||
).rejects.toThrow("Network timeout");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
.action {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.card:hover .action,
|
||||
.card:focus-within .action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { ConnectionStatusBadge, SupportedIntegrations, WidgetItem } from "./item-select-modal";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"item.create.connectionStatus.ready": "Ready",
|
||||
"item.create.connectionStatus.needsSetup": "Needs setup",
|
||||
"item.create.connectionStatus.noConnectionRequired": "No connection required",
|
||||
"item.create.standalone": "Standalone",
|
||||
"widget.mediaServer.name": "Media Server",
|
||||
"widget.mediaServer.description": "Stream your media",
|
||||
"widget.clock.name": "Clock",
|
||||
"widget.clock.description": "Displays current time",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/definitions", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("@homarr/definitions");
|
||||
return {
|
||||
...actual,
|
||||
getIntegrationName: (kind: string) => kind.charAt(0).toUpperCase() + kind.slice(1),
|
||||
};
|
||||
});
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("Advanced Add App - Modal Subcomponents", () => {
|
||||
describe("ConnectionStatusBadge", () => {
|
||||
it("renders 'Ready' badge with teal color when status is 'ready'", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="ready" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Ready");
|
||||
const badge = host.querySelector("span, div");
|
||||
expect(badge).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders 'Needs setup' badge with orange color when status is 'needsSetup'", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="needsSetup" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Needs setup");
|
||||
});
|
||||
|
||||
it("renders null when status is 'noConnectionRequired'", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[class*='Badge-root']")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SupportedIntegrations", () => {
|
||||
it("renders up to 5 avatars for supported integrations", async () => {
|
||||
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex"];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={integrations} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Avatars should be rendered for the 3 items
|
||||
expect(host.querySelectorAll("img, svg, div").length).toBeGreaterThan(0);
|
||||
expect(host.textContent).not.toContain("+");
|
||||
});
|
||||
|
||||
it("renders overflow indicator when 6 or more integrations are supported", async () => {
|
||||
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby", "deluge"];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={integrations} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// 6 integrations total: 4 shown, +2 in badge
|
||||
expect(host.textContent).toContain("+2");
|
||||
});
|
||||
|
||||
it("renders Standalone label when integrations list is empty", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={[]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Standalone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WidgetItem", () => {
|
||||
const FakeIcon = () => <span data-testid="widget-icon">Icon</span>;
|
||||
|
||||
it("renders widget card with icon, title, description, and connection status", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const item = {
|
||||
kind: "mediaServer" as WidgetKind,
|
||||
name: "Media Server",
|
||||
description: "Stream your media collection effortlessly",
|
||||
icon: FakeIcon as never,
|
||||
supportedIntegrations: ["plex" as IntegrationKind, "jellyfin" as IntegrationKind],
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<WidgetItem item={item} disabled={false} loading={false} connectionStatus="ready" onSelect={onSelect} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
expect(host.textContent).toContain("Media Server");
|
||||
expect(host.textContent).toContain("Stream your media collection effortlessly");
|
||||
expect(host.textContent).toContain("Ready");
|
||||
expect(host.querySelector("[data-testid='widget-icon']")).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not mark widget cards as selected", async () => {
|
||||
const item = {
|
||||
kind: "clock" as WidgetKind,
|
||||
name: "Clock",
|
||||
description: "Displays current time",
|
||||
icon: FakeIcon as never,
|
||||
supportedIntegrations: [] as IntegrationKind[],
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<WidgetItem
|
||||
item={item}
|
||||
disabled={false}
|
||||
loading={false}
|
||||
connectionStatus="noConnectionRequired"
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.hasAttribute("data-selected")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
@@ -6,16 +6,18 @@ import {
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Input,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
import { IconApi, IconBuildingStore, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -23,8 +25,8 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import {
|
||||
getIconUrl,
|
||||
getBoardLaneColumnCount,
|
||||
getIconUrl,
|
||||
getIntegrationName,
|
||||
getRootSectionLane,
|
||||
widgetIntegrationSupport,
|
||||
@@ -36,14 +38,14 @@ import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import { SelectableCard } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||
import {
|
||||
@@ -64,6 +66,16 @@ interface ItemSelectModalContentProps {
|
||||
initialSearch?: string;
|
||||
}
|
||||
|
||||
export interface WidgetItemData {
|
||||
kind: WidgetKind;
|
||||
supportedIntegrations: IntegrationKind[];
|
||||
icon: TablerIcon;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
integrationData,
|
||||
@@ -108,15 +120,14 @@ const ItemSelectModalContent = ({
|
||||
[board.sections, currentLayout, mainCanvasSection, t],
|
||||
);
|
||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
|
||||
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
|
||||
? targetSectionId
|
||||
: (placementOptions[0]?.value ?? null);
|
||||
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const { createItem, removeItem } = useItemActions();
|
||||
|
||||
useEffect(() => {
|
||||
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
|
||||
setTargetSectionId(placementOptions[0]?.value ?? null);
|
||||
}, [placementOptions, targetSectionId]);
|
||||
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const settings = useSettings();
|
||||
@@ -128,7 +139,7 @@ const ItemSelectModalContent = ({
|
||||
[integrationData],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
const items = useMemo<WidgetItemData[]>(
|
||||
() =>
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
@@ -188,7 +199,7 @@ const ItemSelectModalContent = ({
|
||||
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
|
||||
createdItem.layouts[0]?.sectionId;
|
||||
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
|
||||
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
|
||||
const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination;
|
||||
const notificationId = `board-item-created:${itemId}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
@@ -247,7 +258,7 @@ const ItemSelectModalContent = ({
|
||||
kind: "customApi",
|
||||
integrationIds: [],
|
||||
options: configuredOptions,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||
@@ -310,7 +321,7 @@ const ItemSelectModalContent = ({
|
||||
kind,
|
||||
options,
|
||||
integrationIds: newIntegrationIds,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
|
||||
@@ -377,125 +388,233 @@ const ItemSelectModalContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap="md" align="stretch">
|
||||
<Box style={{ flex: "1 1 0%", minWidth: 0 }}>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
ariaLabel={t("item.create.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
description={t("item.create.destination.description")}
|
||||
data={placementOptions}
|
||||
value={targetSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
connectionStatus={getWidgetConnectionStatus({
|
||||
supportedIntegrations: item.supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
|
||||
})}
|
||||
<Stack gap="md">
|
||||
{/* Top Search & Destination Selector */}
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
|
||||
<Box style={{ flex: "1 1 240px", minWidth: 200 }}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
aria-label={t("item.create.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
loadingSelection === null &&
|
||||
filteredItems.length === 1 &&
|
||||
filteredItems[0]
|
||||
) {
|
||||
void handleAdd(filteredItems[0].kind);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ flex: "0 1 280px", minWidth: 200 }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
data={placementOptions}
|
||||
value={effectiveSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
size="xs"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
{/* Grid of Widgets */}
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
connectionStatus={getWidgetConnectionStatus({
|
||||
supportedIntegrations: item.supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<IconApi size={14} />
|
||||
<Text size="xs" fw={600}>
|
||||
{t("customWidget.page.list.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<SelectableCard
|
||||
key={def.id}
|
||||
onClick={() => void handleAddCustomWidget(def)}
|
||||
aria-label={def.name}
|
||||
icon={
|
||||
def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={24} h={24} fit="contain" style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
|
||||
<IconApi size={20} />
|
||||
</ThemeIcon>
|
||||
)
|
||||
}
|
||||
title={def.name}
|
||||
description={def.description ?? ""}
|
||||
footerLeft={
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.create.customWidget")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<CatalogItem
|
||||
key={def.id}
|
||||
height={selectGridCardHeight}
|
||||
label={def.name}
|
||||
status={t("item.create.connectionStatus.noConnectionRequired")}
|
||||
disabled={loadingSelection !== null}
|
||||
busy={loadingSelection === `custom:${def.id}`}
|
||||
onSelect={() => void handleAddCustomWidget(def)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition("customApi").catch(() => undefined);
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition("customApi").catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
))}
|
||||
))}
|
||||
<Box mt="xs" style={{ gridColumn: "1 / -1" }}>
|
||||
<Button
|
||||
component="a"
|
||||
href="/manage/custom-widgets/workshop"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconBuildingStore size={16} />}
|
||||
>
|
||||
{t("workshop.browseWorkshop")}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
{/* Opens in a new tab so unsaved board changes survive the detour. */}
|
||||
<Button
|
||||
component="a"
|
||||
href="/manage/custom-widgets/workshop"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconBuildingStore size={16} />}
|
||||
>
|
||||
{t("workshop.browseWorkshop")}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
</Box>
|
||||
</Flex>
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// WidgetItem: SelectableCard with Inset, Title, Status Badge, and Footer
|
||||
// =========================================================================
|
||||
export const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onFocus,
|
||||
onPointerEnter,
|
||||
disabled,
|
||||
loading,
|
||||
connectionStatus,
|
||||
}: {
|
||||
item: WidgetItemData;
|
||||
onSelect: () => void;
|
||||
onFocus?: () => void;
|
||||
onPointerEnter?: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
connectionStatus: WidgetConnectionStatus;
|
||||
}) => {
|
||||
return (
|
||||
<SelectableCard
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
onClick={onSelect}
|
||||
onFocus={onFocus}
|
||||
onPointerEnter={onPointerEnter}
|
||||
aria-label={item.name}
|
||||
icon={
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
|
||||
<item.icon size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
title={item.name}
|
||||
topRight={<ConnectionStatusBadge status={connectionStatus} />}
|
||||
description={item.description}
|
||||
footerLeft={<SupportedIntegrations integrations={item.supportedIntegrations} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
|
||||
const t = useI18n();
|
||||
if (status === "noConnectionRequired") return null;
|
||||
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
|
||||
|
||||
return (
|
||||
<Badge variant="dot" color={color} size="xs" tt="none" w="fit-content" maw="100%">
|
||||
{t(`item.create.connectionStatus.${status}`)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
|
||||
const t = useI18n();
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("item.create.standalone")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const countToShow = integrations.length >= 6 ? 4 : 5;
|
||||
const moreCount = integrations.length - countToShow;
|
||||
|
||||
return (
|
||||
<Group gap={2}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
||||
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
|
||||
</Tooltip>
|
||||
))}
|
||||
{moreCount > 0 && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={
|
||||
<Stack gap={2}>
|
||||
{integrations.slice(countToShow).map((integration) => (
|
||||
<Text key={integration} size="xs">
|
||||
{getIntegrationName(integration)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Avatar radius="xl" size="xs">
|
||||
+{moreCount}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Tooltip.Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemSelectModalFrame = ({
|
||||
actions,
|
||||
innerProps,
|
||||
@@ -541,109 +660,3 @@ export const ItemSelectModal = createModal<{
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onFocus,
|
||||
onPointerEnter,
|
||||
disabled,
|
||||
loading,
|
||||
connectionStatus,
|
||||
}: {
|
||||
item: {
|
||||
kind: WidgetKind;
|
||||
supportedIntegrations: IntegrationKind[];
|
||||
name: string;
|
||||
description: string;
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onFocus: () => void;
|
||||
onPointerEnter: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
connectionStatus: WidgetConnectionStatus;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
|
||||
|
||||
return (
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={item.name}
|
||||
status={statusLabel}
|
||||
onFocus={onFocus}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled}
|
||||
busy={loading}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<ConnectionStatusBadge status={connectionStatus} />
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
|
||||
const t = useI18n();
|
||||
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
|
||||
|
||||
return (
|
||||
<Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
|
||||
{t(`item.create.connectionStatus.${status}`)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
|
||||
if (integrations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const countToShow = integrations.length >= 8 ? 6 : 7;
|
||||
const moreCount = integrations.length - countToShow;
|
||||
|
||||
return (
|
||||
<Group gap={2}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
||||
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
|
||||
</Tooltip>
|
||||
))}
|
||||
{moreCount > 0 && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={
|
||||
<>
|
||||
{integrations.slice(countToShow).map((integration) => (
|
||||
<div key={integration}>{getIntegrationName(integration)}</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Avatar radius="xl" size="xs">
|
||||
+{moreCount}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Tooltip.Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
||||
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
||||
|
||||
@@ -80,13 +83,46 @@ interface LazyWidgetEditModalContentProps {
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const Component = WidgetEditModal.component;
|
||||
const utils = clientApi.useUtils();
|
||||
const [createdIntegrations, setCreatedIntegrations] = useState<IntegrationSelectOption[]>([]);
|
||||
|
||||
const supportedKinds = useMemo(() => {
|
||||
if (!("supportedIntegrations" in props.innerProps.definition)) return [];
|
||||
return (props.innerProps.definition.supportedIntegrations ?? []).filter((k) => k !== "mock");
|
||||
}, [props.innerProps.definition]);
|
||||
|
||||
const handleOpenNewIntegration = useCallback(
|
||||
(onCreated?: (id: string) => void) => {
|
||||
openIntegrationModal({
|
||||
allowedKinds: supportedKinds,
|
||||
onSuccess: (result) => {
|
||||
if (result?.integration) {
|
||||
setCreatedIntegrations((prev) => [...prev, result.integration]);
|
||||
void utils.integration.all.invalidate();
|
||||
onCreated?.(result.integration.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[openIntegrationModal, supportedKinds, utils],
|
||||
);
|
||||
|
||||
const combinedIntegrationData = useMemo(() => {
|
||||
const existing = props.innerProps.integrationData;
|
||||
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
||||
return [...existing, ...additional];
|
||||
}, [props.innerProps.integrationData, createdIntegrations]);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
innerProps={{
|
||||
...props.innerProps,
|
||||
integrationData: combinedIntegrationData,
|
||||
integrationEditForm: LazyIntegrationEditForm,
|
||||
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -122,6 +158,5 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -62,7 +62,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{options.collapsible && !isEditMode && (
|
||||
{options.collapsible && (
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
@@ -71,7 +71,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
px={12}
|
||||
pe={options.showOpenAll ? 40 : 12}
|
||||
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
|
||||
radius="sm"
|
||||
variant="default"
|
||||
justify={options.showLabel ? "flex-start" : "center"}
|
||||
@@ -101,7 +101,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
|
||||
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import type { ComponentType, PropsWithChildren } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { GridEditorRegistryProvider, useRegisteredGridEditors } from "./grid-editor-registry";
|
||||
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
|
||||
|
||||
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
||||
type LoadedGridEditorModule = Awaited<ReturnType<typeof loadGridEditorAsync>>;
|
||||
|
||||
/**
|
||||
* Keeps the dnd-kit runtime outside the read-only bundle while guaranteeing
|
||||
@@ -16,17 +19,20 @@ type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
||||
*/
|
||||
export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const [Provider, setProvider] = useState<BoardGridEditorProviderComponent | null>(null);
|
||||
const [EditorModule, setEditorModule] = useState<LoadedGridEditorModule | null>(
|
||||
getLoadedGridEditorModule() ?? null,
|
||||
);
|
||||
const [hasLoadError, setHasLoadError] = useState(false);
|
||||
const resolvedEditorModule = EditorModule ?? getLoadedGridEditorModule() ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || Provider) return;
|
||||
if (!isEditMode || resolvedEditorModule) return;
|
||||
|
||||
let cancelled = false;
|
||||
setHasLoadError(false);
|
||||
void loadGridEditorAsync()
|
||||
.then((module) => {
|
||||
if (!cancelled) setProvider(() => module.BoardGridEditorProvider);
|
||||
if (!cancelled) setEditorModule(module);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHasLoadError(true);
|
||||
@@ -35,23 +41,34 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [Provider, isEditMode]);
|
||||
}, [isEditMode, resolvedEditorModule]);
|
||||
|
||||
if (!isEditMode) {
|
||||
return <GridEditorRuntimeProvider status="idle">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
if (hasLoadError) {
|
||||
return <GridEditorRuntimeProvider status="error">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
if (!Provider) {
|
||||
return <GridEditorRuntimeProvider status="loading">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
const runtimeStatus = !isEditMode
|
||||
? "idle"
|
||||
: hasLoadError
|
||||
? "error"
|
||||
: resolvedEditorModule
|
||||
? "ready"
|
||||
: "loading";
|
||||
const Provider: BoardGridEditorProviderComponent | null = resolvedEditorModule?.BoardGridEditorProvider ?? null;
|
||||
const GridEditor = resolvedEditorModule?.default ?? null;
|
||||
|
||||
return (
|
||||
<GridEditorRuntimeProvider status="ready">
|
||||
<Provider>{children}</Provider>
|
||||
</GridEditorRuntimeProvider>
|
||||
<GridEditorRegistryProvider>
|
||||
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
|
||||
{isEditMode && Provider && GridEditor && (
|
||||
<Provider>
|
||||
<RegisteredGridEditors GridEditor={GridEditor} />
|
||||
</Provider>
|
||||
)}
|
||||
</GridEditorRegistryProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const RegisteredGridEditors = ({ GridEditor }: { GridEditor: LoadedGridEditorModule["default"] }) => {
|
||||
const editors = useRegisteredGridEditors();
|
||||
|
||||
return Array.from(editors.values(), ({ host, disabled, ...props }) =>
|
||||
disabled ? null : createPortal(<GridEditor {...props} />, host, props.sectionId),
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useCallback, useId, useRef, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
|
||||
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
|
||||
import type { GridTransaction } from "./dnd";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
@@ -33,7 +34,7 @@ interface FixedGridItemProps {
|
||||
/**
|
||||
* Static SSR geometry in view mode and the stable content/keyboard handle in
|
||||
* edit mode. The focused tile owns grid-based keyboard movement while dnd-kit
|
||||
* handles pointer and touch input on the outer editor shell.
|
||||
* binds pointer and touch input to this same element without replacing it.
|
||||
*/
|
||||
export const FixedGridItem = ({
|
||||
item,
|
||||
@@ -45,19 +46,33 @@ export const FixedGridItem = ({
|
||||
const runtimeStatus = useGridEditorRuntimeStatus();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
|
||||
const { section, columnCount, maxRowCount, placements, interactionDisabled, announce, entryElementStore } =
|
||||
useSectionContext();
|
||||
const { commitSectionGrid } = useGridLayoutActions();
|
||||
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
|
||||
const { isSelected, toggleSelectItem } = useBoardSelection();
|
||||
const selected = item.type === "item" && isSelected(item.id);
|
||||
const registerElement = useCallback(
|
||||
(element: HTMLDivElement | null) => entryElementStore.register(item.id, element),
|
||||
[entryElementStore, 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 isEditorActive = isEditMode && runtimeStatus === "ready" && !interactionDisabled;
|
||||
const placement = toPlacement(item);
|
||||
const renderedPlacements = [...items, ...innerSections].map(toPlacement);
|
||||
const keyboardGridRef = useRef<{
|
||||
source: readonly SectionGridPlacement[];
|
||||
current: readonly SectionGridPlacement[];
|
||||
}>({
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
source: placements,
|
||||
current: placements,
|
||||
});
|
||||
const displayName =
|
||||
item.type === "item"
|
||||
@@ -100,14 +115,14 @@ export const FixedGridItem = ({
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!arePlacementListsEqual(keyboardGridRef.current.source, renderedPlacements)) {
|
||||
if (!arePlacementListsEqual(keyboardGridRef.current.source, placements)) {
|
||||
keyboardGridRef.current = {
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
source: placements,
|
||||
current: placements,
|
||||
};
|
||||
}
|
||||
const placements = keyboardGridRef.current.current;
|
||||
const current = placements.find((candidate) => candidate.id === item.id);
|
||||
const currentPlacements = keyboardGridRef.current.current;
|
||||
const current = currentPlacements.find((candidate) => candidate.id === item.id);
|
||||
if (!current) return;
|
||||
|
||||
let transaction: GridTransaction<SectionGridPlacement>;
|
||||
@@ -119,7 +134,7 @@ export const FixedGridItem = ({
|
||||
id: section.id,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
placements: currentPlacements,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -127,8 +142,8 @@ export const FixedGridItem = ({
|
||||
);
|
||||
} catch {
|
||||
keyboardGridRef.current = {
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
source: currentPlacements,
|
||||
current: currentPlacements,
|
||||
};
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
@@ -177,16 +192,28 @@ export const FixedGridItem = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={registerElement}
|
||||
{...semantics}
|
||||
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}
|
||||
data-grid-item-type={isEditorActive ? undefined : item.type}
|
||||
onClickCapture={handleClickCapture}
|
||||
data-board-item-selected={selected ? "true" : undefined}
|
||||
className={combineClasses(
|
||||
classes.staticItem,
|
||||
isEditorActive && "board-grid-entry",
|
||||
isEditorActive && classes.editorEntry,
|
||||
)}
|
||||
style={getLogicalItemStyle(placement)}
|
||||
data-grid-item-id={item.id}
|
||||
data-grid-item-type={item.type}
|
||||
data-grid-x={placement.x}
|
||||
data-grid-y={placement.y}
|
||||
data-grid-w={placement.w}
|
||||
data-grid-h={placement.h}
|
||||
data-editor-grid-entry={isEditorActive ? "true" : undefined}
|
||||
data-type={item.type}
|
||||
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"
|
||||
@@ -195,7 +222,7 @@ export const FixedGridItem = ({
|
||||
onBlur={isEditorActive ? handleBlur : undefined}
|
||||
onKeyDown={isEditorActive ? handleKeyboard : undefined}
|
||||
>
|
||||
{children}
|
||||
<div className={combineClasses("board-grid-content-mount", classes.contentMount)}>{children}</div>
|
||||
{isEditorActive && (
|
||||
<span id={instructionsId} className={classes.liveRegion}>
|
||||
{t("item.moveResize.keyboard.instructions")}
|
||||
|
||||
@@ -3,16 +3,24 @@
|
||||
import type * as GridEditorModule from "./grid-editor";
|
||||
|
||||
let editorModulePromise: Promise<typeof GridEditorModule> | undefined;
|
||||
let editorModule: typeof GridEditorModule | undefined;
|
||||
|
||||
export const loadGridEditorAsync = () => {
|
||||
editorModulePromise ??= import("./grid-editor").catch((error: unknown) => {
|
||||
editorModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
editorModulePromise ??= import("./grid-editor")
|
||||
.then((loadedModule) => {
|
||||
editorModule = loadedModule;
|
||||
return loadedModule;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
editorModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return editorModulePromise;
|
||||
};
|
||||
|
||||
export const getLoadedGridEditorModule = () => editorModule;
|
||||
|
||||
export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => {
|
||||
let animationFrameId: number | undefined;
|
||||
let idleCallbackId: number | undefined;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
|
||||
export interface GridEntryElementSnapshot {
|
||||
revision: number;
|
||||
elements: ReadonlyMap<string, HTMLElement>;
|
||||
}
|
||||
|
||||
export interface GridEntryElementStore {
|
||||
getSnapshot: () => GridEntryElementSnapshot;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
register: (id: string, element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
export const createGridEntryElementStore = (): GridEntryElementStore => {
|
||||
const elements = new Map<string, HTMLElement>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: GridEntryElementSnapshot = { revision: 0, elements };
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
register: (id, element) => {
|
||||
const current = elements.get(id);
|
||||
if (element ? current === element : current === undefined) return;
|
||||
|
||||
if (element) elements.set(id, element);
|
||||
else elements.delete(id);
|
||||
snapshot = { revision: snapshot.revision + 1, elements };
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export interface GridEditorRenderProps {
|
||||
sectionId: string;
|
||||
columnCount: number;
|
||||
rowCount: number;
|
||||
maxRowCount: number | null;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
transactionPlacements: readonly SectionGridPlacement[];
|
||||
className: string;
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
items: SectionItem[];
|
||||
innerSections: ContainerSectionItem[];
|
||||
entryElementStore: GridEntryElementStore;
|
||||
}
|
||||
|
||||
export interface RegisteredGridEditor extends GridEditorRenderProps {
|
||||
host: HTMLElement;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
interface GridEditorRegistrySnapshot {
|
||||
revision: number;
|
||||
editors: ReadonlyMap<string, RegisteredGridEditor>;
|
||||
}
|
||||
|
||||
interface GridEditorRegistry {
|
||||
getSnapshot: () => GridEditorRegistrySnapshot;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
register: (editor: RegisteredGridEditor) => () => void;
|
||||
}
|
||||
|
||||
const createGridEditorRegistry = (): GridEditorRegistry => {
|
||||
const editors = new Map<string, RegisteredGridEditor>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: GridEditorRegistrySnapshot = { revision: 0, editors };
|
||||
|
||||
const publish = () => {
|
||||
snapshot = { revision: snapshot.revision + 1, editors };
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
register: (editor) => {
|
||||
editors.set(editor.sectionId, editor);
|
||||
publish();
|
||||
return () => {
|
||||
if (editors.get(editor.sectionId) !== editor) return;
|
||||
editors.delete(editor.sectionId);
|
||||
publish();
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const GridEditorRegistryContext = createContext<GridEditorRegistry | null>(null);
|
||||
|
||||
export const GridEditorRegistryProvider = ({ children }: PropsWithChildren) => {
|
||||
const [registry] = useState(createGridEditorRegistry);
|
||||
return <GridEditorRegistryContext.Provider value={registry}>{children}</GridEditorRegistryContext.Provider>;
|
||||
};
|
||||
|
||||
export const useGridEditorRegistry = () => {
|
||||
const registry = useContext(GridEditorRegistryContext);
|
||||
if (!registry) throw new Error("GridEditorRegistryProvider is required");
|
||||
return registry;
|
||||
};
|
||||
|
||||
export const useRegisteredGridEditors = () => {
|
||||
const registry = useGridEditorRegistry();
|
||||
return useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot).editors;
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
inset: 0 auto auto 0;
|
||||
overflow: visible;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.board-grid-entry {
|
||||
@@ -22,6 +23,13 @@
|
||||
will-change: translate;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-preview="true"]:not([data-dnd-active="true"]) {
|
||||
transition:
|
||||
translate 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-active="true"],
|
||||
.board-grid-entry[data-dnd-drag-source="true"] {
|
||||
z-index: 1000;
|
||||
@@ -32,6 +40,27 @@
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-board-item-selected="true"] {
|
||||
z-index: 15;
|
||||
opacity: 0.56;
|
||||
filter: saturate(0.72) brightness(0.82);
|
||||
transition:
|
||||
opacity 140ms ease,
|
||||
filter 140ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-board-item-selected="true"]::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
||||
border: 0.125rem dashed var(--mantine-primary-color-filled);
|
||||
border-radius: var(--board-item-radius, var(--mantine-radius-md));
|
||||
box-shadow: inset 0 0 1.125rem color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging] {
|
||||
position: fixed !important;
|
||||
z-index: 10000;
|
||||
@@ -80,24 +109,12 @@
|
||||
zoom: var(--board-grid-overlay-content-zoom, 1);
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-entry,
|
||||
body[data-board-grid-interacting] .board-grid-entry[data-dnd-active="true"],
|
||||
body[data-board-grid-interacting] .board-grid-entry[data-dnd-drag-source="true"],
|
||||
body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.board-grid-content-mount,
|
||||
.board-grid-portal-content,
|
||||
.board-grid-portal-content > [data-editor-grid-entry] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.board-grid-content-mount {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount {
|
||||
overflow: clip;
|
||||
}
|
||||
@@ -177,138 +194,115 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: block;
|
||||
z-index: 25;
|
||||
display: grid;
|
||||
padding: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
background: var(--mantine-color-body);
|
||||
border: 0.0625rem solid var(--mantine-color-default-border);
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
opacity: 0;
|
||||
scale: 0.88;
|
||||
place-items: center;
|
||||
touch-action: none;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
scale 160ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle {
|
||||
.board-grid-resize-handle::before {
|
||||
position: absolute;
|
||||
inset: -0.375rem;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.board-grid-resize-handle > svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle::after {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: 12px;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry:hover > .board-grid-resize-handle::after,
|
||||
.board-grid-resize-handle:focus-visible::after {
|
||||
.board-grid-entry:has(> .board-grid-resize-handle:hover) > .board-grid-resize-handle,
|
||||
.board-grid-entry:has(> .board-grid-resize-handle:active) > .board-grid-resize-handle {
|
||||
border-color: var(--mantine-color-dimmed);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -8px;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
right: 44px;
|
||||
left: 44px;
|
||||
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
left: 50%;
|
||||
width: 1.75rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
translate: -50% 0;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||
top: 0;
|
||||
top: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
bottom: 0;
|
||||
bottom: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
top: 44px;
|
||||
bottom: 44px;
|
||||
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
top: 50%;
|
||||
width: 1rem;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
translate: 0 -50%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||
right: 0;
|
||||
right: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
left: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
z-index: 9;
|
||||
width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: var(--board-item-radius, var(--mantine-radius-md));
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
top: 0;
|
||||
right: 0;
|
||||
top: -0.625rem;
|
||||
right: -0.625rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
right: -0.625rem;
|
||||
bottom: -0.625rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
bottom: -0.625rem;
|
||||
left: -0.625rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: 0;
|
||||
left: 0;
|
||||
top: -0.625rem;
|
||||
left: -0.625rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.board-grid-resize-handle::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.board-grid-entry,
|
||||
.board-grid-placeholder,
|
||||
.board-grid-resize-outline,
|
||||
.board-grid-resize-handle::after {
|
||||
.board-grid-resize-handle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
|
||||
|
||||
@@ -32,12 +33,12 @@ 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";
|
||||
|
||||
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
@@ -56,6 +57,7 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
|
||||
import type { DndGridEntryProps } from "./grid-entry-memo";
|
||||
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
|
||||
import { getGridDepth } from "./grid-depth";
|
||||
import type { GridEditorRenderProps } from "./grid-editor-registry";
|
||||
import { createGridInteractionStore } from "./grid-interaction-store";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import type { GridInteraction } from "./grid-preview-layer";
|
||||
@@ -67,15 +69,6 @@ import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
const GRID_ENTRY_TYPE = "homarr-board-grid-entry";
|
||||
const GRID_TARGET_TYPE = "homarr-board-grid-target";
|
||||
|
||||
interface GridEditorProps {
|
||||
sectionId: string;
|
||||
columnCount: number;
|
||||
rowCount: number;
|
||||
maxRowCount: number | null;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
className: string;
|
||||
}
|
||||
|
||||
interface GridEntryData {
|
||||
kind: typeof GRID_ENTRY_TYPE;
|
||||
sectionId: string;
|
||||
@@ -725,14 +718,22 @@ export default function GridEditor({
|
||||
rowCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
transactionPlacements,
|
||||
className,
|
||||
}: GridEditorProps) {
|
||||
section,
|
||||
items,
|
||||
innerSections,
|
||||
entryElementStore,
|
||||
}: GridEditorRenderProps) {
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections } = useSectionContext();
|
||||
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
|
||||
const interactionStore = useBoardGridEditorInteractionStore();
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [entryElements] = useState(() => new Map<string, HTMLElement>());
|
||||
const entryElements = useSyncExternalStore(
|
||||
entryElementStore.subscribe,
|
||||
entryElementStore.getSnapshot,
|
||||
entryElementStore.getSnapshot,
|
||||
).elements;
|
||||
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
|
||||
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
|
||||
const depth = getDepth(sectionId);
|
||||
@@ -767,14 +768,6 @@ export default function GridEditor({
|
||||
},
|
||||
[droppableRef],
|
||||
);
|
||||
const registerEntryElement = useCallback(
|
||||
(id: string, element: HTMLElement | null) => {
|
||||
if (element) entryElements.set(id, element);
|
||||
else entryElements.delete(id);
|
||||
},
|
||||
[entryElements],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = gridRef.current;
|
||||
if (!element) return;
|
||||
@@ -784,11 +777,11 @@ export default function GridEditor({
|
||||
maxRowCount,
|
||||
parentGridId,
|
||||
ownerPlacementId,
|
||||
placements,
|
||||
placements: transactionPlacements,
|
||||
depth,
|
||||
element,
|
||||
});
|
||||
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
|
||||
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, registerGrid, sectionId, transactionPlacements]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -813,7 +806,7 @@ export default function GridEditor({
|
||||
label={labelById.get(placement.id) ?? placement.id}
|
||||
columnCount={columnCount}
|
||||
maxRowCount={maxRowCount}
|
||||
registerElement={registerEntryElement}
|
||||
element={entryElements.get(placement.id) ?? null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -842,21 +835,18 @@ const DndGridEntryComponent = ({
|
||||
label,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
registerElement,
|
||||
element,
|
||||
}: DndGridEntryProps) => {
|
||||
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
|
||||
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const placementRef = useRef(placement);
|
||||
placementRef.current = placement;
|
||||
const resizeOutlineController = useMemo(
|
||||
() =>
|
||||
createGridResizeOutlineController(
|
||||
() => shellRef.current,
|
||||
() => element,
|
||||
() => placementRef.current,
|
||||
),
|
||||
[],
|
||||
[element],
|
||||
);
|
||||
const sourceData = useMemo<GridEntryData>(
|
||||
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
|
||||
@@ -877,61 +867,39 @@ const DndGridEntryComponent = ({
|
||||
const modifiers = useMemo(
|
||||
() => [
|
||||
RestrictToElement.configure({
|
||||
element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null,
|
||||
element: () => element?.closest('[data-testid="board-canvas"]') ?? null,
|
||||
}),
|
||||
],
|
||||
[],
|
||||
[element],
|
||||
);
|
||||
const {
|
||||
ref: draggableRef,
|
||||
handleRef,
|
||||
isDragSource,
|
||||
isDropping,
|
||||
} = useDraggable<GridEntryData>({
|
||||
const { isDragSource, isDropping } = useDraggable<GridEntryData>({
|
||||
id: placement.id,
|
||||
type: GRID_ENTRY_TYPE,
|
||||
data: sourceData,
|
||||
element,
|
||||
handle: element,
|
||||
disabled: element === null,
|
||||
sensors,
|
||||
modifiers,
|
||||
});
|
||||
const setShellRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
shellRef.current = element;
|
||||
registerElement(placement.id, element);
|
||||
draggableRef(element);
|
||||
handleRef(element);
|
||||
},
|
||||
[draggableRef, handleRef, placement.id, registerElement],
|
||||
);
|
||||
|
||||
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
const container = acquireContainer(placement.id);
|
||||
mount.replaceChildren(container);
|
||||
|
||||
if (!element) return;
|
||||
if (isDragSource) element.dataset.dndDragSource = "true";
|
||||
else delete element.dataset.dndDragSource;
|
||||
if (isDropping) element.dataset.dndDropping = "true";
|
||||
else delete element.dataset.dndDropping;
|
||||
return () => {
|
||||
if (container.parentElement === mount) container.remove();
|
||||
delete element.dataset.dndDragSource;
|
||||
delete element.dataset.dndDropping;
|
||||
};
|
||||
}, [acquireContainer, placement.id, structureRevision]);
|
||||
}, [element, isDragSource, isDropping]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setShellRef}
|
||||
className="board-grid-entry"
|
||||
style={getLogicalItemStyle(placement) as CSSProperties}
|
||||
data-grid-item-id={placement.id}
|
||||
data-grid-item-type={placement.type}
|
||||
data-grid-x={placement.x}
|
||||
data-grid-y={placement.y}
|
||||
data-grid-w={placement.w}
|
||||
data-grid-h={placement.h}
|
||||
data-dnd-drag-source={isDragSource ? "true" : undefined}
|
||||
data-dnd-dropping={isDropping ? "true" : undefined}
|
||||
>
|
||||
<div ref={mountRef} className="board-grid-content-mount" />
|
||||
if (!element) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<GridResizeHandles
|
||||
sectionId={sectionId}
|
||||
placement={placement}
|
||||
@@ -944,7 +912,9 @@ const DndGridEntryComponent = ({
|
||||
cancelResize={cancelResize}
|
||||
scheduleResizeOutline={resizeOutlineController.schedule}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
element,
|
||||
`board-grid-resize-handles:${placement.id}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1102,19 +1072,27 @@ const GridResizeHandles = ({
|
||||
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
|
||||
};
|
||||
|
||||
return RESIZE_DIRECTIONS.map((direction) => (
|
||||
<span
|
||||
key={direction}
|
||||
className="board-grid-resize-handle"
|
||||
data-grid-resize-handle={direction}
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => handlePointerDown(event, direction)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={(event) => handlePointerEnd(event, false)}
|
||||
onPointerCancel={(event) => handlePointerEnd(event, true)}
|
||||
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
|
||||
/>
|
||||
));
|
||||
return RESIZE_DIRECTIONS.map((direction) => {
|
||||
const isHorizontalSide = direction === "n" || direction === "s";
|
||||
const isVerticalSide = direction === "e" || direction === "w";
|
||||
const GripIcon = isHorizontalSide ? IconGripHorizontal : IconGripVertical;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={direction}
|
||||
className="board-grid-resize-handle"
|
||||
data-grid-resize-handle={direction}
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => handlePointerDown(event, direction)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={(event) => handlePointerEnd(event, false)}
|
||||
onPointerCancel={(event) => handlePointerEnd(event, true)}
|
||||
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
|
||||
>
|
||||
<GripIcon size={isHorizontalSide || isVerticalSide ? 13 : 15} stroke={2.25} />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getProjectedDragShape = (
|
||||
@@ -1239,6 +1217,7 @@ const getPreferredRegisteredGridTarget = (
|
||||
|
||||
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
|
||||
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
|
||||
if (event.metaKey || event.ctrlKey) return true;
|
||||
const target = event.target;
|
||||
const activationElement = source.handle ?? source.element;
|
||||
if (!(target instanceof Element) || target === activationElement) return false;
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface DndGridEntryProps {
|
||||
label: string;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
element: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
|
||||
@@ -14,7 +14,7 @@ export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: Dnd
|
||||
previous.label === next.label &&
|
||||
previous.columnCount === next.columnCount &&
|
||||
previous.maxRowCount === next.maxRowCount &&
|
||||
previous.registerElement === next.registerElement &&
|
||||
previous.element === next.element &&
|
||||
previous.placement.id === next.placement.id &&
|
||||
previous.placement.type === next.placement.type &&
|
||||
previous.placement.x === next.placement.x &&
|
||||
|
||||
@@ -2,38 +2,23 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount, normalizeGridPlacement } from "~/components/board/layout";
|
||||
import { SectionContentItem } from "../content";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
interface GridPortalHostContextValue {
|
||||
announce: (message: string) => void;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
containers: ReadonlyMap<string, HTMLElement>;
|
||||
acquireContainer: (id: string) => HTMLElement;
|
||||
structureRevision: string;
|
||||
getEntryRuntime: <TRuntime>(entryId: string, createRuntime: () => TRuntime) => TRuntime;
|
||||
}
|
||||
|
||||
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* One portal registry owns every edit-mode item on the board. Its content
|
||||
* containers are stable even when drag-and-drop replaces or transfers the
|
||||
* outer positioning shell, so moving between grids does not remount widget
|
||||
* content.
|
||||
*/
|
||||
/** Shared board-grid services for editor announcements and integration data. */
|
||||
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const { data: session } = useSession();
|
||||
@@ -41,51 +26,27 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: Boolean(session) && isEditMode,
|
||||
});
|
||||
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
|
||||
const [announcement, setAnnouncement] = useState({ id: 0, message: "" });
|
||||
const entryRuntimesRef = useRef(new Map<string, unknown>());
|
||||
const announce = useCallback((message: string) => {
|
||||
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
|
||||
}, []);
|
||||
const liveEntryIds = useMemo(
|
||||
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
|
||||
[board.items, board.sections],
|
||||
);
|
||||
const structureRevision = useMemo(() => [...liveEntryIds].toSorted().join(":"), [liveEntryIds]);
|
||||
|
||||
const acquireContainer = useCallback((id: string) => {
|
||||
const existing = containersRef.current.get(id);
|
||||
if (existing) return existing;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "board-grid-portal-content";
|
||||
container.dataset.boardGridPortal = id;
|
||||
containersRef.current.set(id, container);
|
||||
setContainers(new Map(containersRef.current));
|
||||
return container;
|
||||
const getEntryRuntime = useCallback(<TRuntime,>(entryId: string, createRuntime: () => TRuntime): TRuntime => {
|
||||
const existing = entryRuntimesRef.current.get(entryId);
|
||||
if (existing !== undefined) return existing as TRuntime;
|
||||
const runtime = createRuntime();
|
||||
entryRuntimesRef.current.set(entryId, runtime);
|
||||
return runtime;
|
||||
}, []);
|
||||
|
||||
const liveItemIds = useMemo(() => new Set(board.items.map((item) => item.id)), [board.items]);
|
||||
useEffect(() => {
|
||||
// A cross-grid move can briefly have no shell, but the entry still exists
|
||||
// on the board. Only remove containers whose board entity was deleted.
|
||||
let didPrune = false;
|
||||
for (const id of containersRef.current.keys()) {
|
||||
if (liveEntryIds.has(id)) continue;
|
||||
containersRef.current.delete(id);
|
||||
didPrune = true;
|
||||
for (const entryId of entryRuntimesRef.current.keys()) {
|
||||
if (!liveItemIds.has(entryId)) entryRuntimesRef.current.delete(entryId);
|
||||
}
|
||||
if (didPrune) setContainers(new Map(containersRef.current));
|
||||
}, [liveEntryIds]);
|
||||
|
||||
}, [liveItemIds]);
|
||||
const value = useMemo<GridPortalHostContextValue>(
|
||||
() => ({
|
||||
announce,
|
||||
integrations,
|
||||
containers,
|
||||
acquireContainer,
|
||||
structureRevision,
|
||||
}),
|
||||
[acquireContainer, announce, containers, integrations, structureRevision],
|
||||
() => ({ announce, integrations, getEntryRuntime }),
|
||||
[announce, getEntryRuntime, integrations],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -98,158 +59,8 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders stable portal content at this component's logical React position.
|
||||
* Keep it inside the editor runtime and dnd-kit providers so portaled entries
|
||||
* inherit the same keyboard, transaction, and nested-grid contexts as shells.
|
||||
*/
|
||||
export const BoardGridPortalRenderer = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { announce, containers, integrations } = useBoardGridPortalHost();
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from(containers, ([id, container]) => {
|
||||
const ownerSectionId = getOwnerSectionId(board, currentLayoutId, id);
|
||||
return ownerSectionId
|
||||
? createPortal(
|
||||
<GridPortalContent
|
||||
itemId={id}
|
||||
ownerSectionId={ownerSectionId}
|
||||
integrations={integrations}
|
||||
announce={announce}
|
||||
/>,
|
||||
container,
|
||||
id,
|
||||
)
|
||||
: null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBoardGridPortalHost = () => {
|
||||
const context = useContext(GridPortalHostContext);
|
||||
if (!context) throw new Error("BoardGridPortalHost is required");
|
||||
return context;
|
||||
};
|
||||
|
||||
interface GridPortalContentProps {
|
||||
itemId: string;
|
||||
ownerSectionId: string;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
announce: (message: string) => void;
|
||||
}
|
||||
|
||||
const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: GridPortalContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(ownerSectionId);
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const section = currentLayout ? getRenderedSection(board, currentLayout, ownerSectionId) : null;
|
||||
|
||||
if (!section || !currentLayout) return null;
|
||||
|
||||
const columnCount = getSectionColumnCount(section, currentLayout);
|
||||
if (columnCount < 1) return null;
|
||||
const renderedItems = items.map((item) => normalizePortalEntry(item, columnCount));
|
||||
const renderedInnerSections = innerSections.map((innerSection) => normalizePortalEntry(innerSection, columnCount));
|
||||
const entry = [...renderedItems, ...renderedInnerSections].find((candidate) => candidate.id === itemId);
|
||||
if (!entry) return null;
|
||||
|
||||
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
|
||||
const contentRowCount = Math.max(
|
||||
1,
|
||||
...renderedItems.map((item) => item.yOffset + item.height),
|
||||
...renderedInnerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
|
||||
);
|
||||
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
|
||||
|
||||
return (
|
||||
<SectionProvider
|
||||
value={{
|
||||
section,
|
||||
items: renderedItems,
|
||||
innerSections: renderedInnerSections,
|
||||
integrations,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
announce,
|
||||
}}
|
||||
>
|
||||
<SectionContentItem item={entry} integrations={integrations} />
|
||||
</SectionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const getRenderedSection = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
|
||||
sectionId: string,
|
||||
visited = new Set<string>(),
|
||||
): Exclude<Section, { kind: "container" }> | ContainerSectionItem | null => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind !== "container") return section;
|
||||
if (visited.has(section.id)) return null;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === currentLayout.id);
|
||||
if (!layout) return null;
|
||||
const parentSection = getRenderedSection(
|
||||
board,
|
||||
currentLayout,
|
||||
layout.parentSectionId,
|
||||
new Set(visited).add(section.id),
|
||||
);
|
||||
if (!parentSection) return null;
|
||||
const parentColumnCount = getSectionColumnCount(parentSection, currentLayout);
|
||||
if (parentColumnCount < 1) return null;
|
||||
const normalized = normalizePortalEntry(layout, parentColumnCount);
|
||||
|
||||
return {
|
||||
...section,
|
||||
...normalized,
|
||||
type: "section",
|
||||
};
|
||||
};
|
||||
|
||||
const getSectionColumnCount = (
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem,
|
||||
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
|
||||
) =>
|
||||
section.kind === "container"
|
||||
? section.width
|
||||
: section.kind === "empty"
|
||||
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
|
||||
: currentLayout.columnCount;
|
||||
|
||||
const normalizePortalEntry = <
|
||||
TEntry extends { id?: string; xOffset: number; yOffset: number; width: number; height: number },
|
||||
>(
|
||||
entry: TEntry,
|
||||
columnCount: number,
|
||||
) => {
|
||||
const placement = normalizeGridPlacement(
|
||||
{ id: entry.id ?? "portal-entry", x: entry.xOffset, y: entry.yOffset, w: entry.width, h: entry.height },
|
||||
columnCount,
|
||||
);
|
||||
return {
|
||||
...entry,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
};
|
||||
|
||||
const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId: string, entryId: string) => {
|
||||
const item = board.items.find((candidate) => candidate.id === entryId);
|
||||
if (item) {
|
||||
return item.layouts.find((layout) => layout.layoutId === layoutId)?.sectionId ?? null;
|
||||
}
|
||||
|
||||
const section = board.sections.find((candidate) => candidate.kind === "container" && candidate.id === entryId);
|
||||
if (!section || section.kind !== "container") return null;
|
||||
return section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
|
||||
};
|
||||
@@ -14,19 +14,23 @@
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
|
||||
.editorPortalHost {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.staticItem {
|
||||
overflow: visible;
|
||||
transition:
|
||||
left 220ms ease,
|
||||
top 220ms ease,
|
||||
width 220ms ease,
|
||||
height 220ms ease;
|
||||
}
|
||||
|
||||
.contentMount {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.editorEntry {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
@@ -43,19 +47,6 @@
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.editorLoading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorLoadingItem {
|
||||
background-color: var(--mantine-color-default);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.liveRegion {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -67,9 +58,3 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.staticItem {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, LoadingOverlay } from "@mantine/core";
|
||||
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
@@ -15,56 +14,21 @@ import {
|
||||
getEditableCanvasAttributes,
|
||||
getGridRowCountForVisualHeight,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { SectionContent } from "../content";
|
||||
import { useCollapsedSectionIds } from "../section-collapse";
|
||||
import { SectionProvider, useSectionContext } from "../section-context";
|
||||
import { useCollapsedSectionIds, useExpandSectionsForEditing } from "../section-collapse";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
const GridEditor = dynamic(loadGridEditorAsync, {
|
||||
loading: GridEditorLoading,
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
function GridEditorLoading() {
|
||||
const t = useI18n();
|
||||
const { items, innerSections } = useSectionContext();
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="output"
|
||||
className={classes.editorLoading}
|
||||
data-testid="board-grid-editor-loading"
|
||||
aria-label={t("common.action.loading")}
|
||||
>
|
||||
{[...items, ...innerSections].map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
className={classes.editorLoadingItem}
|
||||
style={getLogicalItemStyle({
|
||||
x: item.xOffset,
|
||||
y: item.yOffset,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
<LoadingOverlay visible />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionGridProps {
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
columnCount: number;
|
||||
@@ -85,21 +49,19 @@ export const SectionGrid = ({
|
||||
const [isEditMode] = useEditMode();
|
||||
const canvasScale = useBoardCanvasScale();
|
||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||
const editorRegistry = useGridEditorRegistry();
|
||||
const editorHostRef = useRef<HTMLDivElement>(null);
|
||||
const [entryElementStore] = useState(createGridEntryElementStore);
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const { announce, integrations } = useBoardGridPortalHost();
|
||||
const collapsedSectionIds = useCollapsedSectionIds();
|
||||
const minimumBySectionId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
innerSections.map((innerSection) => [
|
||||
innerSection.id,
|
||||
getContainerMinimumSize(board, currentLayoutId, innerSection.id),
|
||||
]),
|
||||
),
|
||||
[board, currentLayoutId, innerSections],
|
||||
);
|
||||
const expandSectionsForEditing = useExpandSectionsForEditing();
|
||||
const minimumBySectionId = useMemo(() => {
|
||||
const minimumSizes = getContainerMinimumSizes(board, currentLayoutId);
|
||||
return new Map(innerSections.map((innerSection) => [innerSection.id, minimumSizes.get(innerSection.id)]));
|
||||
}, [board, currentLayoutId, innerSections]);
|
||||
|
||||
const placements = useMemo(
|
||||
() =>
|
||||
@@ -121,41 +83,112 @@ export const SectionGrid = ({
|
||||
}),
|
||||
[columnCount, innerSections, items, minimumBySectionId],
|
||||
);
|
||||
const collapsibleSectionIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
innerSections.filter((innerSection) => innerSection.options.collapsible).map((innerSection) => innerSection.id),
|
||||
),
|
||||
[innerSections],
|
||||
);
|
||||
|
||||
const directCollapsedIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
placements
|
||||
.filter(
|
||||
(placement) =>
|
||||
placement.type === "section" &&
|
||||
collapsedSectionIds.has(placement.id) &&
|
||||
collapsibleSectionIds.has(placement.id),
|
||||
)
|
||||
.map((placement) => placement.id),
|
||||
),
|
||||
[collapsedSectionIds, collapsibleSectionIds, placements],
|
||||
);
|
||||
const displayPlacements = useMemo(() => {
|
||||
const directCollapsedIds = new Set(
|
||||
placements
|
||||
.filter(
|
||||
(placement) =>
|
||||
placement.type === "section" &&
|
||||
collapsedSectionIds.has(placement.id) &&
|
||||
innerSections.find((innerSection) => innerSection.id === placement.id)?.options.collapsible,
|
||||
)
|
||||
.map((placement) => placement.id),
|
||||
);
|
||||
if (directCollapsedIds.size === 0) return placements;
|
||||
|
||||
return getCollapsedDisplayLayout(placements, {
|
||||
columnCount,
|
||||
collapsedItemIds: directCollapsedIds,
|
||||
});
|
||||
}, [collapsedSectionIds, columnCount, innerSections, placements]);
|
||||
}, [columnCount, directCollapsedIds, placements]);
|
||||
|
||||
const placementById = useMemo(
|
||||
() => new Map(displayPlacements.map((placement) => [placement.id, placement])),
|
||||
[displayPlacements],
|
||||
);
|
||||
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const displayedItems = useMemo(
|
||||
() => items.map((item) => withPlacement(item, placementById.get(item.id))),
|
||||
[items, placementById],
|
||||
);
|
||||
const displayedInnerSections = useMemo(
|
||||
() => innerSections.map((item) => withPlacement(item, placementById.get(item.id))),
|
||||
[innerSections, placementById],
|
||||
);
|
||||
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
|
||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
||||
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||
const canvasAttributes = isEditMode
|
||||
? getEditableCanvasAttributes({ label, columnCount, rowCount })
|
||||
: getReadonlyCanvasAttributes({ label });
|
||||
// A collapsed container's compact coordinates are display-only. Its own
|
||||
// nested grid stays inactive until an explicit edit interaction expands it.
|
||||
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
|
||||
const canvasAttributes =
|
||||
isEditMode && !isInteractionDisabled
|
||||
? getEditableCanvasAttributes({ label, columnCount, rowCount })
|
||||
: getReadonlyCanvasAttributes({ label });
|
||||
const editorClassName = combineClasses("board-grid-editor", classes.editorGrid);
|
||||
const expandCollapsedSectionsForPointerEdit = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!isEditMode || directCollapsedIds.size === 0 || event.button !== 0) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const entry = target.closest('[data-editor-grid-entry="true"]');
|
||||
if (!entry || !event.currentTarget.contains(entry)) return;
|
||||
if (!target.closest(".board-grid-resize-handle") && target.closest(INTERACTIVE_GRID_SELECTOR)) return;
|
||||
expandSectionsForEditing(directCollapsedIds);
|
||||
};
|
||||
const expandCollapsedSectionsForKeyboardEdit = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isEditMode || directCollapsedIds.size === 0 || !EDIT_ACTIVATION_KEYS.has(event.key)) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element) || !target.matches('[data-editor-grid-entry="true"]')) return;
|
||||
expandSectionsForEditing(directCollapsedIds);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const host = editorHostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
return editorRegistry.register({
|
||||
host,
|
||||
disabled: isInteractionDisabled,
|
||||
sectionId: section.id,
|
||||
section,
|
||||
items: displayedItems,
|
||||
innerSections: displayedInnerSections,
|
||||
columnCount,
|
||||
rowCount,
|
||||
maxRowCount,
|
||||
placements: displayPlacements,
|
||||
transactionPlacements: placements,
|
||||
className: editorClassName,
|
||||
entryElementStore,
|
||||
});
|
||||
}, [
|
||||
columnCount,
|
||||
displayPlacements,
|
||||
displayedInnerSections,
|
||||
displayedItems,
|
||||
editorClassName,
|
||||
editorRegistry,
|
||||
entryElementStore,
|
||||
isInteractionDisabled,
|
||||
maxRowCount,
|
||||
placements,
|
||||
rowCount,
|
||||
section,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionProvider
|
||||
@@ -166,47 +199,48 @@ export const SectionGrid = ({
|
||||
integrations,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements: displayPlacements,
|
||||
interactionDisabled: isInteractionDisabled,
|
||||
announce,
|
||||
entryElementStore,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
{...canvasAttributes}
|
||||
className={combineClasses(classes.viewport, className)}
|
||||
style={{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
data-section-id={section.id}
|
||||
data-section-kind={section.kind}
|
||||
data-rail-placement={railPlacement}
|
||||
data-grid-interaction-disabled={isEditMode && isInteractionDisabled ? "true" : undefined}
|
||||
onPointerDownCapture={expandCollapsedSectionsForPointerEdit}
|
||||
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
|
||||
>
|
||||
{isEditMode && editorRuntimeStatus === "ready" ? (
|
||||
<GridEditor
|
||||
sectionId={section.id}
|
||||
columnCount={columnCount}
|
||||
rowCount={rowCount}
|
||||
maxRowCount={maxRowCount}
|
||||
placements={displayPlacements}
|
||||
className={combineClasses("board-grid-editor", classes.editorGrid)}
|
||||
/>
|
||||
) : isEditMode && editorRuntimeStatus === "loading" ? (
|
||||
<GridEditorLoading />
|
||||
) : (
|
||||
<Box
|
||||
className={classes.staticGrid}
|
||||
style={{ width: logicalWidth, height: logicalHeight }}
|
||||
data-grid-section-id={section.id}
|
||||
data-kind={section.kind}
|
||||
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
|
||||
>
|
||||
<SectionContent />
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
className={classes.staticGrid}
|
||||
style={{ width: logicalWidth, height: logicalHeight }}
|
||||
data-grid-section-id={section.id}
|
||||
data-kind={section.kind}
|
||||
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
|
||||
>
|
||||
<SectionContent />
|
||||
</Box>
|
||||
<div ref={editorHostRef} className={classes.editorPortalHost} />
|
||||
</Box>
|
||||
</SectionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const INTERACTIVE_GRID_SELECTOR =
|
||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
||||
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
||||
|
||||
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
||||
const [visualHeight, setVisualHeight] = useState(0);
|
||||
|
||||
@@ -225,37 +259,70 @@ const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
||||
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
|
||||
};
|
||||
|
||||
const getContainerMinimumSize = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
layoutId: string,
|
||||
sectionId: string,
|
||||
visited = new Set<string>(),
|
||||
): { width: number; height: number } => {
|
||||
if (visited.has(sectionId)) return { width: 1, height: 1 };
|
||||
const nextVisited = new Set(visited).add(sectionId);
|
||||
const directItems: PlacementBounds[] = board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.sectionId === sectionId ? [layout] : [];
|
||||
});
|
||||
const directSections: PlacementBounds[] = board.sections.flatMap((candidate) => {
|
||||
if (candidate.kind !== "container") return [];
|
||||
const layout = candidate.layouts.find((candidateLayout) => candidateLayout.layoutId === layoutId);
|
||||
if (!layout || layout.parentSectionId !== sectionId) return [];
|
||||
const minimum = getContainerMinimumSize(board, layoutId, candidate.id, nextVisited);
|
||||
return [
|
||||
{
|
||||
...layout,
|
||||
width: Math.max(layout.width, minimum.width),
|
||||
height: Math.max(layout.height, minimum.height),
|
||||
},
|
||||
];
|
||||
});
|
||||
const children = [...directItems, ...directSections];
|
||||
const containerMinimumSizeCache = new WeakMap<
|
||||
ReturnType<typeof useRequiredBoard>,
|
||||
Map<string, ReadonlyMap<string, { width: number; height: number }>>
|
||||
>();
|
||||
|
||||
return {
|
||||
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
|
||||
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
|
||||
const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, layoutId: string) => {
|
||||
const cachedByLayout = containerMinimumSizeCache.get(board);
|
||||
const cached = cachedByLayout?.get(layoutId);
|
||||
if (cached) return cached;
|
||||
|
||||
const directItemsBySectionId = new Map<string, PlacementBounds[]>();
|
||||
for (const item of board.items) {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) continue;
|
||||
const entries = directItemsBySectionId.get(layout.sectionId) ?? [];
|
||||
entries.push(layout);
|
||||
directItemsBySectionId.set(layout.sectionId, entries);
|
||||
}
|
||||
|
||||
const directSectionsBySectionId = new Map<string, { id: string; placement: PlacementBounds }[]>();
|
||||
for (const section of board.sections) {
|
||||
if (section.kind !== "container") continue;
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) continue;
|
||||
const entries = directSectionsBySectionId.get(layout.parentSectionId) ?? [];
|
||||
entries.push({ id: section.id, placement: layout });
|
||||
directSectionsBySectionId.set(layout.parentSectionId, entries);
|
||||
}
|
||||
|
||||
const minimumBySectionId = new Map<string, { width: number; height: number }>();
|
||||
const visiting = new Set<string>();
|
||||
const resolve = (sectionId: string): { width: number; height: number } => {
|
||||
const existing = minimumBySectionId.get(sectionId);
|
||||
if (existing) return existing;
|
||||
if (visiting.has(sectionId)) return { width: 1, height: 1 };
|
||||
visiting.add(sectionId);
|
||||
|
||||
const itemBounds = directItemsBySectionId.get(sectionId) ?? [];
|
||||
const sectionBounds = (directSectionsBySectionId.get(sectionId) ?? []).map(({ id, placement }) => {
|
||||
const minimum = resolve(id);
|
||||
return {
|
||||
...placement,
|
||||
width: Math.max(placement.width, minimum.width),
|
||||
height: Math.max(placement.height, minimum.height),
|
||||
};
|
||||
});
|
||||
const children = [...itemBounds, ...sectionBounds];
|
||||
const minimum = {
|
||||
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
|
||||
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
|
||||
};
|
||||
visiting.delete(sectionId);
|
||||
minimumBySectionId.set(sectionId, minimum);
|
||||
return minimum;
|
||||
};
|
||||
|
||||
for (const section of board.sections) {
|
||||
if (section.kind === "container") resolve(section.id);
|
||||
}
|
||||
|
||||
const nextByLayout = cachedByLayout ?? new Map();
|
||||
nextByLayout.set(layoutId, minimumBySectionId);
|
||||
containerMinimumSizeCache.set(board, nextByLayout);
|
||||
return minimumBySectionId;
|
||||
};
|
||||
|
||||
interface PlacementBounds {
|
||||
|
||||
@@ -10,6 +10,9 @@ import { FixedGridItem } from "../fixed-grid-item";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
announce: vi.fn(),
|
||||
commitSectionGrid: vi.fn(),
|
||||
isSelected: vi.fn(() => false),
|
||||
toggleSelectItem: vi.fn(),
|
||||
registerElement: vi.fn(),
|
||||
editMode: true,
|
||||
maxRowCount: null as number | null,
|
||||
items: [
|
||||
@@ -44,6 +47,13 @@ vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [mocks.editMode],
|
||||
}));
|
||||
|
||||
vi.mock("~/components/board/selection/board-selection-context", () => ({
|
||||
useBoardSelection: () => ({
|
||||
isSelected: mocks.isSelected,
|
||||
toggleSelectItem: mocks.toggleSelectItem,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useCurrentLayout: () => "layout",
|
||||
}));
|
||||
@@ -70,7 +80,17 @@ vi.mock("../../section-context", () => ({
|
||||
innerSections: [],
|
||||
columnCount: 3,
|
||||
maxRowCount: mocks.maxRowCount,
|
||||
placements: mocks.items.map((item) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
x: item.xOffset,
|
||||
y: item.yOffset,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})),
|
||||
interactionDisabled: false,
|
||||
announce: mocks.announce,
|
||||
entryElementStore: { register: mocks.registerElement },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -89,6 +109,7 @@ describe("fixed grid item behavior", () => {
|
||||
beforeEach(() => {
|
||||
mocks.announce.mockReset();
|
||||
mocks.commitSectionGrid.mockReset();
|
||||
mocks.registerElement.mockReset();
|
||||
mocks.editMode = true;
|
||||
mocks.maxRowCount = null;
|
||||
getWeatherMock().advancedOptions.title = null;
|
||||
|
||||
@@ -22,7 +22,6 @@ describe("grid entry memoization", () => {
|
||||
{
|
||||
"data-grid-id": props.placement.id,
|
||||
"data-x": props.placement.x,
|
||||
ref: (element: HTMLSpanElement | null) => props.registerElement(props.placement.id, element),
|
||||
},
|
||||
createElement(WidgetProbe),
|
||||
);
|
||||
@@ -79,21 +78,17 @@ describe("grid entry memoization", () => {
|
||||
});
|
||||
|
||||
test("keeps 100 entry and widget renders frozen across interaction store previews", () => {
|
||||
const elements = new Map<string, HTMLElement>();
|
||||
const interactionStore = createGridInteractionStore<GridInteraction>();
|
||||
const gridRef = { current: null as HTMLDivElement | null };
|
||||
const setGridRef = (element: HTMLDivElement | null) => {
|
||||
gridRef.current = element;
|
||||
};
|
||||
const registerElement = (id: string, element: HTMLElement | null) => {
|
||||
if (element) elements.set(id, element);
|
||||
else elements.delete(id);
|
||||
};
|
||||
const entries = Array.from({ length: 100 }, (_, index) => ({
|
||||
...createProps(),
|
||||
registerElement,
|
||||
element: document.createElement("span"),
|
||||
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
|
||||
}));
|
||||
const elements = new Map(entries.map((entry) => [entry.placement.id, entry.element]));
|
||||
const renderPreviewBoard = (nextEntries: DndGridEntryProps[]) => {
|
||||
act(() =>
|
||||
root.render(
|
||||
@@ -160,7 +155,7 @@ describe("grid entry memoization", () => {
|
||||
test.each([
|
||||
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
|
||||
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
|
||||
["element registration", (props: DndGridEntryProps) => ({ ...props, registerElement: () => undefined })],
|
||||
["bound element", (props: DndGridEntryProps) => ({ ...props, element: document.createElement("span") })],
|
||||
[
|
||||
"minimum size",
|
||||
(props: DndGridEntryProps) => ({
|
||||
@@ -198,5 +193,5 @@ const createProps = (): DndGridEntryProps => ({
|
||||
label: "Weather",
|
||||
columnCount: 12,
|
||||
maxRowCount: null,
|
||||
registerElement: () => undefined,
|
||||
element: null,
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest";
|
||||
const gridDirectory = resolve(import.meta.dirname, "..");
|
||||
const nextjsSourceDirectory = resolve(gridDirectory, "../../../../");
|
||||
const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
|
||||
const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.tsx");
|
||||
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
||||
|
||||
describe("board grid import boundary", () => {
|
||||
@@ -33,15 +34,16 @@ describe("board grid import boundary", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("loads the editor through next/dynamic only in the edit branch", () => {
|
||||
const source = readFileSync(sectionGridPath, "utf8");
|
||||
test("loads the editor only in edit mode without replacing static board content", () => {
|
||||
const sectionSource = readFileSync(sectionGridPath, "utf8");
|
||||
const boundarySource = readFileSync(editorBoundaryPath, "utf8");
|
||||
|
||||
expect(source).toMatch(/import dynamic from ["']next\/dynamic["']/);
|
||||
expect(source).toMatch(
|
||||
/const GridEditor = dynamic\(loadGridEditorAsync,\s*{[\s\S]*?loading:\s*GridEditorLoading,[\s\S]*?ssr:\s*false,[\s\S]*?}\);/,
|
||||
);
|
||||
expect(source.match(/<GridEditor\b/g)).toHaveLength(1);
|
||||
expect(source).toMatch(/isEditMode\s*&&\s*editorRuntimeStatus\s*===\s*["']ready["']\s*\?\s*\(\s*<GridEditor\b/);
|
||||
expect(sectionSource).not.toMatch(/import dynamic from ["']next\/dynamic["']/);
|
||||
expect(sectionSource).toMatch(/<SectionContent\s*\/>/);
|
||||
expect(sectionSource).toMatch(/<div ref={editorHostRef} className={classes\.editorPortalHost}\s*\/>/);
|
||||
expect(boundarySource).toMatch(/loadGridEditorAsync\(\)/);
|
||||
expect(boundarySource).toMatch(/isEditMode\s*&&\s*Provider\s*&&\s*GridEditor/);
|
||||
expect(boundarySource).toMatch(/createPortal\(<GridEditor \{\.\.\.props} \/>, host, props\.sectionId\)/);
|
||||
expect(getRuntimeImports(sectionGridPath).filter(({ specifier }) => specifier === "./grid-editor")).toEqual([]);
|
||||
expect(
|
||||
getRuntimeImports(editorLoaderPath)
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { act, createElement, useLayoutEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { BoardGridPortalHost, useBoardGridPortalHost } from "../grid-portal-host";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
board: {
|
||||
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
|
||||
sections: [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: {
|
||||
integration: {
|
||||
all: {
|
||||
useQuery: () => ({ data: undefined }),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useSession: () => ({ data: { user: { id: "user" } } }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useCurrentLayout: () => "desktop",
|
||||
useRequiredBoard: () => mocks.board,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [true],
|
||||
}));
|
||||
|
||||
describe("board grid portal host", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.board = {
|
||||
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
|
||||
sections: [],
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("keeps a portal across relocation and prunes it after permanent deletion", () => {
|
||||
const acquired: HTMLElement[] = [];
|
||||
|
||||
renderHost(root, true, 0, acquired);
|
||||
expect(getContainerCount(container)).toBe("1");
|
||||
|
||||
mocks.board = {
|
||||
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "other" }] }],
|
||||
sections: [],
|
||||
};
|
||||
renderHost(root, true, 1, acquired);
|
||||
expect(getContainerCount(container)).toBe("1");
|
||||
expect(new Set(acquired).size).toBe(1);
|
||||
|
||||
mocks.board = { items: [], sections: [] };
|
||||
renderHost(root, false, 2, acquired);
|
||||
expect(getContainerCount(container)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
const renderHost = (root: Root, acquire: boolean, revision: number, acquired: HTMLElement[]) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(BoardGridPortalHost, null, createElement(PortalRegistryProbe, { acquire, revision, acquired })),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const PortalRegistryProbe = ({
|
||||
acquire,
|
||||
revision,
|
||||
acquired,
|
||||
}: {
|
||||
acquire: boolean;
|
||||
revision: number;
|
||||
acquired: HTMLElement[];
|
||||
}) => {
|
||||
const portalHost = useBoardGridPortalHost();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (acquire) acquired.push(portalHost.acquireContainer("weather"));
|
||||
}, [acquire, acquired, portalHost, revision]);
|
||||
|
||||
return createElement("span", { "data-container-count": String(portalHost.containers.size) });
|
||||
};
|
||||
|
||||
const getContainerCount = (container: HTMLElement) =>
|
||||
container.querySelector<HTMLElement>("[data-container-count]")?.dataset.containerCount;
|
||||
@@ -12,13 +12,16 @@ import { readSectionCollapsedFromStorage, writeSectionCollapsedToStorage } from
|
||||
|
||||
interface SectionCollapseContextValue {
|
||||
collapsedSectionIds: ReadonlySet<string>;
|
||||
visuallyCollapsedSectionIds: ReadonlySet<string>;
|
||||
setCollapsed: (sectionId: string, collapsed: boolean) => void;
|
||||
expandForEditing: (sectionIds: ReadonlySet<string>) => void;
|
||||
}
|
||||
|
||||
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
|
||||
|
||||
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { status } = useSession();
|
||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
|
||||
@@ -27,8 +30,13 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
||||
),
|
||||
);
|
||||
const collapsedByIdRef = useRef(collapsedById);
|
||||
const [temporarilyExpandedIds, setTemporarilyExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
collapsedByIdRef.current = collapsedById;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode) setTemporarilyExpandedIds((current) => (current.size === 0 ? current : new Set()));
|
||||
}, [isEditMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedById((previous) => {
|
||||
const next = { ...previous };
|
||||
@@ -57,6 +65,12 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
||||
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
|
||||
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed };
|
||||
setCollapsedById((previous) => ({ ...previous, [sectionId]: collapsed }));
|
||||
setTemporarilyExpandedIds((current) => {
|
||||
if (!current.has(sectionId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(sectionId);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (status === "authenticated") {
|
||||
mutate(
|
||||
@@ -76,47 +90,70 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
||||
[mutate, status],
|
||||
);
|
||||
|
||||
const value = useMemo<SectionCollapseContextValue>(
|
||||
() => ({
|
||||
collapsedSectionIds: new Set(
|
||||
Object.entries(collapsedById)
|
||||
.filter(([, collapsed]) => collapsed)
|
||||
.map(([sectionId]) => sectionId),
|
||||
const expandForEditing = useCallback(
|
||||
(sectionIds: ReadonlySet<string>) => {
|
||||
if (!isEditMode || sectionIds.size === 0) return;
|
||||
setTemporarilyExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const sectionId of sectionIds) next.add(sectionId);
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
},
|
||||
[isEditMode],
|
||||
);
|
||||
|
||||
const value = useMemo<SectionCollapseContextValue>(() => {
|
||||
const collapsedSectionIds = new Set(
|
||||
Object.entries(collapsedById)
|
||||
.filter(([, collapsed]) => collapsed)
|
||||
.map(([sectionId]) => sectionId),
|
||||
);
|
||||
return {
|
||||
collapsedSectionIds,
|
||||
visuallyCollapsedSectionIds: new Set(
|
||||
[...collapsedSectionIds].filter((sectionId) => !isEditMode || !temporarilyExpandedIds.has(sectionId)),
|
||||
),
|
||||
setCollapsed,
|
||||
}),
|
||||
[collapsedById, setCollapsed],
|
||||
);
|
||||
expandForEditing,
|
||||
};
|
||||
}, [collapsedById, expandForEditing, isEditMode, setCollapsed, temporarilyExpandedIds]);
|
||||
|
||||
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
|
||||
};
|
||||
|
||||
export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: string; collapsible: boolean }) => {
|
||||
const context = useContext(SectionCollapseContext);
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("BoardSectionCollapseProvider is required");
|
||||
}
|
||||
|
||||
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
|
||||
const isVisuallyCollapsed = collapsible && context.visuallyCollapsedSectionIds.has(sectionId);
|
||||
return {
|
||||
isCollapsed,
|
||||
isVisuallyCollapsed: isCollapsed && !isEditMode,
|
||||
isVisuallyCollapsed,
|
||||
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
|
||||
toggle: () => context.setCollapsed(sectionId, !isCollapsed),
|
||||
toggle: () => context.setCollapsed(sectionId, !isVisuallyCollapsed),
|
||||
};
|
||||
};
|
||||
|
||||
export const useCollapsedSectionIds = () => {
|
||||
const context = useContext(SectionCollapseContext);
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("BoardSectionCollapseProvider is required");
|
||||
}
|
||||
|
||||
return isEditMode ? EMPTY_SET : context.collapsedSectionIds;
|
||||
return context.visuallyCollapsedSectionIds;
|
||||
};
|
||||
|
||||
const EMPTY_SET = new Set<string>();
|
||||
export const useExpandSectionsForEditing = () => {
|
||||
const context = useContext(SectionCollapseContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("BoardSectionCollapseProvider is required");
|
||||
}
|
||||
|
||||
return context.expandForEditing;
|
||||
};
|
||||
@@ -3,6 +3,8 @@ import { createContext, useContext } from "react";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { GridEntryElementStore } from "./grid/grid-editor-registry";
|
||||
import type { SectionGridPlacement } from "./grid/use-grid-layout-actions";
|
||||
|
||||
interface SectionContextProps {
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
@@ -11,7 +13,10 @@ interface SectionContextProps {
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
interactionDisabled: boolean;
|
||||
announce: (message: string) => void;
|
||||
entryElementStore: GridEntryElementStore;
|
||||
}
|
||||
|
||||
const SectionContext = createContext<SectionContextProps | null>(null);
|
||||
|
||||
@@ -7,47 +7,55 @@ import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_t
|
||||
export const useSectionItems = (sectionId: string): { innerSections: ContainerSectionItem[]; items: SectionItem[] } => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
|
||||
const innerSections = useMemo(
|
||||
() =>
|
||||
board.sections
|
||||
.filter((innerSection) => innerSection.kind === "container")
|
||||
.map(({ layouts, ...innerSection }) => {
|
||||
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
...innerSection,
|
||||
type: "section" as const,
|
||||
};
|
||||
})
|
||||
.filter((item) => item !== null)
|
||||
.filter((innerSection) => innerSection.parentSectionId === sectionId),
|
||||
[board.sections, currentLayoutId, sectionId],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
board.items
|
||||
.map(({ layouts, ...item }) => {
|
||||
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||
if (!layout) return null;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
...item,
|
||||
type: "item" as const,
|
||||
};
|
||||
})
|
||||
.filter((item) => item !== null)
|
||||
.filter((item) => item.sectionId === sectionId),
|
||||
[board.items, currentLayoutId, sectionId],
|
||||
);
|
||||
const index = useMemo(() => getSectionItemsIndex(board, currentLayoutId), [board, currentLayoutId]);
|
||||
|
||||
return {
|
||||
innerSections,
|
||||
items,
|
||||
innerSections: index.innerSectionsByParentId.get(sectionId) ?? EMPTY_INNER_SECTIONS,
|
||||
items: index.itemsBySectionId.get(sectionId) ?? EMPTY_ITEMS,
|
||||
};
|
||||
};
|
||||
|
||||
interface SectionItemsIndex {
|
||||
innerSectionsByParentId: ReadonlyMap<string, ContainerSectionItem[]>;
|
||||
itemsBySectionId: ReadonlyMap<string, SectionItem[]>;
|
||||
}
|
||||
|
||||
const sectionItemsIndexCache = new WeakMap<
|
||||
ReturnType<typeof useRequiredBoard>,
|
||||
Map<string, SectionItemsIndex>
|
||||
>();
|
||||
const EMPTY_INNER_SECTIONS: ContainerSectionItem[] = [];
|
||||
const EMPTY_ITEMS: SectionItem[] = [];
|
||||
|
||||
const getSectionItemsIndex = (board: ReturnType<typeof useRequiredBoard>, layoutId: string): SectionItemsIndex => {
|
||||
const cachedByLayout = sectionItemsIndexCache.get(board);
|
||||
const cached = cachedByLayout?.get(layoutId);
|
||||
if (cached) return cached;
|
||||
|
||||
const innerSectionsByParentId = new Map<string, ContainerSectionItem[]>();
|
||||
for (const section of board.sections) {
|
||||
if (section.kind !== "container") continue;
|
||||
const { layouts, ...definition } = section;
|
||||
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) continue;
|
||||
const entries = innerSectionsByParentId.get(layout.parentSectionId) ?? [];
|
||||
entries.push({ ...layout, ...definition, type: "section" });
|
||||
innerSectionsByParentId.set(layout.parentSectionId, entries);
|
||||
}
|
||||
|
||||
const itemsBySectionId = new Map<string, SectionItem[]>();
|
||||
for (const item of board.items) {
|
||||
const { layouts, ...definition } = item;
|
||||
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) continue;
|
||||
const entries = itemsBySectionId.get(layout.sectionId) ?? [];
|
||||
entries.push({ ...layout, ...definition, type: "item" });
|
||||
itemsBySectionId.set(layout.sectionId, entries);
|
||||
}
|
||||
|
||||
const index = { innerSectionsByParentId, itemsBySectionId };
|
||||
const nextByLayout = cachedByLayout ?? new Map();
|
||||
nextByLayout.set(layoutId, index);
|
||||
sectionItemsIndexCache.set(board, nextByLayout);
|
||||
return index;
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { usePersistBoard } from "@homarr/boards/updater";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||
import { playPopSound, playTrashSound } from "../audio/board-sounds";
|
||||
|
||||
interface BoardSelectionContextValue {
|
||||
selectedItemIds: Set<string>;
|
||||
toggleSelectItem: (id: string) => void;
|
||||
clearSelection: () => void;
|
||||
isSelected: (id: string) => boolean;
|
||||
removeSelectedItems: () => void;
|
||||
moveSelectedItemsToSection: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
|
||||
|
||||
export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { updateAndPersistBoard } = usePersistBoard(board);
|
||||
const t = useI18n();
|
||||
const tSelection = useScopedI18n("item.selection");
|
||||
|
||||
const persistBoard = useCallback(
|
||||
(updater: (previous: typeof board) => typeof board) => {
|
||||
updateAndPersistBoard(updater, {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tSelection("saveErrorTitle"),
|
||||
message: tSelection("saveErrorMessage"),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[tSelection, updateAndPersistBoard],
|
||||
);
|
||||
|
||||
// Clear selection if leaving edit mode
|
||||
useEffect(() => {
|
||||
if (!isEditMode) {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
// Clear selection on Escape key
|
||||
useEffect(() => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedItemIds.size]);
|
||||
|
||||
const toggleSelectItem = useCallback((id: string) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
playPopSound();
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedItemIds(new Set());
|
||||
}, []);
|
||||
|
||||
const isSelected = useCallback((id: string) => selectedItemIds.has(id), [selectedItemIds]);
|
||||
|
||||
const removeSelectedItems = useCallback(() => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const idsToRemove = Array.from(selectedItemIds);
|
||||
const count = idsToRemove.length;
|
||||
const removedItems = board.items.filter((item) => idsToRemove.includes(item.id));
|
||||
|
||||
playTrashSound();
|
||||
clearSelection();
|
||||
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.filter((item) => !idsToRemove.includes(item.id)),
|
||||
}));
|
||||
|
||||
const notificationId = `batch-remove-${Date.now()}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
title: t("common.success"),
|
||||
message: (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">{tSelection("removed", { count })}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => {
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: [...previous.items, ...removedItems],
|
||||
}));
|
||||
notifications.hide(notificationId);
|
||||
}}
|
||||
>
|
||||
{t("common.action.undo")}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
autoClose: 10_000,
|
||||
});
|
||||
}, [board.items, clearSelection, persistBoard, selectedItemIds, t, tSelection]);
|
||||
|
||||
const moveSelectedItemsToSection = useCallback(
|
||||
(targetSectionId: string) => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const idsToMove = new Set(selectedItemIds);
|
||||
let didMoveAll = false;
|
||||
|
||||
persistBoard((previous) => {
|
||||
const targetSection = previous.sections.find((section) => section.id === targetSectionId);
|
||||
const boardLayout = previous.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
if (!targetSection || !boardLayout) return previous;
|
||||
|
||||
const containerLayout =
|
||||
targetSection.kind === "container"
|
||||
? targetSection.layouts.find((layout) => layout.layoutId === currentLayoutId)
|
||||
: undefined;
|
||||
const columnCount =
|
||||
targetSection.kind === "container"
|
||||
? (containerLayout?.width ?? 0)
|
||||
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
|
||||
const rowCount = containerLayout?.height ?? 9999;
|
||||
if (columnCount === 0 || rowCount === 0) return previous;
|
||||
|
||||
const existingContainers = previous.sections
|
||||
.filter((section): section is ContainerSection => section.kind === "container")
|
||||
.flatMap((section) => {
|
||||
const layout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === currentLayoutId && candidate.parentSectionId === targetSection.id,
|
||||
);
|
||||
return layout
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const existingItems = previous.items.flatMap((item) => {
|
||||
const layout = item.layouts.find(
|
||||
(candidate) => candidate.layoutId === currentLayoutId && candidate.sectionId === targetSection.id,
|
||||
);
|
||||
return layout
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const occupiedPositions = [...existingContainers, ...existingItems];
|
||||
const movedLayouts = new Map<string, ItemLayout>();
|
||||
|
||||
for (const item of previous.items.filter((candidate) => idsToMove.has(candidate.id))) {
|
||||
const currentItemLayout = item.layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||
if (!currentItemLayout) continue;
|
||||
|
||||
const previousPositionIndex = occupiedPositions.findIndex((position) => position.id === item.id);
|
||||
const previousPosition =
|
||||
previousPositionIndex >= 0 ? occupiedPositions.splice(previousPositionIndex, 1).at(0) : undefined;
|
||||
const { width, height } = currentItemLayout;
|
||||
const emptyPosition =
|
||||
width <= columnCount && height <= rowCount
|
||||
? getFirstEmptyPosition(occupiedPositions, columnCount, rowCount, { width, height })
|
||||
: undefined;
|
||||
|
||||
if (!emptyPosition) {
|
||||
if (previousPosition) occupiedPositions.push(previousPosition);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextLayout: ItemLayout = {
|
||||
...currentItemLayout,
|
||||
sectionId: targetSectionId,
|
||||
xOffset: emptyPosition.xOffset,
|
||||
yOffset: emptyPosition.yOffset,
|
||||
};
|
||||
movedLayouts.set(item.id, nextLayout);
|
||||
occupiedPositions.push({ id: item.id, ...emptyPosition, width, height });
|
||||
}
|
||||
|
||||
if (movedLayouts.size !== idsToMove.size) return previous;
|
||||
didMoveAll = true;
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) => {
|
||||
const movedLayout = movedLayouts.get(item.id);
|
||||
if (!movedLayout) return item;
|
||||
return {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => (layout.layoutId === currentLayoutId ? movedLayout : layout)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
if (didMoveAll) {
|
||||
clearSelection();
|
||||
} else {
|
||||
showErrorNotification({
|
||||
title: tSelection("moveUnavailableTitle"),
|
||||
message: tSelection("moveUnavailableMessage"),
|
||||
});
|
||||
}
|
||||
},
|
||||
[clearSelection, currentLayoutId, persistBoard, selectedItemIds, tSelection],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
selectedItemIds,
|
||||
toggleSelectItem,
|
||||
clearSelection,
|
||||
isSelected,
|
||||
removeSelectedItems,
|
||||
moveSelectedItemsToSection,
|
||||
}),
|
||||
[clearSelection, isSelected, moveSelectedItemsToSection, removeSelectedItems, selectedItemIds, toggleSelectItem],
|
||||
);
|
||||
|
||||
return <BoardSelectionContext.Provider value={value}>{children}</BoardSelectionContext.Provider>;
|
||||
};
|
||||
|
||||
export const useBoardSelection = () => {
|
||||
const context = useContext(BoardSelectionContext);
|
||||
if (!context) {
|
||||
throw new Error("useBoardSelection must be used within a BoardSelectionProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
HoverCard,
|
||||
Menu,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useBoardSelection } from "./board-selection-context";
|
||||
|
||||
const MAX_AVATARS_DISPLAYED = 5;
|
||||
|
||||
export const BoardSelectionToolbar = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const t = useI18n();
|
||||
const tSelection = useScopedI18n("item.selection");
|
||||
|
||||
const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id));
|
||||
const hasAppWidgets = selectedItems.some((item) => item.kind === "app");
|
||||
const { data: selectableApps } = clientApi.app.selectable.useQuery(undefined, {
|
||||
enabled: hasAppWidgets,
|
||||
});
|
||||
|
||||
if (!isEditMode || selectedItemIds.size === 0) return null;
|
||||
|
||||
const appMap = new Map((selectableApps ?? []).map((app) => [app.id, app]));
|
||||
|
||||
// Sort items so apps appear first
|
||||
const sortedSelectedItems = selectedItems.toSorted((itemA, itemB) => {
|
||||
if (itemA.kind === "app" && itemB.kind !== "app") return -1;
|
||||
if (itemA.kind !== "app" && itemB.kind === "app") return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const getItemDetails = (item: (typeof selectedItems)[number]) => {
|
||||
let displayName = item.advancedOptions.title?.trim();
|
||||
let iconUrl: string | undefined = undefined;
|
||||
const IconComponent = widgetCatalogIcons[item.kind] ?? IconCheck;
|
||||
|
||||
if (item.kind === "app") {
|
||||
const appId = (item.options as { appId?: string })?.appId;
|
||||
const app = appId ? appMap.get(appId) : undefined;
|
||||
if (!displayName) {
|
||||
displayName = app?.name || t("widget.app.name");
|
||||
}
|
||||
if (app?.iconUrl) {
|
||||
iconUrl = app.iconUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
displayName = t(`widget.${item.kind}.name`);
|
||||
}
|
||||
|
||||
return { displayName, iconUrl, IconComponent, kind: item.kind };
|
||||
};
|
||||
|
||||
const visibleItems = sortedSelectedItems.slice(0, MAX_AVATARS_DISPLAYED);
|
||||
const overflowCount = sortedSelectedItems.length - visibleItems.length;
|
||||
|
||||
const mainCanvasSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
const placementOptions = [
|
||||
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "empty") return [];
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
|
||||
return [
|
||||
{
|
||||
value: section.id,
|
||||
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
||||
},
|
||||
];
|
||||
}),
|
||||
...board.sections
|
||||
.filter((section) => section.kind === "container")
|
||||
.map((section) => ({
|
||||
value: section.id,
|
||||
label: section.options.title.trim() || t("section.container.untitled"),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="xl"
|
||||
radius="xl"
|
||||
p="xs"
|
||||
bg="light-dark(var(--mantine-color-body), var(--mantine-color-dark-7))"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 28,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 1000,
|
||||
width: "max-content",
|
||||
maxWidth: "calc(100vw - 1rem)",
|
||||
boxShadow: "0 12px 40px rgba(0, 0, 0, 0.28)",
|
||||
backdropFilter: "blur(14px)",
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
animation: "slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Avatar Group with HoverCard Popover showing all items */}
|
||||
<HoverCard position="top" withArrow shadow="xl" radius="md" openDelay={100} closeDelay={200} withinPortal>
|
||||
<HoverCard.Target>
|
||||
<UnstyledButton
|
||||
display="inline-flex"
|
||||
aria-label={tSelection("summary", { count: sortedSelectedItems.length })}
|
||||
style={{ alignItems: "center", borderRadius: "var(--mantine-radius-xl)" }}
|
||||
>
|
||||
<Avatar.Group>
|
||||
{visibleItems.map((item) => {
|
||||
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
|
||||
return (
|
||||
<Avatar
|
||||
key={item.id}
|
||||
src={iconUrl}
|
||||
radius="xl"
|
||||
size={32}
|
||||
color="primaryColor"
|
||||
variant="light"
|
||||
styles={{ image: { objectFit: "contain", padding: 2 } }}
|
||||
alt={displayName}
|
||||
>
|
||||
{!iconUrl && <IconComponent size={16} />}
|
||||
</Avatar>
|
||||
);
|
||||
})}
|
||||
{overflowCount > 0 && (
|
||||
<Avatar radius="xl" size={32} color="gray" variant="filled">
|
||||
+{overflowCount}
|
||||
</Avatar>
|
||||
)}
|
||||
</Avatar.Group>
|
||||
</UnstyledButton>
|
||||
</HoverCard.Target>
|
||||
|
||||
<HoverCard.Dropdown p={6} style={{ width: 220 }}>
|
||||
<Stack gap={4}>
|
||||
<Text size="10px" fw={700} c="dimmed" tt="uppercase" px={4}>
|
||||
{tSelection("selected", { count: sortedSelectedItems.length })}
|
||||
</Text>
|
||||
|
||||
<ScrollArea.Autosize mah={176} type="auto">
|
||||
<Stack gap={2}>
|
||||
{sortedSelectedItems.map((item) => {
|
||||
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
|
||||
return (
|
||||
<Group key={item.id} gap={6} wrap="nowrap" px={4} py={2}>
|
||||
<Avatar
|
||||
src={iconUrl}
|
||||
radius="sm"
|
||||
size={22}
|
||||
color="primaryColor"
|
||||
variant="light"
|
||||
styles={{ image: { objectFit: "contain", padding: 2 } }}
|
||||
alt={displayName}
|
||||
>
|
||||
{!iconUrl && <IconComponent size={12} />}
|
||||
</Avatar>
|
||||
<Text size="xs" fw={500} truncate>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
|
||||
<Text size="xs" c="dimmed" visibleFrom="sm" maw={220} lh={1.25}>
|
||||
{t("app.action.select.multiSelectTip")}
|
||||
</Text>
|
||||
|
||||
<Divider orientation="vertical" />
|
||||
|
||||
{/* Actions */}
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{placementOptions.length > 1 && (
|
||||
<Menu position="top" withArrow shadow="md">
|
||||
<Menu.Target>
|
||||
<Button size="xs" variant="light" color="primaryColor" leftSection={<IconFolderShare size={15} />}>
|
||||
{tSelection("move")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
|
||||
{placementOptions.map((opt) => (
|
||||
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
|
||||
{opt.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={15} />}
|
||||
onClick={removeSelectedItems}
|
||||
>
|
||||
{tSelection("delete")}
|
||||
</Button>
|
||||
|
||||
<Tooltip label={tSelection("clearShortcut")} withArrow>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={clearSelection}
|
||||
aria-label={tSelection("clear")}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "~/components/board/items/actions/test/mocks/layout-mock";
|
||||
import { BoardSelectionProvider, useBoardSelection } from "./board-selection-context";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => key,
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("../audio/board-sounds", () => ({
|
||||
playPopSound: vi.fn(),
|
||||
playTrashSound: vi.fn(),
|
||||
}));
|
||||
|
||||
let currentBoardState: Board;
|
||||
let mockUpdateBoard: (updater: (prev: Board) => Board) => void;
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useRequiredBoard: () => currentBoardState,
|
||||
useCurrentLayout: () => "layout-1",
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [true, vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/updater", () => ({
|
||||
usePersistBoard: () => ({
|
||||
updateAndPersistBoard: (updater: (prev: Board) => Board) => {
|
||||
mockUpdateBoard(updater);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const SelectionConsumer = ({ onReady }: { onReady: (ctx: ReturnType<typeof useBoardSelection>) => void }) => {
|
||||
const selection = useBoardSelection();
|
||||
onReady(selection);
|
||||
return <div>Selection Ready</div>;
|
||||
};
|
||||
|
||||
describe("BoardSelectionContext", () => {
|
||||
it("toggles item selection and clears on clearSelection", async () => {
|
||||
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
|
||||
const item1 = new ItemMockBuilder({ id: "item-1" }).addLayout({ layoutId: "layout-1" }).build();
|
||||
currentBoardState = new BoardMockBuilder().addLayout(layout).addItem(item1).build();
|
||||
mockUpdateBoard = vi.fn();
|
||||
|
||||
let selectionContext!: ReturnType<typeof useBoardSelection>;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<BoardSelectionProvider>
|
||||
<SelectionConsumer
|
||||
onReady={(ctx) => {
|
||||
selectionContext = ctx;
|
||||
}}
|
||||
/>
|
||||
</BoardSelectionProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(selectionContext.isSelected("item-1")).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.toggleSelectItem("item-1");
|
||||
});
|
||||
expect(selectionContext.isSelected("item-1")).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.clearSelection();
|
||||
});
|
||||
expect(selectionContext.isSelected("item-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("moves selected items to the first available positions in a container section", async () => {
|
||||
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
|
||||
const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container-1" })
|
||||
.addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 4, height: 4 })
|
||||
.build();
|
||||
|
||||
// Existing item inside the container at (0, 0) with 2x2
|
||||
const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 2, height: 2 })
|
||||
.build();
|
||||
|
||||
// Two items to move from emptyRoot
|
||||
const itemA = new ItemMockBuilder({ id: "item-a" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 5, yOffset: 5, width: 2, height: 1 })
|
||||
.build();
|
||||
const itemB = new ItemMockBuilder({ id: "item-b" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 8, yOffset: 8, width: 2, height: 1 })
|
||||
.build();
|
||||
|
||||
currentBoardState = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addSection(emptyRoot)
|
||||
.addSection(container)
|
||||
.addItem(existingContainerItem)
|
||||
.addItem(itemA)
|
||||
.addItem(itemB)
|
||||
.build();
|
||||
|
||||
let updatedBoard: Board = currentBoardState;
|
||||
mockUpdateBoard = vi.fn((updater) => {
|
||||
updatedBoard = updater(currentBoardState);
|
||||
currentBoardState = updatedBoard;
|
||||
});
|
||||
|
||||
let selectionContext!: ReturnType<typeof useBoardSelection>;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<BoardSelectionProvider>
|
||||
<SelectionConsumer
|
||||
onReady={(ctx) => {
|
||||
selectionContext = ctx;
|
||||
}}
|
||||
/>
|
||||
</BoardSelectionProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.toggleSelectItem("item-a");
|
||||
selectionContext.toggleSelectItem("item-b");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.moveSelectedItemsToSection("container-1");
|
||||
});
|
||||
|
||||
const movedA = updatedBoard.items.find((i) => i.id === "item-a")?.layouts.find((l) => l.layoutId === "layout-1");
|
||||
const movedB = updatedBoard.items.find((i) => i.id === "item-b")?.layouts.find((l) => l.layoutId === "layout-1");
|
||||
|
||||
expect(movedA?.sectionId).toBe("container-1");
|
||||
expect(movedB?.sectionId).toBe("container-1");
|
||||
|
||||
// Container width is 4. Existing item at (0, 0) occupies (0,0)-(1,1).
|
||||
// itemA (2x1) is placed at first empty spot: (2, 0)
|
||||
expect(movedA?.xOffset).toBe(2);
|
||||
expect(movedA?.yOffset).toBe(0);
|
||||
|
||||
// itemB (2x1) is placed at next first empty spot: (2, 1)
|
||||
expect(movedB?.xOffset).toBe(2);
|
||||
expect(movedB?.yOffset).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the entire selection in place when the destination cannot fit every item", async () => {
|
||||
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
|
||||
const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container-1" })
|
||||
.addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 2, height: 1 })
|
||||
.build();
|
||||
const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
const itemA = new ItemMockBuilder({ id: "item-a" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 0, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
const itemB = new ItemMockBuilder({ id: "item-b" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 1, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
|
||||
currentBoardState = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addSection(emptyRoot)
|
||||
.addSection(container)
|
||||
.addItem(existingContainerItem)
|
||||
.addItem(itemA)
|
||||
.addItem(itemB)
|
||||
.build();
|
||||
|
||||
let updatedBoard = currentBoardState;
|
||||
mockUpdateBoard = vi.fn((updater) => {
|
||||
updatedBoard = updater(currentBoardState);
|
||||
currentBoardState = updatedBoard;
|
||||
});
|
||||
|
||||
let selectionContext!: ReturnType<typeof useBoardSelection>;
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<BoardSelectionProvider>
|
||||
<SelectionConsumer
|
||||
onReady={(ctx) => {
|
||||
selectionContext = ctx;
|
||||
}}
|
||||
/>
|
||||
</BoardSelectionProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.toggleSelectItem("item-a");
|
||||
selectionContext.toggleSelectItem("item-b");
|
||||
});
|
||||
await act(async () => {
|
||||
selectionContext.moveSelectedItemsToSection("container-1");
|
||||
});
|
||||
|
||||
expect(updatedBoard.items.find((item) => item.id === "item-a")?.layouts[0]?.sectionId).toBe("empty-root");
|
||||
expect(updatedBoard.items.find((item) => item.id === "item-b")?.layouts[0]?.sectionId).toBe("empty-root");
|
||||
expect(selectionContext.isSelected("item-a")).toBe(true);
|
||||
expect(selectionContext.isSelected("item-b")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export const useSetupAnalytics = () => {
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: t("universalCreate.analyticsError"),
|
||||
message: t("common.analyticsError"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { RankedUniversalCreateAction } from "./universal-create-actions";
|
||||
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
|
||||
describe("getUniversalCreateActionDefinitions", () => {
|
||||
test("only exposes actions allowed without a board context", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: false,
|
||||
permissions: ["board-create", "integration-create"],
|
||||
});
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
|
||||
});
|
||||
|
||||
test("places globally-created apps in the library group", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
|
||||
|
||||
expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
|
||||
});
|
||||
|
||||
test("keeps board content actions available without global create permissions", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
|
||||
});
|
||||
|
||||
test("exposes the complete surface to administrators on a board", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: true,
|
||||
permissions: ["admin", "app-create", "integration-create", "board-create"],
|
||||
});
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual([
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAndRankUniversalCreateActions", () => {
|
||||
const actions = [
|
||||
{ key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
|
||||
{
|
||||
key: "integration",
|
||||
group: "library",
|
||||
priority: 80,
|
||||
name: "Connect service",
|
||||
description: "Create an integration",
|
||||
keywords: ["provider"],
|
||||
},
|
||||
{ key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
|
||||
] satisfies RankedUniversalCreateAction[];
|
||||
|
||||
test("uses product priority when no search is entered", () => {
|
||||
expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
|
||||
"widget",
|
||||
"integration",
|
||||
"board",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ranks a name prefix ahead of a description or keyword match", () => {
|
||||
const results = filterAndRankUniversalCreateActions(actions, "create");
|
||||
|
||||
expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
|
||||
});
|
||||
|
||||
test("matches keywords and removes unrelated actions", () => {
|
||||
expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
export const universalCreateActionKeys = [
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
] as const;
|
||||
|
||||
export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
|
||||
export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
|
||||
|
||||
export interface UniversalCreateContext {
|
||||
hasBoardContext: boolean;
|
||||
permissions: readonly GroupPermissionKey[];
|
||||
}
|
||||
|
||||
interface UniversalCreateActionDefinition {
|
||||
key: UniversalCreateActionKey;
|
||||
group: UniversalCreateActionGroup;
|
||||
priority: number;
|
||||
isVisible: (context: UniversalCreateContext) => boolean;
|
||||
}
|
||||
|
||||
const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
|
||||
context.permissions.includes(permission);
|
||||
|
||||
const definitions: readonly UniversalCreateActionDefinition[] = [
|
||||
{
|
||||
key: "widget",
|
||||
group: "currentBoard",
|
||||
priority: 100,
|
||||
isVisible: ({ hasBoardContext }) => hasBoardContext,
|
||||
},
|
||||
{
|
||||
key: "app",
|
||||
group: "currentBoard",
|
||||
priority: 90,
|
||||
isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
|
||||
},
|
||||
{
|
||||
key: "integration",
|
||||
group: "library",
|
||||
priority: 80,
|
||||
isVisible: (context) => hasPermission(context, "integration-create"),
|
||||
},
|
||||
{
|
||||
key: "container",
|
||||
group: "currentBoard",
|
||||
priority: 70,
|
||||
isVisible: ({ hasBoardContext }) => hasBoardContext,
|
||||
},
|
||||
{
|
||||
key: "board",
|
||||
group: "boards",
|
||||
priority: 60,
|
||||
isVisible: (context) => hasPermission(context, "board-create"),
|
||||
},
|
||||
{
|
||||
key: "workshop",
|
||||
group: "library",
|
||||
priority: 40,
|
||||
isVisible: (context) => hasPermission(context, "admin"),
|
||||
},
|
||||
{
|
||||
key: "customWidget",
|
||||
group: "library",
|
||||
priority: 30,
|
||||
isVisible: (context) => hasPermission(context, "admin"),
|
||||
},
|
||||
];
|
||||
|
||||
export interface RankedUniversalCreateAction {
|
||||
key: UniversalCreateActionKey;
|
||||
group: UniversalCreateActionGroup;
|
||||
priority: number;
|
||||
name: string;
|
||||
description: string;
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
|
||||
definitions
|
||||
.filter((definition) => definition.isVisible(context))
|
||||
.map((definition) =>
|
||||
definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
|
||||
);
|
||||
|
||||
const normalize = (value: string) => value.trim().toLocaleLowerCase();
|
||||
|
||||
export const filterAndRankUniversalCreateActions = <TAction extends RankedUniversalCreateAction>(
|
||||
actions: readonly TAction[],
|
||||
query: string,
|
||||
) => {
|
||||
const normalizedQuery = normalize(query);
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const name = normalize(action.name);
|
||||
const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
|
||||
const relevance =
|
||||
normalizedQuery.length === 0
|
||||
? 0
|
||||
: name === normalizedQuery
|
||||
? 300
|
||||
: name.startsWith(normalizedQuery)
|
||||
? 200
|
||||
: searchableText.includes(normalizedQuery)
|
||||
? 100
|
||||
: -1;
|
||||
|
||||
return { action, relevance };
|
||||
})
|
||||
.filter(({ relevance }) => relevance >= 0)
|
||||
.toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
|
||||
.map(({ action }) => action);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
.action {
|
||||
width: 100%;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.action:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
declare const classes: {
|
||||
readonly action: string;
|
||||
readonly actionIcon: string;
|
||||
};
|
||||
export default classes;
|
||||
@@ -1,162 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconApps,
|
||||
IconBox,
|
||||
IconCode,
|
||||
IconLayoutDashboard,
|
||||
IconLayoutGridAdd,
|
||||
IconPlugConnected,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import classes from "./universal-create-modal.module.css";
|
||||
import type {
|
||||
RankedUniversalCreateAction,
|
||||
UniversalCreateActionGroup,
|
||||
UniversalCreateActionKey,
|
||||
} from "./universal-create-actions";
|
||||
import { getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
import type { SetupMetricEntryPoint } from "./setup-analytics";
|
||||
import { useSetupAnalytics } from "./setup-analytics";
|
||||
|
||||
type BoardActionKey = "widget" | "app" | "integration" | "container";
|
||||
|
||||
export interface UniversalCreateModalProps {
|
||||
boardActions?: Partial<Record<BoardActionKey, () => void>>;
|
||||
entryPoint?: SetupMetricEntryPoint;
|
||||
}
|
||||
|
||||
const actionIcons = {
|
||||
widget: IconApps,
|
||||
app: IconBox,
|
||||
integration: IconPlugConnected,
|
||||
container: IconLayoutGridAdd,
|
||||
board: IconLayoutDashboard,
|
||||
workshop: IconSearch,
|
||||
customWidget: IconCode,
|
||||
} satisfies Record<UniversalCreateActionKey, TablerIcon>;
|
||||
|
||||
export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ actions, innerProps }) => {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
|
||||
const trackedOpen = useRef(false);
|
||||
const boardActions = innerProps.boardActions;
|
||||
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
|
||||
const trackSetup = useSetupAnalytics();
|
||||
const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
|
||||
const definitions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
permissions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedOpen.current) return;
|
||||
trackedOpen.current = true;
|
||||
trackSetup("surface-opened", {
|
||||
entryPoint,
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
});
|
||||
}, [boardActions, entryPoint, trackSetup]);
|
||||
|
||||
const invokeAction = (key: UniversalCreateActionKey) => {
|
||||
const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
|
||||
actions.closeModal();
|
||||
trackSetup("intent-selected", {
|
||||
entryPoint,
|
||||
intent: key,
|
||||
outcome: "continued",
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
});
|
||||
|
||||
if (boardAction) {
|
||||
boardAction();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "app":
|
||||
router.push("/manage/apps/new");
|
||||
break;
|
||||
case "integration":
|
||||
router.push("/manage/integrations/new");
|
||||
break;
|
||||
case "board":
|
||||
openAddBoardModal(undefined);
|
||||
break;
|
||||
case "workshop":
|
||||
router.push("/manage/workshop");
|
||||
break;
|
||||
case "customWidget":
|
||||
router.push("/manage/custom-widgets/new");
|
||||
break;
|
||||
case "widget":
|
||||
case "container":
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedActions = definitions.map(
|
||||
(definition) =>
|
||||
({
|
||||
...definition,
|
||||
name: t(`action.${definition.key}.label`),
|
||||
description: t(`action.${definition.key}.description`),
|
||||
keywords: t(`action.${definition.key}.keywords`).split(" "),
|
||||
}) satisfies RankedUniversalCreateAction,
|
||||
);
|
||||
const orderedActions = resolvedActions.toSorted((left, right) => right.priority - left.priority);
|
||||
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{groups.map((group) => {
|
||||
const groupActions = orderedActions.filter((action) => action.group === group);
|
||||
if (groupActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack key={group} gap={4}>
|
||||
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
|
||||
{t(`group.${group}`)}
|
||||
</Text>
|
||||
{groupActions.map((action) => {
|
||||
const Icon = actionIcons[action.key];
|
||||
return (
|
||||
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
|
||||
<Icon size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={1}>
|
||||
<Text fw={600} size="sm">
|
||||
{action.name}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{action.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("universalCreate.title"),
|
||||
size: "lg",
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UniversalCreateModal } from "./universal-create-modal";
|
||||
|
||||
/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
|
||||
export const UniversalCreateSpotlightResult = () => {
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const item = useMemo(
|
||||
() => ({
|
||||
id: "universal-create",
|
||||
name: t("title"),
|
||||
description: t("trigger.description"),
|
||||
icon: IconPlus,
|
||||
alwaysVisible: true,
|
||||
interaction: () => ({
|
||||
type: "javaScript" as const,
|
||||
onSelect: () => {
|
||||
openModal({ entryPoint: "spotlight" });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
[openModal, t],
|
||||
);
|
||||
|
||||
useRegisterSpotlightContextResults("universal-create", [item], [item]);
|
||||
return null;
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { UniversalCreateModal } from "./universal-create-modal";
|
||||
|
||||
export const UniversalCreateHeaderAction = () => {
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
|
||||
return (
|
||||
<HeaderButton onClick={() => openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const UniversalCreateQueryGate = () => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const handled = useRef(false);
|
||||
const shouldOpen = searchParams.get("create") === "true";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldOpen) {
|
||||
handled.current = false;
|
||||
return;
|
||||
}
|
||||
if (handled.current) return;
|
||||
|
||||
handled.current = true;
|
||||
openModal({ entryPoint: "spotlight" });
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("create");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [openModal, pathname, router, searchParams, shouldOpen]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconArrowLeft, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
@@ -27,9 +28,28 @@ interface IntegrationSelectModalProps {
|
||||
|
||||
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
|
||||
const directKind =
|
||||
innerProps.initialKind ?? (innerProps.allowedKinds?.length === 1 ? innerProps.allowedKinds[0] : null);
|
||||
const isDirectForm = Boolean(directKind);
|
||||
const [step, setStep] = useState<"select" | "form">(isDirectForm ? "form" : "select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(directKind ?? null);
|
||||
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
|
||||
const { data: integrationData, isError: isIntegrationDataError, refetch } = clientApi.integration.all.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "form" && !isDirectForm) {
|
||||
actions.setCloseInterceptor?.(() => {
|
||||
setStep("select");
|
||||
setSelectedKind(null);
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
actions.setCloseInterceptor?.(null);
|
||||
}
|
||||
return () => {
|
||||
actions.setCloseInterceptor?.(null);
|
||||
};
|
||||
}, [step, isDirectForm, actions]);
|
||||
|
||||
const handleSelect = (kind: IntegrationKind) => {
|
||||
setSelectedKind(kind);
|
||||
@@ -40,7 +60,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
setStep("select");
|
||||
setSelectedKind(null);
|
||||
};
|
||||
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
|
||||
const handleFormBack = isDirectForm ? actions.closeModal : handleBack;
|
||||
|
||||
const handleSuccess = (result?: CreatedIntegrationResult) => {
|
||||
actions.closeModal();
|
||||
@@ -52,12 +72,8 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
};
|
||||
|
||||
if (step === "form" && selectedKind) {
|
||||
let HeaderIcon = IconArrowLeft;
|
||||
let headerLabel = t("common.action.previous");
|
||||
if (innerProps.initialKind) {
|
||||
HeaderIcon = IconX;
|
||||
headerLabel = t("common.action.close");
|
||||
}
|
||||
const HeaderIcon = isDirectForm ? IconX : IconArrowLeft;
|
||||
const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous");
|
||||
|
||||
return (
|
||||
<ScrollArea.Autosize mah="80vh">
|
||||
@@ -79,11 +95,25 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
);
|
||||
}
|
||||
|
||||
if (isIntegrationDataError) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("integration.grid.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationSelectGrid
|
||||
onSelect={handleSelect}
|
||||
enableMockIntegration={innerProps.enableMockIntegration}
|
||||
allowedKinds={innerProps.allowedKinds}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
);
|
||||
}).withOptions({
|
||||
|
||||
@@ -3,8 +3,6 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger";
|
||||
import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight";
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
@@ -38,7 +36,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
<DesktopSearchInput />
|
||||
</TourTarget>
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{hasNavigation && <UniversalCreateHeaderAction />}
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
@@ -46,8 +43,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<UniversalCreateQueryGate />
|
||||
<UniversalCreateSpotlightResult />
|
||||
<LazySpotlight />
|
||||
</AppShellHeader>
|
||||
);
|
||||
|
||||
@@ -78,11 +78,15 @@ describe("lazy widget application graph", () => {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
const signedIn = page.waitForURL(baseUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname === "/", {
|
||||
waitUntil: "commit",
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await signedIn;
|
||||
|
||||
await expect(page.locator("[data-homarr-dev-benchmark-board]")).toBeVisible({ timeout: 30_000 });
|
||||
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
||||
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
||||
const clockWidget = page.locator(
|
||||
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { iconsRouter } from "./router/icons";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { airQualityRouter } from "./router/widgets/air-quality";
|
||||
import { bazarrRouter } from "./router/widgets/bazarr";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
@@ -35,6 +36,7 @@ export const mcpRouter = createTRPCRouter({
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
airQuality: airQualityRouter,
|
||||
bazarr: bazarrRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { airQualityRequestHandler } from "@homarr/request-handler/air-quality";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const atLocationInput = z.object({
|
||||
longitude: z.number().min(-180).max(180),
|
||||
latitude: z.number().min(-90).max(90),
|
||||
});
|
||||
|
||||
export const airQualityRouter = createTRPCRouter({
|
||||
atLocation: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get current and forecast air quality, UV, pollutant, and pollen data for geographic coordinates. REQUIRED: latitude (-90 to 90) and longitude (-180 to 180).",
|
||||
},
|
||||
})
|
||||
.input(atLocationInput)
|
||||
.query(async ({ input }) => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||
|
||||
const handler = airQualityRequestHandler.handler(input);
|
||||
return await handler.getDataAsync().then((result) => result.data);
|
||||
}),
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export const widgetRouter = createTRPCRouter({
|
||||
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
|
||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
||||
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
|
||||
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
|
||||
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
|
||||
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
|
||||
|
||||
@@ -6,8 +6,8 @@ import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const atLocationInput = z.object({
|
||||
longitude: z.number(),
|
||||
latitude: z.number(),
|
||||
longitude: z.number().min(-180).max(180),
|
||||
latitude: z.number().min(-90).max(90),
|
||||
});
|
||||
|
||||
export const weatherRouter = createTRPCRouter({
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
const MCP_TOOL_ALLOWLIST = {
|
||||
query: [
|
||||
"airQuality_atLocation",
|
||||
"apiKeys_getAll",
|
||||
"app_all",
|
||||
"app_byId",
|
||||
|
||||
@@ -336,6 +336,9 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/air-quality"
|
||||
| "/docs/widgets/countdown"
|
||||
| "/docs/widgets/timer"
|
||||
| "/docs/widgets/app"
|
||||
| "/docs/widgets/iframe"
|
||||
| "/docs/widgets/video"
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { WidgetKind } from "../widget";
|
||||
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
clock: "clock",
|
||||
weather: "weather",
|
||||
airQuality: "air-quality",
|
||||
countdown: "countdown",
|
||||
timer: "timer",
|
||||
app: "app",
|
||||
iframe: "iframe",
|
||||
video: "video",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const widgetKinds = [
|
||||
"clock",
|
||||
"weather",
|
||||
"airQuality",
|
||||
"countdown",
|
||||
"timer",
|
||||
"app",
|
||||
"iframe",
|
||||
"video",
|
||||
@@ -59,6 +62,9 @@ export const widgetKinds = [
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
|
||||
airQuality: { width: 2, height: 1 },
|
||||
countdown: { width: 2, height: 1 },
|
||||
timer: { width: 2, height: 1 },
|
||||
uptimeKuma: { width: 2, height: 3 },
|
||||
audioStats: { width: 2, height: 2 },
|
||||
paperlessNgx: { width: 2, height: 2 },
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AppSelectModal } from "./app-select-modal";
|
||||
|
||||
const AppSelectModalComponent = AppSelectModal.component;
|
||||
|
||||
const sampleApps = [
|
||||
{
|
||||
id: "app-1",
|
||||
name: "Sonarr",
|
||||
description: "Manage TV series downloads",
|
||||
iconUrl: "https://example.com/sonarr.png",
|
||||
href: "http://sonarr.local",
|
||||
},
|
||||
{
|
||||
id: "app-2",
|
||||
name: "Radarr",
|
||||
description: "Manage movie collections",
|
||||
iconUrl: "https://example.com/radarr.png",
|
||||
href: "http://radarr.local",
|
||||
},
|
||||
{
|
||||
id: "app-3",
|
||||
name: "Plex",
|
||||
description: "Stream your media collection",
|
||||
iconUrl: "https://example.com/plex.png",
|
||||
href: "http://plex.local",
|
||||
},
|
||||
];
|
||||
|
||||
const mockUseQuery = vi.fn(() => ({
|
||||
data: sampleApps,
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const mockOpenQuickAddAppModal = vi.fn();
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: {
|
||||
app: {
|
||||
selectable: {
|
||||
useQuery: () => mockUseQuery(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/modals", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("@homarr/modals");
|
||||
return {
|
||||
...actual,
|
||||
useModalAction: () => ({
|
||||
openModal: mockOpenQuickAddAppModal,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string, values?: { count?: number }) => {
|
||||
const count = values?.count ?? 0;
|
||||
const translations: Record<string, string> = {
|
||||
"app.action.select.search": "Search apps",
|
||||
"app.action.select.title": "Select application",
|
||||
"app.action.select.selected": "Selected",
|
||||
"app.action.select.toggle": "Click to select",
|
||||
"app.action.select.noResults": "No apps found",
|
||||
"app.action.select.multiSelectTip": "Hold Ctrl or Command to select multiple apps",
|
||||
"app.action.select.selectedCount": `${count} selected`,
|
||||
"app.action.select.appsSelected": `${count} ${count === 1 ? "app" : "apps"} selected`,
|
||||
"app.action.select.customApplication": "Custom application",
|
||||
"app.action.select.application": "Application",
|
||||
"app.action.create.title": "Add custom app",
|
||||
"app.action.create.description": "Configure custom URL and icon",
|
||||
"common.action.discard": "Discard",
|
||||
"common.action.add": "Add",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
vi.clearAllMocks();
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: sampleApps,
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("AppSelectModal", () => {
|
||||
describe("single selection mode", () => {
|
||||
it("renders app cards with name, description, and icon", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
expect(host.textContent).toContain("Radarr");
|
||||
expect(host.textContent).toContain("Plex");
|
||||
expect(host.textContent).toContain("Manage TV series downloads");
|
||||
});
|
||||
|
||||
it("selects an app and closes modal when card is clicked", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
expect(sonarrCard).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi selection mode", () => {
|
||||
it("toggles multiple app selections and submits batch on button click", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelectMany = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
const radarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Radarr"));
|
||||
|
||||
// Select Sonarr
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
|
||||
// Modal stays open, 1 app selected
|
||||
expect(closeModal).not.toHaveBeenCalled();
|
||||
expect(host.textContent).toContain("1 app selected");
|
||||
|
||||
// Select Radarr
|
||||
await act(async () => {
|
||||
radarrCard?.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("2 apps selected");
|
||||
|
||||
// Submit multi select
|
||||
const submitBtn = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Add (2)"));
|
||||
expect(submitBtn).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
submitBtn?.click();
|
||||
});
|
||||
|
||||
expect(onSelectMany).toHaveBeenCalledWith([sampleApps[0], sampleApps[1]]);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("unselects an app when clicked a second time", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelectMany = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
|
||||
// Select Sonarr
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
expect(host.textContent).toContain("1 app selected");
|
||||
|
||||
// Unselect Sonarr
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
expect(host.textContent).not.toContain("1 app selected");
|
||||
});
|
||||
|
||||
it("clears selection when Discard button is clicked", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelectMany = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
expect(host.textContent).toContain("1 app selected");
|
||||
|
||||
const discardBtn = Array.from(host.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.includes("Discard"),
|
||||
);
|
||||
await act(async () => {
|
||||
discardBtn?.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain("app selected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search and filtering", () => {
|
||||
it("filters apps in real-time by search query (case-insensitive)", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
expect(searchInput).not.toBeNull();
|
||||
|
||||
// Type "plex"
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "plex");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("Plex");
|
||||
expect(host.textContent).not.toContain("Sonarr");
|
||||
expect(host.textContent).not.toContain("Radarr");
|
||||
});
|
||||
|
||||
it("selects the single matching app when Enter key is pressed in search input", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "sonarr");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("displays empty state message when search yields no results", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "non-matching-query");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("No apps found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom app creation card", () => {
|
||||
it("renders custom app creation card when withCreate is true", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Add custom app");
|
||||
expect(host.textContent).toContain("Configure custom URL and icon");
|
||||
});
|
||||
|
||||
it("omits custom app creation card when withCreate is false", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).not.toContain("Add custom app");
|
||||
});
|
||||
|
||||
it("opens QuickAddAppModal when custom app creation card is clicked in single select mode", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const createCard = Array.from(host.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.includes("Add custom app"),
|
||||
);
|
||||
expect(createCard).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
createCard?.click();
|
||||
});
|
||||
|
||||
expect(mockOpenQuickAddAppModal).toHaveBeenCalledOnce();
|
||||
|
||||
// Trigger the onClose callback passed to QuickAddAppModal
|
||||
const options = mockOpenQuickAddAppModal.mock.calls[0]?.[0] as { onClose: (app: (typeof sampleApps)[0]) => void };
|
||||
const newCustomApp = {
|
||||
id: "custom-app-1",
|
||||
name: "Custom Home Assistant",
|
||||
description: "Local instance",
|
||||
iconUrl: "https://example.com/ha.png",
|
||||
href: "http://ha.local",
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
options.onClose(newCustomApp);
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(newCustomApp);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,25 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Input,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import { selectGridCols, SelectableCard } from "@homarr/ui";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
@@ -36,7 +49,15 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
|
||||
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
|
||||
|
||||
const handleSelect = (app: SelectableApp) => {
|
||||
const handleSelect = (app: SelectableApp, event?: React.MouseEvent) => {
|
||||
const isModifierPressed = multiSelect && Boolean(event?.shiftKey || event?.ctrlKey || event?.metaKey);
|
||||
|
||||
if (innerProps.onSelect && !isModifierPressed && selectedAppIds.size === 0) {
|
||||
innerProps.onSelect(app);
|
||||
actions.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiSelect) {
|
||||
setSelectedAppIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -44,22 +65,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
else next.add(app.id);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
innerProps.onSelect?.(app);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (multiSelect) {
|
||||
innerProps.onSelectMany?.(selectedApps);
|
||||
actions.closeModal();
|
||||
return;
|
||||
}
|
||||
const [first] = filteredApps;
|
||||
if (first) {
|
||||
innerProps.onSelect?.(first);
|
||||
actions.closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,100 +81,145 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
});
|
||||
};
|
||||
|
||||
const handleMultiSubmit = () => {
|
||||
innerProps.onSelectMany?.(selectedApps);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
ariaLabel={t("app.action.select.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{innerProps.withCreate && (
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={t("app.action.create.title")}
|
||||
status={t("app.action.create.action")}
|
||||
onSelect={handleAddNewApp}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
<Text size="xs" c="blue" fw={500} mt="auto">
|
||||
{t("app.action.create.action")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
)}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<CatalogItem
|
||||
key={app.id}
|
||||
height={selectGridCardHeight}
|
||||
label={app.name}
|
||||
status={
|
||||
multiSelect
|
||||
? selectedAppIds.has(app.id)
|
||||
? t("app.action.select.selected")
|
||||
: t("app.action.select.toggle")
|
||||
: t("app.action.select.action", { app: app.name })
|
||||
<Stack gap="md">
|
||||
{/* Top Search Input & Multi-Select Tip */}
|
||||
<Stack gap={6}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
aria-label={t("app.action.select.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
|
||||
onSelect={() => handleSelect(app)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{app.name}
|
||||
</Text>
|
||||
{multiSelect && selectedAppIds.has(app.id) && (
|
||||
<IconCheck size={18} color="var(--mantine-primary-color-filled)" />
|
||||
)}
|
||||
</Group>
|
||||
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{app.description ?? ""}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text size="xs" c="blue" fw={500} mt="auto">
|
||||
{multiSelect
|
||||
? selectedAppIds.has(app.id)
|
||||
? t("app.action.select.selected")
|
||||
: t("app.action.select.toggle")
|
||||
: t("app.action.select.action", { app: app.name })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
))}
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
}}
|
||||
/>
|
||||
{multiSelect && (
|
||||
<Group justify="space-between" px={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("app.action.select.multiSelectTip")}
|
||||
</Text>
|
||||
{selectedAppIds.size > 0 && (
|
||||
<Badge variant="light" color="primaryColor" size="xs">
|
||||
{t("app.action.select.selectedCount", { count: selectedAppIds.size })}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
{multiSelect && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button leftSection={<IconApps size={16} />} disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
|
||||
{t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Scrollable Container with App Cards */}
|
||||
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
|
||||
<Stack gap="md" pt="xs" pr="xs" px={4}>
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{innerProps.withCreate && (
|
||||
<SelectableCard
|
||||
onClick={handleAddNewApp}
|
||||
style={{ borderStyle: "dashed" }}
|
||||
icon={
|
||||
<ThemeIcon variant="light" color="primaryColor" size={34} radius="md">
|
||||
<IconPlus size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
title={t("app.action.create.title")}
|
||||
description={t("app.action.create.description")}
|
||||
footerLeft={
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("app.action.select.customApplication")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
isSelected={selectedAppIds.has(app.id)}
|
||||
multiSelect={multiSelect}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
{/* Multi-Select Action Footer */}
|
||||
{multiSelect && selectedApps.length > 0 && (
|
||||
<Paper withBorder p="xs" radius="md" bg="light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("app.action.select.appsSelected", { count: selectedApps.length })}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button variant="default" size="xs" onClick={() => setSelectedAppIds(new Set())}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button color="primaryColor" size="xs" onClick={handleMultiSubmit}>
|
||||
{t("common.action.add")} ({selectedApps.length})
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// AppCard: Variant 3 (Dashboard Inset) with Medium Title & Large App Icon
|
||||
// =========================================================================
|
||||
const AppCard = ({
|
||||
app,
|
||||
isSelected,
|
||||
multiSelect,
|
||||
onSelect,
|
||||
}: {
|
||||
app: SelectableApp;
|
||||
isSelected: boolean;
|
||||
multiSelect: boolean;
|
||||
onSelect: (app: SelectableApp, event?: React.MouseEvent) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<SelectableCard
|
||||
onClick={(event) => onSelect(app, event)}
|
||||
aria-label={app.name}
|
||||
selected={isSelected}
|
||||
icon={<Image src={app.iconUrl} alt={app.name} w={28} h={28} fit="contain" style={{ flexShrink: 0 }} />}
|
||||
title={app.name}
|
||||
topRight={
|
||||
isSelected ? (
|
||||
<Badge variant="dot" color="primaryColor" size="xs">
|
||||
{t("app.action.select.selected")}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
description={app.description}
|
||||
footerLeft={
|
||||
<Text size="xs" c="dimmed">
|
||||
{multiSelect && isSelected ? t("app.action.select.selected") : t("app.action.select.application")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -102,6 +102,13 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
content: presentationClasses.inspectorContent,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
const canClose = modal.reference.onCloseRequest ? modal.reference.onCloseRequest() : true;
|
||||
if (canClose) {
|
||||
handleCloseModal();
|
||||
}
|
||||
}, [handleCloseModal, modal.reference]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
key={modal.id}
|
||||
@@ -123,7 +130,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
{...otherModalProps}
|
||||
title={translateIfNecessary(t, modal.props.defaultTitle)}
|
||||
opened={opened}
|
||||
onClose={handleCloseModal}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{modal.reference.content}
|
||||
</Modal>
|
||||
@@ -144,7 +151,6 @@ export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) =>
|
||||
return {
|
||||
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
|
||||
// void actually is undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
context.openModalInner({ modal, innerProps, options: options ?? {} });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -94,6 +94,8 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
const ModalContent = modal.modal.component;
|
||||
|
||||
const { innerProps, ...rest } = modal.props;
|
||||
let closeInterceptor: (() => boolean) | null = null;
|
||||
|
||||
const FullModal = () => {
|
||||
const context = useContext(ModalContext);
|
||||
|
||||
@@ -106,6 +108,9 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
innerProps={innerProps}
|
||||
actions={{
|
||||
closeModal: () => context.closeModal(modal.id),
|
||||
setCloseInterceptor: (interceptor: (() => boolean) | null) => {
|
||||
closeInterceptor = interceptor;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -114,5 +119,11 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
return {
|
||||
modalProps: rest,
|
||||
content: <FullModal />,
|
||||
onCloseRequest: () => {
|
||||
if (closeInterceptor) {
|
||||
return closeInterceptor();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -5,10 +5,12 @@ import type { stringOrTranslation } from "@homarr/translation";
|
||||
|
||||
import type { ModalPresentation } from "./modal-presentation";
|
||||
|
||||
export type ModalComponent<TInnerProps> = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: TInnerProps;
|
||||
}) => ReactNode;
|
||||
export interface ModalActions {
|
||||
closeModal: () => void;
|
||||
setCloseInterceptor?: (interceptor: (() => boolean) | null) => void;
|
||||
}
|
||||
|
||||
export type ModalComponent<TInnerProps> = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode;
|
||||
|
||||
export type CreateModalOptions = Pick<
|
||||
ModalOptions<unknown>,
|
||||
|
||||
@@ -104,7 +104,7 @@ const CredentialsSetup = () => {
|
||||
|
||||
return (
|
||||
<AccountShell title={t("title")} description={t("description")}>
|
||||
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
|
||||
<form onSubmit={form.onSubmit((values) => submitAsync(values))}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
|
||||
<UserCreatePasswordFields
|
||||
@@ -119,7 +119,7 @@ const CredentialsSetup = () => {
|
||||
{submitError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
|
||||
<Button type="submit" size="md" loading={form.submitting} rightSection={<IconArrowRight size={18} />}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { airQualityRequestHandler } from "./air-quality";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const makeAirQualityResponse = () => {
|
||||
const times = ["2026-07-01T00:00", "2026-07-01T01:00", "2026-07-02T00:00"];
|
||||
const values = [10, 20, 30];
|
||||
const fields = [
|
||||
"european_aqi",
|
||||
"us_aqi",
|
||||
"uv_index",
|
||||
"pm10",
|
||||
"pm2_5",
|
||||
"carbon_monoxide",
|
||||
"nitrogen_dioxide",
|
||||
"sulphur_dioxide",
|
||||
"ozone",
|
||||
"european_aqi_pm2_5",
|
||||
"european_aqi_pm10",
|
||||
"european_aqi_nitrogen_dioxide",
|
||||
"european_aqi_ozone",
|
||||
"european_aqi_sulphur_dioxide",
|
||||
"us_aqi_pm2_5",
|
||||
"us_aqi_pm10",
|
||||
"us_aqi_nitrogen_dioxide",
|
||||
"us_aqi_carbon_monoxide",
|
||||
"us_aqi_ozone",
|
||||
"us_aqi_sulphur_dioxide",
|
||||
"alder_pollen",
|
||||
"birch_pollen",
|
||||
"grass_pollen",
|
||||
"mugwort_pollen",
|
||||
"olive_pollen",
|
||||
"ragweed_pollen",
|
||||
] as const;
|
||||
|
||||
return {
|
||||
timezone: "Europe/Berlin",
|
||||
utc_offset_seconds: 7200,
|
||||
current: {
|
||||
time: "2026-07-01T12:00",
|
||||
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? null : 12])),
|
||||
},
|
||||
hourly: {
|
||||
time: times,
|
||||
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? [null, null, null] : values])),
|
||||
},
|
||||
fields,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
airQualityRequestHandler.invalidateCache();
|
||||
});
|
||||
|
||||
describe("air quality request handler", () => {
|
||||
it("requests and normalizes the full useful forecast field set", async () => {
|
||||
const fixture = makeAirQualityResponse();
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(fixture)));
|
||||
|
||||
const first = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
const second = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
|
||||
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
|
||||
expect(first.data.current.pollutants.pm2_5).toBe(12);
|
||||
expect(first.data.current.pollen.ragweed).toBeNull();
|
||||
expect(first.data.hourly[0]?.observedAt).toBe("2026-06-30T22:00:00.000Z");
|
||||
expect(first.data.daily).toEqual([
|
||||
{ date: "2026-07-01", europeanAqiMax: 20, usAqiMax: 20, uvIndexMax: 20, pm2_5Max: 20, pm10Max: 20 },
|
||||
{ date: "2026-07-02", europeanAqiMax: 30, usAqiMax: 30, uvIndexMax: 30, pm2_5Max: 30, pm10Max: 30 },
|
||||
]);
|
||||
expect(second.data).toStrictEqual(first.data);
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
||||
expect(requestUrl.origin).toBe("https://air-quality-api.open-meteo.com");
|
||||
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
|
||||
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
|
||||
expect(requestUrl.searchParams.get("current")).toContain("european_aqi_pm2_5");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("us_aqi_carbon_monoxide");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("ragweed_pollen");
|
||||
});
|
||||
|
||||
it("rejects unsuccessful Open-Meteo responses", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
|
||||
|
||||
await expect(
|
||||
airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const FORECAST_DAYS = 7;
|
||||
|
||||
const requestedVariables = [
|
||||
"european_aqi",
|
||||
"us_aqi",
|
||||
"uv_index",
|
||||
"pm10",
|
||||
"pm2_5",
|
||||
"carbon_monoxide",
|
||||
"nitrogen_dioxide",
|
||||
"sulphur_dioxide",
|
||||
"ozone",
|
||||
"european_aqi_pm2_5",
|
||||
"european_aqi_pm10",
|
||||
"european_aqi_nitrogen_dioxide",
|
||||
"european_aqi_ozone",
|
||||
"european_aqi_sulphur_dioxide",
|
||||
"us_aqi_pm2_5",
|
||||
"us_aqi_pm10",
|
||||
"us_aqi_nitrogen_dioxide",
|
||||
"us_aqi_carbon_monoxide",
|
||||
"us_aqi_ozone",
|
||||
"us_aqi_sulphur_dioxide",
|
||||
"alder_pollen",
|
||||
"birch_pollen",
|
||||
"grass_pollen",
|
||||
"mugwort_pollen",
|
||||
"olive_pollen",
|
||||
"ragweed_pollen",
|
||||
] as const;
|
||||
|
||||
export const airQualityRequestHandler = createWidgetRequestHandler({
|
||||
cacheTtlMs: 15 * 60 * 1000,
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 24 * 60 * 60 * 1000,
|
||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||
const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
|
||||
url.searchParams.set("latitude", input.latitude.toString());
|
||||
url.searchParams.set("longitude", input.longitude.toString());
|
||||
url.searchParams.set("current", requestedVariables.join(","));
|
||||
url.searchParams.set("hourly", requestedVariables.join(","));
|
||||
url.searchParams.set("forecast_days", FORECAST_DAYS.toString());
|
||||
url.searchParams.set("timezone", "auto");
|
||||
|
||||
const response = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
});
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const json: unknown = await response.json();
|
||||
const airQuality = await atLocationOutput.parseAsync(json);
|
||||
const hourly = airQuality.hourly.time.map((value, index) => {
|
||||
return normalizePoint(airQuality.hourly, index, toUtcInstant(value, airQuality.timezone));
|
||||
});
|
||||
|
||||
return {
|
||||
timezone: airQuality.timezone,
|
||||
utcOffsetSeconds: airQuality.utc_offset_seconds,
|
||||
current: normalizePoint(
|
||||
airQuality.current,
|
||||
undefined,
|
||||
toUtcInstant(airQuality.current.time, airQuality.timezone),
|
||||
),
|
||||
hourly,
|
||||
daily: getDailyMaximums(airQuality.hourly),
|
||||
} satisfies AirQuality;
|
||||
},
|
||||
});
|
||||
|
||||
const nullableNumber = z.number().nullable();
|
||||
const nullableNumberArray = z.array(nullableNumber);
|
||||
|
||||
const currentValues = z.object({
|
||||
time: z.string(),
|
||||
european_aqi: nullableNumber,
|
||||
us_aqi: nullableNumber,
|
||||
uv_index: nullableNumber,
|
||||
pm10: nullableNumber,
|
||||
pm2_5: nullableNumber,
|
||||
carbon_monoxide: nullableNumber,
|
||||
nitrogen_dioxide: nullableNumber,
|
||||
sulphur_dioxide: nullableNumber,
|
||||
ozone: nullableNumber,
|
||||
european_aqi_pm2_5: nullableNumber,
|
||||
european_aqi_pm10: nullableNumber,
|
||||
european_aqi_nitrogen_dioxide: nullableNumber,
|
||||
european_aqi_ozone: nullableNumber,
|
||||
european_aqi_sulphur_dioxide: nullableNumber,
|
||||
us_aqi_pm2_5: nullableNumber,
|
||||
us_aqi_pm10: nullableNumber,
|
||||
us_aqi_nitrogen_dioxide: nullableNumber,
|
||||
us_aqi_carbon_monoxide: nullableNumber,
|
||||
us_aqi_ozone: nullableNumber,
|
||||
us_aqi_sulphur_dioxide: nullableNumber,
|
||||
alder_pollen: nullableNumber,
|
||||
birch_pollen: nullableNumber,
|
||||
grass_pollen: nullableNumber,
|
||||
mugwort_pollen: nullableNumber,
|
||||
olive_pollen: nullableNumber,
|
||||
ragweed_pollen: nullableNumber,
|
||||
});
|
||||
|
||||
const hourlyValues = z.object({
|
||||
time: z.array(z.string()),
|
||||
european_aqi: nullableNumberArray,
|
||||
us_aqi: nullableNumberArray,
|
||||
uv_index: nullableNumberArray,
|
||||
pm10: nullableNumberArray,
|
||||
pm2_5: nullableNumberArray,
|
||||
carbon_monoxide: nullableNumberArray,
|
||||
nitrogen_dioxide: nullableNumberArray,
|
||||
sulphur_dioxide: nullableNumberArray,
|
||||
ozone: nullableNumberArray,
|
||||
european_aqi_pm2_5: nullableNumberArray,
|
||||
european_aqi_pm10: nullableNumberArray,
|
||||
european_aqi_nitrogen_dioxide: nullableNumberArray,
|
||||
european_aqi_ozone: nullableNumberArray,
|
||||
european_aqi_sulphur_dioxide: nullableNumberArray,
|
||||
us_aqi_pm2_5: nullableNumberArray,
|
||||
us_aqi_pm10: nullableNumberArray,
|
||||
us_aqi_nitrogen_dioxide: nullableNumberArray,
|
||||
us_aqi_carbon_monoxide: nullableNumberArray,
|
||||
us_aqi_ozone: nullableNumberArray,
|
||||
us_aqi_sulphur_dioxide: nullableNumberArray,
|
||||
alder_pollen: nullableNumberArray,
|
||||
birch_pollen: nullableNumberArray,
|
||||
grass_pollen: nullableNumberArray,
|
||||
mugwort_pollen: nullableNumberArray,
|
||||
olive_pollen: nullableNumberArray,
|
||||
ragweed_pollen: nullableNumberArray,
|
||||
});
|
||||
|
||||
const atLocationOutput = z.object({
|
||||
timezone: z.string().min(1),
|
||||
utc_offset_seconds: z.number(),
|
||||
current: currentValues,
|
||||
hourly: hourlyValues,
|
||||
});
|
||||
|
||||
type CurrentValues = z.infer<typeof currentValues>;
|
||||
type HourlyValues = z.infer<typeof hourlyValues>;
|
||||
|
||||
const getValue = (values: CurrentValues | HourlyValues, field: keyof Omit<CurrentValues, "time">, index?: number) => {
|
||||
const value = values[field];
|
||||
if (Array.isArray(value)) return value[index ?? 0] ?? null;
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizePoint = (
|
||||
values: CurrentValues | HourlyValues,
|
||||
index: number | undefined,
|
||||
observedAt: string,
|
||||
): AirQualityPoint => ({
|
||||
observedAt,
|
||||
europeanAqi: getValue(values, "european_aqi", index),
|
||||
usAqi: getValue(values, "us_aqi", index),
|
||||
uvIndex: getValue(values, "uv_index", index),
|
||||
pollutants: {
|
||||
pm2_5: getValue(values, "pm2_5", index),
|
||||
pm10: getValue(values, "pm10", index),
|
||||
ozone: getValue(values, "ozone", index),
|
||||
nitrogenDioxide: getValue(values, "nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "sulphur_dioxide", index),
|
||||
carbonMonoxide: getValue(values, "carbon_monoxide", index),
|
||||
},
|
||||
europeanSubIndices: {
|
||||
pm2_5: getValue(values, "european_aqi_pm2_5", index),
|
||||
pm10: getValue(values, "european_aqi_pm10", index),
|
||||
ozone: getValue(values, "european_aqi_ozone", index),
|
||||
nitrogenDioxide: getValue(values, "european_aqi_nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "european_aqi_sulphur_dioxide", index),
|
||||
carbonMonoxide: null,
|
||||
},
|
||||
usSubIndices: {
|
||||
pm2_5: getValue(values, "us_aqi_pm2_5", index),
|
||||
pm10: getValue(values, "us_aqi_pm10", index),
|
||||
ozone: getValue(values, "us_aqi_ozone", index),
|
||||
nitrogenDioxide: getValue(values, "us_aqi_nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "us_aqi_sulphur_dioxide", index),
|
||||
carbonMonoxide: getValue(values, "us_aqi_carbon_monoxide", index),
|
||||
},
|
||||
pollen: {
|
||||
alder: getValue(values, "alder_pollen", index),
|
||||
birch: getValue(values, "birch_pollen", index),
|
||||
grass: getValue(values, "grass_pollen", index),
|
||||
mugwort: getValue(values, "mugwort_pollen", index),
|
||||
olive: getValue(values, "olive_pollen", index),
|
||||
ragweed: getValue(values, "ragweed_pollen", index),
|
||||
},
|
||||
});
|
||||
|
||||
const getDailyMaximums = (hourly: HourlyValues): AirQuality["daily"] => {
|
||||
const grouped = new Map<string, AirQuality["daily"][number]>();
|
||||
|
||||
hourly.time.forEach((time, index) => {
|
||||
const date = time.slice(0, 10);
|
||||
const existing = grouped.get(date) ?? {
|
||||
date,
|
||||
europeanAqiMax: null,
|
||||
usAqiMax: null,
|
||||
uvIndexMax: null,
|
||||
pm2_5Max: null,
|
||||
pm10Max: null,
|
||||
};
|
||||
|
||||
existing.europeanAqiMax = maxNullable(existing.europeanAqiMax, hourly.european_aqi[index]);
|
||||
existing.usAqiMax = maxNullable(existing.usAqiMax, hourly.us_aqi[index]);
|
||||
existing.uvIndexMax = maxNullable(existing.uvIndexMax, hourly.uv_index[index]);
|
||||
existing.pm2_5Max = maxNullable(existing.pm2_5Max, hourly.pm2_5[index]);
|
||||
existing.pm10Max = maxNullable(existing.pm10Max, hourly.pm10[index]);
|
||||
grouped.set(date, existing);
|
||||
});
|
||||
|
||||
return Array.from(grouped.values()).slice(0, FORECAST_DAYS);
|
||||
};
|
||||
|
||||
const maxNullable = (current: number | null, candidate: number | null | undefined): number | null => {
|
||||
if (candidate === null || candidate === undefined) return current;
|
||||
if (current === null) return candidate;
|
||||
return Math.max(current, candidate);
|
||||
};
|
||||
|
||||
const toUtcInstant = (value: string, timeZone: string): string => {
|
||||
const parsed = dayjs.tz(value, timeZone);
|
||||
if (!parsed.isValid()) throw new Error(`Invalid air quality timestamp: ${value}`);
|
||||
return parsed.utc().toISOString();
|
||||
};
|
||||
|
||||
export interface AirQualityPollutants {
|
||||
pm2_5: number | null;
|
||||
pm10: number | null;
|
||||
ozone: number | null;
|
||||
nitrogenDioxide: number | null;
|
||||
sulphurDioxide: number | null;
|
||||
carbonMonoxide: number | null;
|
||||
}
|
||||
|
||||
export interface AirQualityPoint {
|
||||
observedAt: string;
|
||||
europeanAqi: number | null;
|
||||
usAqi: number | null;
|
||||
uvIndex: number | null;
|
||||
pollutants: AirQualityPollutants;
|
||||
europeanSubIndices: AirQualityPollutants;
|
||||
usSubIndices: AirQualityPollutants;
|
||||
pollen: {
|
||||
alder: number | null;
|
||||
birch: number | null;
|
||||
grass: number | null;
|
||||
mugwort: number | null;
|
||||
olive: number | null;
|
||||
ragweed: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AirQuality {
|
||||
timezone: string;
|
||||
utcOffsetSeconds: number;
|
||||
current: AirQualityPoint;
|
||||
hourly: AirQualityPoint[];
|
||||
daily: {
|
||||
date: string;
|
||||
europeanAqiMax: number | null;
|
||||
usAqiMax: number | null;
|
||||
uvIndexMax: number | null;
|
||||
pm2_5Max: number | null;
|
||||
pm10Max: number | null;
|
||||
}[];
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import { createRequestHandler } from "./request-handler";
|
||||
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
}
|
||||
|
||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
|
||||
const inner = createRequestHandler<TData, TInput>(options);
|
||||
return inner;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { weatherRequestHandler } from "./weather";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const makeWeatherResponse = () => {
|
||||
const hours = Array.from({ length: 25 }, (_, index) => `2026-07-01T${String(index % 24).padStart(2, "0")}:00`);
|
||||
const days = [
|
||||
"2026-06-30",
|
||||
...Array.from({ length: 7 }, (_, index) => `2026-07-${String(index + 1).padStart(2, "0")}`),
|
||||
];
|
||||
const hourlyValues = hours.map<number | null>((_, index) => index);
|
||||
const dailyValues = days.map<number | null>((_, index) => index);
|
||||
const sunrise = days.map<string | null>((day) => `${day}T05:00`);
|
||||
|
||||
return {
|
||||
timezone: "Europe/Berlin",
|
||||
utc_offset_seconds: 7200,
|
||||
current: {
|
||||
time: "2026-07-01T12:00",
|
||||
temperature_2m: 24,
|
||||
relative_humidity_2m: 55,
|
||||
apparent_temperature: 25,
|
||||
is_day: 1,
|
||||
precipitation: 0,
|
||||
weather_code: 1,
|
||||
wind_speed_10m: 12,
|
||||
wind_gusts_10m: 20,
|
||||
},
|
||||
hourly: {
|
||||
time: hours,
|
||||
temperature_2m: hourlyValues,
|
||||
apparent_temperature: hourlyValues,
|
||||
precipitation_probability: hourlyValues,
|
||||
precipitation: hourlyValues,
|
||||
weather_code: hourlyValues,
|
||||
relative_humidity_2m: hourlyValues,
|
||||
wind_speed_10m: hourlyValues,
|
||||
wind_gusts_10m: hourlyValues,
|
||||
},
|
||||
daily: {
|
||||
time: days,
|
||||
weather_code: dailyValues,
|
||||
temperature_2m_max: dailyValues,
|
||||
temperature_2m_min: dailyValues,
|
||||
apparent_temperature_max: dailyValues,
|
||||
apparent_temperature_min: dailyValues,
|
||||
precipitation_probability_max: dailyValues,
|
||||
precipitation_sum: dailyValues,
|
||||
relative_humidity_2m_mean: dailyValues,
|
||||
wind_speed_10m_max: dailyValues,
|
||||
wind_gusts_10m_max: dailyValues,
|
||||
uv_index_max: dailyValues,
|
||||
sunrise,
|
||||
sunset: days.map((day) => `${day}T21:00`),
|
||||
daylight_duration: dailyValues,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
weatherRequestHandler.invalidateCache();
|
||||
});
|
||||
|
||||
describe("weather request handler", () => {
|
||||
it("requests bounded useful forecasts and normalizes location times", async () => {
|
||||
const response = makeWeatherResponse();
|
||||
response.hourly.temperature_2m = [null];
|
||||
Reflect.deleteProperty(response.hourly, "wind_gusts_10m");
|
||||
response.daily.sunrise[1] = null;
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(response)));
|
||||
|
||||
const first = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
const second = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
|
||||
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
|
||||
expect(first.data.hourly).toHaveLength(24);
|
||||
expect(first.data.daily).toHaveLength(7);
|
||||
expect(first.data.daily[0]?.date).toBe("2026-07-01");
|
||||
expect(first.data.hourly[0]?.temperature).toBeNull();
|
||||
expect(first.data.hourly[1]?.temperature).toBeNull();
|
||||
expect(first.data.hourly[0]?.windGusts).toBeNull();
|
||||
expect(first.data.daily[0]?.sunriseAt).toBeNull();
|
||||
expect(first.data.daily[1]?.sunriseAt).toBe("2026-07-02T03:00:00.000Z");
|
||||
expect(second.data).toStrictEqual(first.data);
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
||||
expect(requestUrl.searchParams.get("forecast_hours")).toBe("24");
|
||||
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
|
||||
expect(requestUrl.searchParams.has("past_days")).toBe(false);
|
||||
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
|
||||
expect(requestUrl.searchParams.get("current")).toContain("apparent_temperature");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("precipitation_probability");
|
||||
expect(requestUrl.searchParams.get("daily")).toContain("uv_index_max");
|
||||
});
|
||||
|
||||
it("rejects unsuccessful Open-Meteo responses", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
|
||||
|
||||
await expect(weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,73 +1,255 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const HOURLY_FORECAST_LENGTH = 24;
|
||||
const DAILY_FORECAST_LENGTH = 7;
|
||||
|
||||
const requestWeatherAsync = async (input: { latitude: number; longitude: number }): Promise<Weather> => {
|
||||
const url = new URL("https://api.open-meteo.com/v1/forecast");
|
||||
url.searchParams.set("latitude", input.latitude.toString());
|
||||
url.searchParams.set("longitude", input.longitude.toString());
|
||||
url.searchParams.set(
|
||||
"current",
|
||||
[
|
||||
"temperature_2m",
|
||||
"relative_humidity_2m",
|
||||
"apparent_temperature",
|
||||
"is_day",
|
||||
"precipitation",
|
||||
"weather_code",
|
||||
"wind_speed_10m",
|
||||
"wind_gusts_10m",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set(
|
||||
"hourly",
|
||||
[
|
||||
"temperature_2m",
|
||||
"apparent_temperature",
|
||||
"precipitation_probability",
|
||||
"precipitation",
|
||||
"weather_code",
|
||||
"relative_humidity_2m",
|
||||
"wind_speed_10m",
|
||||
"wind_gusts_10m",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set(
|
||||
"daily",
|
||||
[
|
||||
"weather_code",
|
||||
"temperature_2m_max",
|
||||
"temperature_2m_min",
|
||||
"apparent_temperature_max",
|
||||
"apparent_temperature_min",
|
||||
"precipitation_probability_max",
|
||||
"precipitation_sum",
|
||||
"relative_humidity_2m_mean",
|
||||
"wind_speed_10m_max",
|
||||
"wind_gusts_10m_max",
|
||||
"uv_index_max",
|
||||
"sunrise",
|
||||
"sunset",
|
||||
"daylight_duration",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set("timezone", "auto");
|
||||
url.searchParams.set("forecast_hours", HOURLY_FORECAST_LENGTH.toString());
|
||||
url.searchParams.set("forecast_days", DAILY_FORECAST_LENGTH.toString());
|
||||
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
});
|
||||
if (!res.ok) throw new ResponseError(res);
|
||||
|
||||
const json: unknown = await res.json();
|
||||
const weather = await atLocationOutput.parseAsync(json);
|
||||
const currentLocalDate = weather.current.time.slice(0, 10);
|
||||
const matchedCurrentDayIndex = weather.daily.time.indexOf(currentLocalDate);
|
||||
const currentDayIndex = matchedCurrentDayIndex < 0 ? 0 : matchedCurrentDayIndex;
|
||||
const dailyIndexes = weather.daily.time
|
||||
.map((_, index) => index)
|
||||
.slice(currentDayIndex, currentDayIndex + DAILY_FORECAST_LENGTH);
|
||||
return {
|
||||
timezone: weather.timezone,
|
||||
utcOffsetSeconds: weather.utc_offset_seconds,
|
||||
current: {
|
||||
observedAt: toUtcInstant(weather.current.time, weather.timezone),
|
||||
temperature: weather.current.temperature_2m,
|
||||
apparentTemperature: weather.current.apparent_temperature,
|
||||
relativeHumidity: weather.current.relative_humidity_2m,
|
||||
precipitation: weather.current.precipitation,
|
||||
weatherCode: weather.current.weather_code,
|
||||
weathercode: weather.current.weather_code,
|
||||
windSpeed: weather.current.wind_speed_10m,
|
||||
windspeed: weather.current.wind_speed_10m,
|
||||
windGusts: weather.current.wind_gusts_10m,
|
||||
isDay: weather.current.is_day === 1,
|
||||
},
|
||||
hourly: weather.hourly.time.slice(0, HOURLY_FORECAST_LENGTH).map((value, index) => {
|
||||
return {
|
||||
observedAt: toUtcInstant(value, weather.timezone),
|
||||
temperature: weather.hourly.temperature_2m[index] ?? null,
|
||||
apparentTemperature: weather.hourly.apparent_temperature[index] ?? null,
|
||||
precipitationProbability: weather.hourly.precipitation_probability[index] ?? null,
|
||||
precipitation: weather.hourly.precipitation[index] ?? null,
|
||||
weatherCode: weather.hourly.weather_code[index] ?? null,
|
||||
relativeHumidity: weather.hourly.relative_humidity_2m[index] ?? null,
|
||||
windSpeed: weather.hourly.wind_speed_10m[index] ?? null,
|
||||
windGusts: weather.hourly.wind_gusts_10m[index] ?? null,
|
||||
};
|
||||
}),
|
||||
daily: dailyIndexes.map((index) => {
|
||||
const value = weather.daily.time[index] ?? currentLocalDate;
|
||||
return {
|
||||
date: value,
|
||||
time: value,
|
||||
weatherCode: weather.daily.weather_code[index] ?? 404,
|
||||
maxTemperature: weather.daily.temperature_2m_max[index] ?? null,
|
||||
maxTemp: weather.daily.temperature_2m_max[index] ?? undefined,
|
||||
minTemperature: weather.daily.temperature_2m_min[index] ?? null,
|
||||
minTemp: weather.daily.temperature_2m_min[index] ?? undefined,
|
||||
maxApparentTemperature: weather.daily.apparent_temperature_max[index] ?? null,
|
||||
minApparentTemperature: weather.daily.apparent_temperature_min[index] ?? null,
|
||||
precipitationProbability: weather.daily.precipitation_probability_max[index] ?? null,
|
||||
precipitation: weather.daily.precipitation_sum[index] ?? null,
|
||||
averageHumidity: weather.daily.relative_humidity_2m_mean[index] ?? null,
|
||||
humidity: weather.daily.relative_humidity_2m_mean[index] ?? undefined,
|
||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index] ?? null,
|
||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index] ?? null,
|
||||
uvIndex: weather.daily.uv_index_max[index] ?? null,
|
||||
sunriseAt: toOptionalUtcInstant(weather.daily.sunrise[index], weather.timezone),
|
||||
sunrise: weather.daily.sunrise[index] ?? undefined,
|
||||
sunsetAt: toOptionalUtcInstant(weather.daily.sunset[index], weather.timezone),
|
||||
sunset: weather.daily.sunset[index] ?? undefined,
|
||||
daylightDuration: weather.daily.daylight_duration[index] ?? null,
|
||||
};
|
||||
}),
|
||||
} satisfies Weather;
|
||||
};
|
||||
|
||||
export const weatherRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean¤t_weather=true&timezone=auto`,
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
const json: unknown = await res.json();
|
||||
const weather = await atLocationOutput.parseAsync(json);
|
||||
return {
|
||||
current: weather.current_weather,
|
||||
daily: weather.daily.time.map((value, index) => {
|
||||
return {
|
||||
time: value,
|
||||
weatherCode: weather.daily.weathercode[index] ?? 404,
|
||||
maxTemp: weather.daily.temperature_2m_max[index],
|
||||
minTemp: weather.daily.temperature_2m_min[index],
|
||||
sunrise: weather.daily.sunrise[index],
|
||||
sunset: weather.daily.sunset[index],
|
||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
||||
humidity: weather.daily.relative_humidity_2m_mean[index],
|
||||
};
|
||||
}),
|
||||
} satisfies Weather;
|
||||
},
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
requestAsync: requestWeatherAsync,
|
||||
});
|
||||
|
||||
const nullableNumberArray = z.array(z.number().nullable()).default([]);
|
||||
const nullableStringArray = z.array(z.string().nullable()).default([]);
|
||||
|
||||
const atLocationOutput = z.object({
|
||||
current_weather: z.object({
|
||||
weathercode: z.number(),
|
||||
temperature: z.number(),
|
||||
windspeed: z.number(),
|
||||
timezone: z.string().min(1),
|
||||
utc_offset_seconds: z.number(),
|
||||
current: z.object({
|
||||
time: z.string(),
|
||||
temperature_2m: z.number(),
|
||||
relative_humidity_2m: z.number(),
|
||||
apparent_temperature: z.number(),
|
||||
is_day: z.number(),
|
||||
precipitation: z.number(),
|
||||
weather_code: z.number(),
|
||||
wind_speed_10m: z.number(),
|
||||
wind_gusts_10m: z.number(),
|
||||
}),
|
||||
hourly: z.object({
|
||||
time: z.array(z.string()),
|
||||
temperature_2m: nullableNumberArray,
|
||||
apparent_temperature: nullableNumberArray,
|
||||
precipitation_probability: nullableNumberArray,
|
||||
precipitation: nullableNumberArray,
|
||||
weather_code: nullableNumberArray,
|
||||
relative_humidity_2m: nullableNumberArray,
|
||||
wind_speed_10m: nullableNumberArray,
|
||||
wind_gusts_10m: nullableNumberArray,
|
||||
}),
|
||||
daily: z.object({
|
||||
time: z.array(z.string()),
|
||||
weathercode: z.array(z.number()),
|
||||
temperature_2m_max: z.array(z.number()),
|
||||
temperature_2m_min: z.array(z.number()),
|
||||
sunrise: z.array(z.string()),
|
||||
sunset: z.array(z.string()),
|
||||
wind_speed_10m_max: z.array(z.number()),
|
||||
wind_gusts_10m_max: z.array(z.number()),
|
||||
relative_humidity_2m_mean: z.array(z.number()),
|
||||
weather_code: nullableNumberArray,
|
||||
temperature_2m_max: nullableNumberArray,
|
||||
temperature_2m_min: nullableNumberArray,
|
||||
apparent_temperature_max: nullableNumberArray,
|
||||
apparent_temperature_min: nullableNumberArray,
|
||||
precipitation_probability_max: nullableNumberArray,
|
||||
precipitation_sum: nullableNumberArray,
|
||||
relative_humidity_2m_mean: nullableNumberArray,
|
||||
wind_speed_10m_max: nullableNumberArray,
|
||||
wind_gusts_10m_max: nullableNumberArray,
|
||||
uv_index_max: nullableNumberArray,
|
||||
sunrise: nullableStringArray,
|
||||
sunset: nullableStringArray,
|
||||
daylight_duration: nullableNumberArray,
|
||||
}),
|
||||
});
|
||||
|
||||
const toUtcInstant = (value: string, timeZone: string): string => {
|
||||
const parsed = dayjs.tz(value, timeZone);
|
||||
if (!parsed.isValid()) throw new Error(`Invalid weather timestamp: ${value}`);
|
||||
return parsed.utc().toISOString();
|
||||
};
|
||||
|
||||
const toOptionalUtcInstant = (value: string | null | undefined, timeZone: string): string | null => {
|
||||
if (!value) return null;
|
||||
return toUtcInstant(value, timeZone);
|
||||
};
|
||||
|
||||
export interface Weather {
|
||||
timezone: string;
|
||||
utcOffsetSeconds: number;
|
||||
current: {
|
||||
weathercode: number;
|
||||
temperature: number;
|
||||
apparentTemperature: number;
|
||||
relativeHumidity: number;
|
||||
precipitation: number;
|
||||
weatherCode: number;
|
||||
weathercode: number;
|
||||
windSpeed: number;
|
||||
windspeed: number;
|
||||
windGusts: number;
|
||||
isDay: boolean;
|
||||
observedAt: string;
|
||||
};
|
||||
hourly: {
|
||||
observedAt: string;
|
||||
temperature: number | null;
|
||||
apparentTemperature: number | null;
|
||||
precipitationProbability: number | null;
|
||||
precipitation: number | null;
|
||||
weatherCode: number | null;
|
||||
relativeHumidity: number | null;
|
||||
windSpeed: number | null;
|
||||
windGusts: number | null;
|
||||
}[];
|
||||
daily: {
|
||||
date: string;
|
||||
time: string;
|
||||
weatherCode: number;
|
||||
maxTemperature: number | null;
|
||||
maxTemp: number | undefined;
|
||||
minTemperature: number | null;
|
||||
minTemp: number | undefined;
|
||||
sunrise: string | undefined;
|
||||
sunset: string | undefined;
|
||||
maxWindSpeed: number | undefined;
|
||||
maxWindGusts: number | undefined;
|
||||
maxApparentTemperature: number | null;
|
||||
minApparentTemperature: number | null;
|
||||
precipitationProbability: number | null;
|
||||
precipitation: number | null;
|
||||
averageHumidity: number | null;
|
||||
humidity: number | undefined;
|
||||
maxWindSpeed: number | null;
|
||||
maxWindGusts: number | null;
|
||||
uvIndex: number | null;
|
||||
sunriseAt: string | null;
|
||||
sunrise: string | undefined;
|
||||
sunsetAt: string | null;
|
||||
sunset: string | undefined;
|
||||
daylightDuration: number | null;
|
||||
}[];
|
||||
}
|
||||
@@ -236,61 +236,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -876,7 +821,12 @@
|
||||
"title": "Select an app to add to this board",
|
||||
"toggle": "Click to select",
|
||||
"selected": "Selected",
|
||||
"multiple": "Add {count}"
|
||||
"multiple": "Add {count}",
|
||||
"multiSelectTip": "Tip: Hold Ctrl / ⌘ to select multiple apps at once",
|
||||
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
|
||||
"appsSelected": "{count, plural, one {# app selected} other {# apps selected}}",
|
||||
"customApplication": "Custom application",
|
||||
"application": "Application"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create new app",
|
||||
@@ -1721,7 +1671,11 @@
|
||||
"grid": {
|
||||
"widgetCount": "{count} widgets",
|
||||
"noWidgets": "No widgets",
|
||||
"detected": "Detected via Docker"
|
||||
"detected": "Detected via Docker",
|
||||
"connected": "{count, plural, one {# connected} other {# connected}}",
|
||||
"tiedWidgets": "Tied widgets",
|
||||
"more": "+{count} more",
|
||||
"loadError": "Integrations could not be loaded. Try again before creating a new connection."
|
||||
},
|
||||
"category": {
|
||||
"dnsHole": "DNS",
|
||||
@@ -2135,6 +2089,7 @@
|
||||
"success": "Success",
|
||||
"beta": "Beta",
|
||||
"error": "Error",
|
||||
"analyticsError": "Setup analytics could not be recorded.",
|
||||
"action": {
|
||||
"add": "Add",
|
||||
"apply": "Apply",
|
||||
@@ -2810,6 +2765,8 @@
|
||||
"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."
|
||||
@@ -2826,6 +2783,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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}",
|
||||
@@ -3169,9 +3140,6 @@
|
||||
"label": "24-hour format",
|
||||
"description": "Use 24-hour format instead of 12-hour format"
|
||||
},
|
||||
"showSeconds": {
|
||||
"label": "Display seconds"
|
||||
},
|
||||
"useCustomTimezone": {
|
||||
"label": "Use a fixed timezone"
|
||||
},
|
||||
@@ -3183,16 +3151,12 @@
|
||||
"label": "Show the date"
|
||||
},
|
||||
"dateFormat": {
|
||||
"label": "Date Format",
|
||||
"description": "How the date should look like"
|
||||
"label": "Date format",
|
||||
"description": "Choose a Day.js preset. Each option shows a live example and its format tokens."
|
||||
},
|
||||
"customTimeFormat": {
|
||||
"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)"
|
||||
"label": "Time format",
|
||||
"description": "Choose a Day.js preset. The selected format controls 12/24-hour display and whether the clock updates every second."
|
||||
},
|
||||
"showWeather": {
|
||||
"label": "Show weather",
|
||||
@@ -3203,7 +3167,273 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -3599,6 +3829,13 @@
|
||||
"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"
|
||||
@@ -3652,11 +3889,40 @@
|
||||
"detailsFor": "Show weather details for {date}",
|
||||
"advanced": {
|
||||
"forecast": "7-day forecast",
|
||||
"hourlyForecast": "Next 24 hours",
|
||||
"day": "Day",
|
||||
"time": "Time",
|
||||
"conditions": "Conditions",
|
||||
"temperature": "High / low",
|
||||
"temperature": "Temperature",
|
||||
"feelsLike": "Feels like {temperature}",
|
||||
"feelsLikeLabel": "Feels like",
|
||||
"feelsLikeRange": "Feels like: {maximum} / {minimum}",
|
||||
"highLow": "High {maximum} · low {minimum}",
|
||||
"observedAt": "Updated at {time}",
|
||||
"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"
|
||||
}
|
||||
@@ -4570,70 +4836,70 @@
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
"columnTitle": "Controls"
|
||||
"columnTitle": "Actions"
|
||||
},
|
||||
"added": {
|
||||
"columnTitle": "Added",
|
||||
"detailsTitle": "Date Added"
|
||||
"detailsTitle": "Added"
|
||||
},
|
||||
"category": {
|
||||
"columnTitle": "Extras",
|
||||
"detailsTitle": "Categories (Or extra information)"
|
||||
"columnTitle": "Category",
|
||||
"detailsTitle": "Category"
|
||||
},
|
||||
"downSpeed": {
|
||||
"columnTitle": "Down",
|
||||
"detailsTitle": "Download Speed"
|
||||
"detailsTitle": "Down"
|
||||
},
|
||||
"index": {
|
||||
"columnTitle": "#",
|
||||
"detailsTitle": "Current index within client"
|
||||
"detailsTitle": "Index"
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "Id",
|
||||
"detailsTitle": "Item ID"
|
||||
"columnTitle": "ID",
|
||||
"detailsTitle": "ID"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": "Integration"
|
||||
"columnTitle": "Source"
|
||||
},
|
||||
"name": {
|
||||
"columnTitle": "Job name"
|
||||
"columnTitle": "Name"
|
||||
},
|
||||
"progress": {
|
||||
"columnTitle": "Progress",
|
||||
"detailsTitle": "Download Progress"
|
||||
"detailsTitle": "Progress"
|
||||
},
|
||||
"ratio": {
|
||||
"columnTitle": "Ratio",
|
||||
"detailsTitle": "Torrent ratio (received/sent)"
|
||||
"detailsTitle": "Ratio"
|
||||
},
|
||||
"received": {
|
||||
"columnTitle": "Total down",
|
||||
"detailsTitle": "Total downloaded"
|
||||
"columnTitle": "Down",
|
||||
"detailsTitle": "Downloaded"
|
||||
},
|
||||
"sent": {
|
||||
"columnTitle": "Total up",
|
||||
"detailsTitle": "Total Uploaded"
|
||||
"columnTitle": "Up",
|
||||
"detailsTitle": "Uploaded"
|
||||
},
|
||||
"size": {
|
||||
"columnTitle": "File Size",
|
||||
"detailsTitle": "Total Size of selection/files"
|
||||
"columnTitle": "Size",
|
||||
"detailsTitle": "Size"
|
||||
},
|
||||
"state": {
|
||||
"columnTitle": "State",
|
||||
"detailsTitle": "Job State"
|
||||
"detailsTitle": "State"
|
||||
},
|
||||
"time": {
|
||||
"columnTitle": "Finish time",
|
||||
"detailsTitle": "Time since/to completion",
|
||||
"columnTitle": "ETA",
|
||||
"detailsTitle": "ETA",
|
||||
"eta": "ETA"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "Type",
|
||||
"detailsTitle": "Download Client type"
|
||||
"detailsTitle": "Type"
|
||||
},
|
||||
"upSpeed": {
|
||||
"columnTitle": "Up",
|
||||
"detailsTitle": "Upload Speed"
|
||||
"detailsTitle": "Up"
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
|
||||
@@ -13,6 +13,13 @@ 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";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { SelectableCard } from "./selectable-card";
|
||||
export type {
|
||||
SelectableCardProps,
|
||||
SelectableCardStylesNames,
|
||||
SelectableCardCssVariables,
|
||||
SelectableCardFactory,
|
||||
} from "./selectable-card";
|
||||
@@ -0,0 +1,79 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
declare const classes: {
|
||||
readonly root: string;
|
||||
readonly header: string;
|
||||
readonly body: string;
|
||||
readonly footer: string;
|
||||
};
|
||||
export default classes;
|
||||
@@ -0,0 +1,370 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
"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;
|
||||
@@ -0,0 +1,279 @@
|
||||
// @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,27 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconPuzzle } from "@tabler/icons-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 type { IntegrationKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { IntegrationAvatar } from "../components/integration-avatar";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout";
|
||||
import { SelectableCard } from "../components/selectable-card";
|
||||
import { selectGridCols } from "../select-grid-layout";
|
||||
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||
import classes from "./integration-select-grid.module.css";
|
||||
import type { IntegrationGridItem } from "./integration-grid-shared";
|
||||
|
||||
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();
|
||||
@@ -32,75 +73,182 @@ export const IntegrationSelectGrid = ({
|
||||
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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);
|
||||
}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
</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,7 +13,9 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
|
||||
"./inputs": "./src/_inputs/index.ts",
|
||||
"./inputs/form": "./src/_inputs/form.ts",
|
||||
"./inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
|
||||
"./custom-api/component": "./src/custom-api/component.tsx",
|
||||
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
|
||||
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
|
||||
@@ -65,6 +67,7 @@
|
||||
"@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,6 +3,7 @@ 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";
|
||||
@@ -15,6 +16,7 @@ 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";
|
||||
@@ -26,6 +28,8 @@ const mapping = {
|
||||
internal: WidgetInternalInput,
|
||||
anchorNote: WidgetAnchorNoteInput,
|
||||
text: WidgetTextInput,
|
||||
dateTimeEventList: WidgetDateTimeEventListInput,
|
||||
timezoneList: WidgetTimezoneListInput,
|
||||
location: WidgetLocationInput,
|
||||
multiSelect: WidgetMultiSelectInput,
|
||||
multiText: WidgetMultiTextInput,
|
||||
|
||||
@@ -17,6 +17,12 @@ 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);
|
||||
@@ -49,13 +55,9 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
|
||||
}
|
||||
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||
description={
|
||||
<Text size="xs">
|
||||
<Text component="span" size="xs">
|
||||
{t.rich("widget.common.app.description", {
|
||||
here: () => (
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
||||
{t("common.here")}
|
||||
</Anchor>
|
||||
),
|
||||
here: renderHereLink,
|
||||
})}
|
||||
</Text>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"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,6 +72,7 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
|
||||
);
|
||||
}}
|
||||
description={options.withDescription ? tWidget("description") : undefined}
|
||||
placeholder={options.withPlaceholder ? tWidget("placeholder") : undefined}
|
||||
searchable={options.searchable}
|
||||
{...inputProps}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"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;
|
||||
@@ -0,0 +1,399 @@
|
||||
"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);
|
||||
@@ -0,0 +1,64 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
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 });
|
||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
||||
const app = getUsableWidgetQueryData(appQuery);
|
||||
const href = getSafeAppHref(app?.href);
|
||||
useRegisterSpotlightContextResults(
|
||||
@@ -50,6 +50,7 @@ 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 />;
|
||||
|
||||
|
||||
@@ -123,19 +123,12 @@ const MIN_CELL_HEIGHT = 80;
|
||||
const ADVANCED_MIN_CELL_WIDTH = 320;
|
||||
const ADVANCED_MIN_CELL_HEIGHT = 420;
|
||||
|
||||
const getColCount = (width: number, _height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
|
||||
const getColCount = (width: number, height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
|
||||
if (itemCount <= 1) return 1;
|
||||
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth)));
|
||||
|
||||
let best = 1;
|
||||
for (let c = 1; c <= maxCols; c++) {
|
||||
const emptyCells = (c - (itemCount % c)) % c;
|
||||
const bestEmpty = (best - (itemCount % best)) % best;
|
||||
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
|
||||
const idealCols = Math.round(Math.sqrt(itemCount * aspectRatio));
|
||||
return Math.min(maxCols, Math.max(1, idealCols));
|
||||
};
|
||||
|
||||
interface MetricRowProps {
|
||||
|
||||
@@ -18,12 +18,14 @@ import {
|
||||
IconChartAreaLine,
|
||||
IconChartBar,
|
||||
IconClock,
|
||||
IconClockPlay,
|
||||
IconCloud,
|
||||
IconDeviceCctv,
|
||||
IconDeviceGamepad,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconGraphFilled,
|
||||
IconHourglass,
|
||||
IconHeadphones,
|
||||
IconHeartRateMonitor,
|
||||
IconHeartbeat,
|
||||
@@ -47,6 +49,7 @@ import {
|
||||
IconTransform,
|
||||
IconVideo,
|
||||
IconWall,
|
||||
IconWind,
|
||||
IconZoomQuestion,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
@@ -56,6 +59,9 @@ 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,
|
||||
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user