Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b6f08a36 | ||
|
|
5cd627d66c | ||
|
|
002381d294 | ||
|
|
9b406d5d83 | ||
|
|
4c475b8c0a | ||
|
|
f3014fe271 | ||
|
|
bd4f6a92c6 | ||
|
|
7e8c139590 | ||
|
|
d241bac55d | ||
|
|
58a24569ce | ||
|
|
53b518960c | ||
|
|
ddf8ba9948 |
No files matched your search
@@ -374,21 +374,31 @@ jobs:
|
|||||||
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||||
run: |
|
run: |
|
||||||
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}@${AMD64_DIGEST}" \
|
||||||
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
||||||
|
|
||||||
- name: Verify preview manifest platforms
|
- name: Verify preview manifest platforms
|
||||||
if: "!env.ACT"
|
if: "!env.ACT"
|
||||||
run: |
|
run: |
|
||||||
image="${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
tags=("${PREVIEW_TAG}")
|
||||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||||
for architecture in amd64 arm64; do
|
tags+=("pr-6545")
|
||||||
jq --exit-status --arg architecture "$architecture" \
|
fi
|
||||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
for tag in "${tags[@]}"; do
|
||||||
<<<"$manifest" >/dev/null
|
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
|
done
|
||||||
docker buildx imagetools inspect "$image"
|
|
||||||
|
|
||||||
cleanup:
|
cleanup:
|
||||||
name: Clean up preview image
|
name: Clean up preview image
|
||||||
|
|||||||
@@ -27,37 +27,103 @@ jobs:
|
|||||||
- run: pnpm test:workshop
|
- run: pnpm test:workshop
|
||||||
- run: pnpm test:workshop-image
|
- 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')
|
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||||
needs: validate
|
needs: validate
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 30
|
timeout-minutes: 20
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
outputs:
|
||||||
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: docker/setup-qemu-action@v4
|
|
||||||
- uses: docker/setup-buildx-action@v4
|
- uses: docker/setup-buildx-action@v4
|
||||||
- uses: docker/login-action@v4
|
- uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- 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
|
- uses: docker/build-push-action@v7
|
||||||
|
id: build
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: apps/workshop/Dockerfile
|
file: apps/workshop/Dockerfile
|
||||||
target: production
|
target: production
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64
|
||||||
push: true
|
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
cache-from: type=gha,scope=workshop-linux-amd64
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
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"
|
||||||
@@ -44,12 +44,15 @@ host gateway address.
|
|||||||
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
|
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
|
||||||
add Homarr items as context, `/` for shortcuts, and the paperclip for supported files.
|
add Homarr items as context, `/` for shortcuts, and the paperclip for supported files.
|
||||||
|
|
||||||
|
Use **Conversations** to search, switch, rename, export, or delete threads.
|
||||||
|
|
||||||
Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small
|
Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small
|
||||||
summary of accessible Homarr resources. Live status is fetched only when needed.
|
summary of accessible Homarr resources. Live status is fetched only when needed.
|
||||||
|
|
||||||
## Permissions and privacy
|
## Permissions and privacy
|
||||||
|
|
||||||
- Read-only actions can run automatically. Changes require approval.
|
- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the
|
||||||
|
current conversation, turn off when you switch conversations, and never answer questions for you.
|
||||||
- Homarr permissions apply to every tool call and mentioned item.
|
- Homarr permissions apply to every tool call and mentioned item.
|
||||||
- API keys and custom headers are encrypted and never returned to the browser.
|
- API keys and custom headers are encrypted and never returned to the browser.
|
||||||
- Conversations are stored per user and can be exported or deleted.
|
- Conversations are stored per user and can be exported or deleted.
|
||||||
|
|||||||
@@ -204,10 +204,13 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
|
|||||||
style={{ maxWidth: "100%" }}
|
style={{ maxWidth: "100%" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
While edit mode is active, **Add board content** opens a menu for adding a widget, app, integration, or Container. The
|
||||||
Spotlight. It lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
available actions respect the current user's permissions. Widget setup offers the main canvas, enabled rails, and existing
|
||||||
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
|
Containers as destinations before the widget is created, so the common flow does not require a second move step.
|
||||||
destinations before the widget is created, so the common flow does not require a second move step.
|
|
||||||
|
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
|
||||||
|
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
|
||||||
|
selection. A move is applied only when every selected item fits in the destination.
|
||||||
|
|
||||||
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
|
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
|
||||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||||
@@ -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.
|
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
|
||||||
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
||||||
|
|
||||||
<img
|
<img src={keyboardTileEditingImage} alt="A focused board tile in keyboard editing mode" style={{ maxWidth: "100%" }} />
|
||||||
src={keyboardTileEditingImage}
|
|
||||||
alt="A focused board tile in keyboard editing mode"
|
|
||||||
style={{ maxWidth: "100%" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
#### Preview
|
#### Preview
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Administration
|
||||||
|
- Logs
|
||||||
|
- Troubleshooting
|
||||||
|
---
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
|
||||||
|
The logs page shows recent Homarr application logs and continues updating as new events occur. Use it to investigate
|
||||||
|
widget, integration, and server errors without opening the container logs separately.
|
||||||
|
|
||||||
|
## Opening the logs page
|
||||||
|
|
||||||
|
Navigate to **Management** > **Tools** > **Logs**. The page and widget log links are only available to users with the
|
||||||
|
**View logs** permission.
|
||||||
|
|
||||||
|
When a widget reports an error, **Check logs for more details** opens the logs page in a new tab and marks the time when
|
||||||
|
the widget displayed the error. The related server error normally appears immediately above that marker. If those logs
|
||||||
|
have already been removed from the retained history, the marker explains that they are no longer available.
|
||||||
|
|
||||||
|
## Retained and live logs
|
||||||
|
|
||||||
|
Homarr retains the latest 5,000 log events in Redis. Opening the page first loads this history and then continues with
|
||||||
|
live events without a gap. The history is bounded, so older events are removed automatically.
|
||||||
|
|
||||||
|
The history survives normal application restarts when the configured Redis instance persists its data. For complete
|
||||||
|
platform-level history, startup failures, or events older than the retained limit, use the logs provided by Docker or
|
||||||
|
your deployment platform.
|
||||||
|
|
||||||
|
## Display controls
|
||||||
|
|
||||||
|
The severity selector applies to both retained and live events. Selecting a different level redraws the existing view
|
||||||
|
using that threshold. The font-size control changes the terminal text size and is remembered in the browser.
|
||||||
@@ -13,7 +13,7 @@ tags:
|
|||||||
The settings page applies options to the whole Homarr application.
|
The settings page applies options to the whole Homarr application.
|
||||||
Some changes need a full page reload before they take effect.
|
Some changes need a full page reload before they take effect.
|
||||||
|
|
||||||
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
All settings sections remain visible in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||||
|
|
||||||
## Analytics
|
## Analytics
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import beszelSystemGrid from "./img/beszel-system-grid.png";
|
|||||||
|
|
||||||
<WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} />
|
<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.
|
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Today"
|
|
||||||
description: "Displays the current date and progress through the calendar."
|
|
||||||
hide_title: true
|
|
||||||
---
|
|
||||||
|
|
||||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
|
||||||
|
|
||||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
|
||||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
|
||||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
|
||||||
import { todayWidget } from ".";
|
|
||||||
|
|
||||||
<WidgetHeader widget={todayWidget} categories={["Date", "Time"]} />
|
|
||||||
|
|
||||||
The Today widget presents the current local date without duplicating a live clock. It progressively adds the week
|
|
||||||
number, day of year, quarter, remaining days, and calendar progress as more space becomes available.
|
|
||||||
|
|
||||||
The advanced view shows week, month, and year progress together with a twelve-month overview. Week numbers can follow
|
|
||||||
your locale or the ISO 8601 convention. “Days after today” excludes the current day to keep the remaining-day count
|
|
||||||
unambiguous.
|
|
||||||
|
|
||||||
The widget runs entirely in your browser and does not send date or location data to an external service. It follows
|
|
||||||
the timezone and locale of the device viewing the board, so viewers in different regions can see their own local date.
|
|
||||||
|
|
||||||
<WidgetInteractionGuide />
|
|
||||||
|
|
||||||
### Adding the widget
|
|
||||||
|
|
||||||
<AddingWidget />
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
<WidgetConfig configuration={todayWidget.configuration} />
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { WidgetDefinition } from "@site/src/types";
|
|
||||||
import { IconCalendarWeek } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
export const todayWidget: WidgetDefinition = {
|
|
||||||
icon: IconCalendarWeek,
|
|
||||||
name: "Today",
|
|
||||||
description: "Displays the current date and progress through the calendar.",
|
|
||||||
path: "../../widgets/today",
|
|
||||||
configuration: {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: "Week numbering",
|
|
||||||
description: "Use locale-specific week numbering or ISO 8601 week numbers",
|
|
||||||
values: { type: "select", options: ["Use locale rules", "ISO 8601"] },
|
|
||||||
defaultValue: "Use locale rules",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Show week number",
|
|
||||||
description: "Display the current week number when the widget has room",
|
|
||||||
values: { type: "boolean" },
|
|
||||||
defaultValue: "yes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Show day of year",
|
|
||||||
description: "Display the current day number within the year",
|
|
||||||
values: { type: "boolean" },
|
|
||||||
defaultValue: "yes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Show quarter",
|
|
||||||
description: "Display the current calendar quarter",
|
|
||||||
values: { type: "boolean" },
|
|
||||||
defaultValue: "yes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Show year progress",
|
|
||||||
description: "Display progress through the current year",
|
|
||||||
values: { type: "boolean" },
|
|
||||||
defaultValue: "yes",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
-1
@@ -2106,7 +2106,6 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"clock",
|
"clock",
|
||||||
"weather",
|
"weather",
|
||||||
"today",
|
|
||||||
"airQuality",
|
"airQuality",
|
||||||
"countdown",
|
"countdown",
|
||||||
"timer",
|
"timer",
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import {
|
|||||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
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 { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||||
|
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
|
||||||
|
import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
|
||||||
import classes from "./_client.module.css";
|
import classes from "./_client.module.css";
|
||||||
|
|
||||||
const APP_SHELL_INLINE_PADDING = 32;
|
const APP_SHELL_INLINE_PADDING = 32;
|
||||||
@@ -63,71 +65,73 @@ export const ClientBoard = () => {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<BoardAdvancedFocusProvider>
|
<BoardSelectionProvider>
|
||||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
<BoardAdvancedFocusProvider>
|
||||||
<BoardBackgroundVideo />
|
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||||
<BoardEmptyState />
|
<BoardBackgroundVideo />
|
||||||
<BoardSetupChecklist />
|
<BoardEmptyState />
|
||||||
<BoardSectionCollapseProvider>
|
<BoardSetupChecklist />
|
||||||
<BoardGridPortalHost>
|
<BoardSectionCollapseProvider>
|
||||||
<ScaledBoardCanvas
|
<BoardGridPortalHost>
|
||||||
logicalWidth={logicalWidth}
|
<ScaledBoardCanvas
|
||||||
initialLogicalHeight={initialLogicalHeight}
|
logicalWidth={logicalWidth}
|
||||||
initialAvailableWidth={initialAvailableWidth}
|
initialLogicalHeight={initialLogicalHeight}
|
||||||
label={board.name}
|
initialAvailableWidth={initialAvailableWidth}
|
||||||
>
|
label={board.name}
|
||||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
>
|
||||||
<BoardGridPortalRenderer />
|
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||||
{leftColumnCount > 0 && leftSection && (
|
{leftColumnCount > 0 && leftSection && (
|
||||||
<aside
|
<aside
|
||||||
className={`${classes.lane} ${classes.gutter}`}
|
className={`${classes.lane} ${classes.gutter}`}
|
||||||
aria-label={t("leftRail")}
|
aria-label={t("leftRail")}
|
||||||
data-board-gutter="left"
|
data-board-gutter="left"
|
||||||
data-board-editing={isEditMode ? "true" : undefined}
|
data-board-editing={isEditMode ? "true" : undefined}
|
||||||
>
|
>
|
||||||
<BoardEmptySection
|
<BoardEmptySection
|
||||||
key={`${currentLayoutId}-${leftSection.id}`}
|
key={`${currentLayoutId}-${leftSection.id}`}
|
||||||
section={leftSection}
|
section={leftSection}
|
||||||
columnCount={leftColumnCount}
|
columnCount={leftColumnCount}
|
||||||
requestedRowCount={0}
|
requestedRowCount={0}
|
||||||
railPlacement="left"
|
railPlacement="left"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className={classes.lane} aria-label={t("canvas")}>
|
<section className={classes.lane} aria-label={t("canvas")}>
|
||||||
<BoardEmptySection
|
|
||||||
key={`${currentLayoutId}-${mainSection.id}`}
|
|
||||||
section={mainSection}
|
|
||||||
columnCount={mainColumnCount}
|
|
||||||
requestedRowCount={0}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{rightColumnCount > 0 && rightSection && (
|
|
||||||
<aside
|
|
||||||
className={`${classes.lane} ${classes.gutter}`}
|
|
||||||
aria-label={t("rightRail")}
|
|
||||||
data-board-gutter="right"
|
|
||||||
data-board-editing={isEditMode ? "true" : undefined}
|
|
||||||
>
|
|
||||||
<BoardEmptySection
|
<BoardEmptySection
|
||||||
key={`${currentLayoutId}-${rightSection.id}`}
|
key={`${currentLayoutId}-${mainSection.id}`}
|
||||||
section={rightSection}
|
section={mainSection}
|
||||||
columnCount={rightColumnCount}
|
columnCount={mainColumnCount}
|
||||||
requestedRowCount={0}
|
requestedRowCount={0}
|
||||||
railPlacement="right"
|
|
||||||
/>
|
/>
|
||||||
</aside>
|
</section>
|
||||||
)}
|
|
||||||
</div>
|
{rightColumnCount > 0 && rightSection && (
|
||||||
</BoardGridEditorBoundary>
|
<aside
|
||||||
</ScaledBoardCanvas>
|
className={`${classes.lane} ${classes.gutter}`}
|
||||||
</BoardGridPortalHost>
|
aria-label={t("rightRail")}
|
||||||
</BoardSectionCollapseProvider>
|
data-board-gutter="right"
|
||||||
</Box>
|
data-board-editing={isEditMode ? "true" : undefined}
|
||||||
</BoardAdvancedFocusProvider>
|
>
|
||||||
|
<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;
|
if (representativeWidth === null) return content;
|
||||||
|
|||||||
@@ -2,14 +2,19 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { 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 { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { AppSelectModal } from "@homarr/modals-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
|
import { useItemActions } from "~/components/board/items/item-actions";
|
||||||
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
|
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";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
|
|
||||||
export default function BoardEditActions() {
|
export default function BoardEditActions() {
|
||||||
@@ -17,9 +22,13 @@ export default function BoardEditActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AddMenu = () => {
|
const AddMenu = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { openModal } = useModalAction(UniversalCreateModal);
|
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||||
|
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||||
|
const { addContainer } = useContainerActions();
|
||||||
|
const { createItem } = useItemActions();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -29,29 +38,69 @@ const AddMenu = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
||||||
handledInitialAdd.current = true;
|
handledInitialAdd.current = true;
|
||||||
addWidget();
|
openItemSelectModal({ boardId: board.id });
|
||||||
|
|
||||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
nextSearchParams.delete("add");
|
nextSearchParams.delete("add");
|
||||||
const query = nextSearchParams.toString();
|
const query = nextSearchParams.toString();
|
||||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||||
}, [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 (
|
return (
|
||||||
<HeaderButton
|
<Menu position="bottom-end">
|
||||||
aria-label={t("board.action.addContent")}
|
<Menu.Target>
|
||||||
onClick={() =>
|
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||||
openModal({
|
<Group gap={4} wrap="nowrap">
|
||||||
boardActions: {
|
<IconPlus stroke={1.5} />
|
||||||
widget: addWidget,
|
<IconChevronDown color="gray" size={16} />
|
||||||
app: addApp,
|
</Group>
|
||||||
integration: canConnectService ? connectService : undefined,
|
</HeaderButton>
|
||||||
container: addContainer,
|
</Menu.Target>
|
||||||
},
|
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||||
})
|
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||||
}
|
{t("item.action.create")}
|
||||||
>
|
</Menu.Item>
|
||||||
<IconPlus stroke={1.5} />
|
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||||
</HeaderButton>
|
{t("app.action.add")}
|
||||||
|
</Menu.Item>
|
||||||
|
{session?.user.permissions.includes("integration-create") && (
|
||||||
|
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||||
|
{t("integration.action.create")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
||||||
|
{t("section.container.action.create")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -29,6 +29,7 @@ export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) =>
|
|||||||
}))}
|
}))}
|
||||||
{...form.getInputProps("defaultColorScheme")}
|
{...form.getInputProps("defaultColorScheme")}
|
||||||
SelectOption={AppearanceCustomOption}
|
SelectOption={AppearanceCustomOption}
|
||||||
|
withinPortal
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Switch, Text } from "@mantine/core";
|
import { Switch, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -12,11 +12,11 @@ import type { FormValues } from "./settings-form";
|
|||||||
|
|
||||||
interface BoardSettingsFormProps {
|
interface BoardSettingsFormProps {
|
||||||
form: UseFormReturnType<FormValues>;
|
form: UseFormReturnType<FormValues>;
|
||||||
|
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormProps) => {
|
||||||
const tBoard = useScopedI18n("management.page.settings.section.board");
|
const tBoard = useScopedI18n("management.page.settings.section.board");
|
||||||
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={tBoard("title")}>
|
<SectionCard title={tBoard("title")}>
|
||||||
@@ -26,6 +26,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
|||||||
clearable
|
clearable
|
||||||
boards={selectableBoards}
|
boards={selectableBoards}
|
||||||
{...form.getInputProps("homeBoardId")}
|
{...form.getInputProps("homeBoardId")}
|
||||||
|
withinPortal
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BoardSelect
|
<BoardSelect
|
||||||
@@ -34,6 +35,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
|||||||
clearable
|
clearable
|
||||||
boards={selectableBoards}
|
boards={selectableBoards}
|
||||||
{...form.getInputProps("mobileHomeBoardId")}
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
|
withinPortal
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
|
|||||||
label={tCulture("defaultLocale.label")}
|
label={tCulture("defaultLocale.label")}
|
||||||
{...localeInputProps}
|
{...localeInputProps}
|
||||||
value={localeInputProps.value as SupportedLanguage}
|
value={localeInputProps.value as SupportedLanguage}
|
||||||
|
withinPortal
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Select } from "@mantine/core";
|
import { Select } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -11,11 +11,11 @@ import type { FormValues } from "./settings-form";
|
|||||||
|
|
||||||
interface SearchSettingsFormProps {
|
interface SearchSettingsFormProps {
|
||||||
form: UseFormReturnType<FormValues>;
|
form: UseFormReturnType<FormValues>;
|
||||||
|
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
|
export const SearchSettingsForm = ({ form, selectableSearchEngines }: SearchSettingsFormProps) => {
|
||||||
const tSearch = useScopedI18n("management.page.settings.section.search");
|
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={tSearch("title")}>
|
<SectionCard title={tSearch("title")}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Button, Stack } from "@mantine/core";
|
import { Button, Stack } from "@mantine/core";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
@@ -58,9 +59,11 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
|||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
initialData: ServerSettings;
|
initialData: ServerSettings;
|
||||||
|
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
|
||||||
|
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tSettings = useScopedI18n("management.page.settings");
|
const tSettings = useScopedI18n("management.page.settings");
|
||||||
|
|
||||||
@@ -169,13 +172,13 @@ export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<AnalyticsSettings form={form} />
|
<BoardSettingsForm form={form} selectableBoards={selectableBoards} />
|
||||||
<CrawlingAndIndexingSettings form={form} />
|
|
||||||
<BoardSettingsForm form={form} />
|
|
||||||
<UserSettingsForm form={form} />
|
<UserSettingsForm form={form} />
|
||||||
<SearchSettingsForm form={form} />
|
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
|
||||||
<AppearanceSettingsForm form={form} />
|
<AppearanceSettingsForm form={form} />
|
||||||
<CultureSettingsForm form={form} />
|
<CultureSettingsForm form={form} />
|
||||||
|
<AnalyticsSettings form={form} />
|
||||||
|
<CrawlingAndIndexingSettings form={form} />
|
||||||
|
|
||||||
{form.isDirty() && (
|
{form.isDirty() && (
|
||||||
<UnsavedChangesBar>
|
<UnsavedChangesBar>
|
||||||
|
|||||||
@@ -23,12 +23,20 @@ export default async function SettingsPage() {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverSettings = await api.serverSettings.getAll();
|
const [serverSettings, selectableBoards, selectableSearchEngines, tSettings] = await Promise.all([
|
||||||
const tSettings = await getScopedI18n("management.page.settings");
|
api.serverSettings.getAll(),
|
||||||
|
api.board.getPublicBoards(),
|
||||||
|
api.searchEngine.getSelectable({ withIntegrations: false }),
|
||||||
|
getScopedI18n("management.page.settings"),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout title={tSettings("title")}>
|
<ManagePageLayout title={tSettings("title")}>
|
||||||
<SettingsForm initialData={serverSettings} />
|
<SettingsForm
|
||||||
|
initialData={serverSettings}
|
||||||
|
selectableBoards={selectableBoards}
|
||||||
|
selectableSearchEngines={selectableSearchEngines}
|
||||||
|
/>
|
||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
export const ClientSideTerminalComponent = dynamic(
|
interface ClientSideTerminalComponentProps {
|
||||||
|
focusTimestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClientSideTerminalComponent = dynamic<ClientSideTerminalComponentProps>(
|
||||||
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
|
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|||||||
@@ -29,12 +29,25 @@ export async function generateMetadata() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function LogsManagementPage() {
|
interface LogsManagementPageProps {
|
||||||
|
searchParams: Promise<{ focus?: string | string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LogsManagementPage({ searchParams }: LogsManagementPageProps) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user.permissions.includes("other-view-logs")) {
|
if (!session?.user.permissions.includes("other-view-logs")) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const focus = (await searchParams).focus;
|
||||||
|
let focusTimestamp: number | undefined;
|
||||||
|
if (typeof focus === "string") {
|
||||||
|
const parsedFocusTimestamp = Number(focus);
|
||||||
|
if (Number.isSafeInteger(parsedFocusTimestamp) && parsedFocusTimestamp > 0) {
|
||||||
|
focusTimestamp = parsedFocusTimestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
@@ -45,7 +58,7 @@ export default async function LogsManagementPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||||
<ClientSideTerminalComponent />
|
<ClientSideTerminalComponent focusTimestamp={focusTimestamp} />
|
||||||
</Box>
|
</Box>
|
||||||
</LogContextProvider>
|
</LogContextProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,25 +7,163 @@ import { FitAddon } from "@xterm/addon-fit";
|
|||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { LoggerMessage, LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||||
|
import { LOG_HISTORY_MAX_ENTRIES, logLevels } from "@homarr/core/infrastructure/logs/constants";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useLogContext } from "./log-context";
|
import { useLogContext } from "./log-context";
|
||||||
import classes from "./terminal.module.css";
|
import classes from "./terminal.module.css";
|
||||||
|
|
||||||
export const TerminalComponent = () => {
|
const ALL_LOG_LEVELS = [...logLevels];
|
||||||
|
const TERMINAL_SCROLLBACK_LINES = LOG_HISTORY_MAX_ENTRIES * 20;
|
||||||
|
|
||||||
|
const getFocusMarker = (
|
||||||
|
messages: LoggerMessage[],
|
||||||
|
visibleMessages: LoggerMessage[],
|
||||||
|
focusTimestamp: number | undefined,
|
||||||
|
markerText: string,
|
||||||
|
expiredMarkerText: string,
|
||||||
|
) => {
|
||||||
|
if (focusTimestamp === undefined) return null;
|
||||||
|
|
||||||
|
const oldestTimestamp = messages[0]?.timestamp.getTime();
|
||||||
|
if (oldestTimestamp === undefined || focusTimestamp < oldestTimestamp) {
|
||||||
|
return { index: 0, text: expiredMarkerText };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: visibleMessages.findLastIndex((message) => message.timestamp.getTime() <= focusTimestamp) + 1,
|
||||||
|
text: markerText,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findMarkerLine = (terminal: Terminal, linesFromEnd: number) => {
|
||||||
|
const buffer = terminal.buffer.active;
|
||||||
|
const logicalLineStarts: number[] = [];
|
||||||
|
const cursorLine = buffer.baseY + buffer.cursorY;
|
||||||
|
|
||||||
|
for (let index = 0; index <= cursorLine; index++) {
|
||||||
|
const line = buffer.getLine(index);
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
if (!line.isWrapped) {
|
||||||
|
logicalLineStarts.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return logicalLineStarts.at(-linesFromEnd) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMessages = ({
|
||||||
|
terminal,
|
||||||
|
messages,
|
||||||
|
activeLevels,
|
||||||
|
focusTimestamp,
|
||||||
|
markerText,
|
||||||
|
expiredMarkerText,
|
||||||
|
}: {
|
||||||
|
terminal: Terminal;
|
||||||
|
messages: LoggerMessage[];
|
||||||
|
activeLevels: LogLevel[];
|
||||||
|
focusTimestamp: number | undefined;
|
||||||
|
markerText: string;
|
||||||
|
expiredMarkerText: string;
|
||||||
|
}) => {
|
||||||
|
const activeLevelSet = new Set(activeLevels);
|
||||||
|
const visibleMessages = messages.filter((message) => activeLevelSet.has(message.level));
|
||||||
|
const focusMarker = getFocusMarker(messages, visibleMessages, focusTimestamp, markerText, expiredMarkerText);
|
||||||
|
const output: string[] = [];
|
||||||
|
let markerOutputIndex: number | null = null;
|
||||||
|
|
||||||
|
visibleMessages.forEach((message, index) => {
|
||||||
|
if (focusMarker?.index === index) {
|
||||||
|
markerOutputIndex = output.length;
|
||||||
|
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
|
||||||
|
}
|
||||||
|
output.push(message.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (focusMarker?.index === visibleMessages.length) {
|
||||||
|
markerOutputIndex = output.length;
|
||||||
|
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let markerLinesFromEnd: number | null = null;
|
||||||
|
if (markerOutputIndex !== null) {
|
||||||
|
markerLinesFromEnd =
|
||||||
|
1 + output.slice(markerOutputIndex).reduce((lineCount, line) => lineCount + line.split(/\r?\n/).length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.write(`\x1bc${output.join("\r\n")}${output.length > 0 ? "\r\n" : ""}`, () => {
|
||||||
|
if (markerLinesFromEnd === null) {
|
||||||
|
terminal.scrollToBottom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerLine = findMarkerLine(terminal, markerLinesFromEnd);
|
||||||
|
if (markerLine !== null) {
|
||||||
|
terminal.scrollToLine(markerLine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TerminalComponentProps {
|
||||||
|
focusTimestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const { activeLevels, fontSize } = useLogContext();
|
const { activeLevels, fontSize } = useLogContext();
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
const terminalRef = useRef<Terminal>(null);
|
const terminalRef = useRef<Terminal>(null);
|
||||||
const fitAddonRef = useRef<FitAddon>(null);
|
const fitAddonRef = useRef<FitAddon>(null);
|
||||||
|
const messagesRef = useRef<LoggerMessage[]>([]);
|
||||||
|
const initialFontSizeRef = useRef(fontSize);
|
||||||
|
const activeLevelsRef = useRef(activeLevels);
|
||||||
|
const markerTextRef = useRef("");
|
||||||
|
const expiredMarkerTextRef = useRef("");
|
||||||
|
const focusTimestampRef = useRef(focusTimestamp);
|
||||||
|
|
||||||
|
activeLevelsRef.current = activeLevels;
|
||||||
|
markerTextRef.current = t("log.context.widgetError");
|
||||||
|
expiredMarkerTextRef.current = t("log.context.expiredWidgetError");
|
||||||
|
focusTimestampRef.current = focusTimestamp;
|
||||||
|
|
||||||
|
const redrawTerminal = () => {
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (!terminal) return;
|
||||||
|
|
||||||
|
renderMessages({
|
||||||
|
terminal,
|
||||||
|
messages: messagesRef.current,
|
||||||
|
activeLevels: activeLevelsRef.current,
|
||||||
|
focusTimestamp: focusTimestampRef.current,
|
||||||
|
markerText: markerTextRef.current,
|
||||||
|
expiredMarkerText: expiredMarkerTextRef.current,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
clientApi.log.subscribe.useSubscription(
|
clientApi.log.subscribe.useSubscription(
|
||||||
{
|
{
|
||||||
levels: activeLevels,
|
levels: ALL_LOG_LEVELS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onData(data) {
|
onData(event) {
|
||||||
terminalRef.current?.writeln(data.message);
|
if (event.type === "history") {
|
||||||
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
messagesRef.current = event.messages.slice(-LOG_HISTORY_MAX_ENTRIES);
|
||||||
|
redrawTerminal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesRef.current.push(event.message);
|
||||||
|
if (messagesRef.current.length > LOG_HISTORY_MAX_ENTRIES) {
|
||||||
|
messagesRef.current.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeLevelsRef.current.includes(event.message.level)) {
|
||||||
|
terminalRef.current?.writeln(event.message.message);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
alert(err);
|
alert(err);
|
||||||
@@ -44,22 +182,26 @@ export const TerminalComponent = () => {
|
|||||||
cursorBlink: false,
|
cursorBlink: false,
|
||||||
disableStdin: true,
|
disableStdin: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
fontSize,
|
fontSize: initialFontSizeRef.current,
|
||||||
|
scrollback: TERMINAL_SCROLLBACK_LINES,
|
||||||
});
|
});
|
||||||
terminalRef.current.open(ref.current);
|
terminalRef.current.open(ref.current);
|
||||||
terminalRef.current.loadAddon(canvasAddon);
|
terminalRef.current.loadAddon(canvasAddon);
|
||||||
|
|
||||||
setTimeout(() => {
|
const fitTimeout = window.setTimeout(() => {
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
fitAddonRef.current = fitAddon;
|
fitAddonRef.current = fitAddon;
|
||||||
terminalRef.current?.loadAddon(fitAddon);
|
terminalRef.current?.loadAddon(fitAddon);
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
|
redrawTerminal();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.clearTimeout(fitTimeout);
|
||||||
canvasAddon.dispose();
|
canvasAddon.dispose();
|
||||||
terminalRef.current?.dispose();
|
terminalRef.current?.dispose();
|
||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
|
fitAddonRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -69,5 +211,9 @@ export const TerminalComponent = () => {
|
|||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
}, [fontSize]);
|
}, [fontSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
redrawTerminal();
|
||||||
|
}, [activeLevels, focusTimestamp]);
|
||||||
|
|
||||||
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
||||||
};
|
};
|
||||||
@@ -104,6 +104,46 @@ describe("assistant automatic approval tracker", () => {
|
|||||||
await act(async () => root.unmount());
|
await act(async () => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not approve a ready action while switching conversations", async () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
containers.push(container);
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
let confirmationCount = 0;
|
||||||
|
const renderConversation = async (conversationId: string, ready: boolean) => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
createElement(
|
||||||
|
AssistantAutoApprovalProvider,
|
||||||
|
{ conversationId },
|
||||||
|
createElement(
|
||||||
|
Fragment,
|
||||||
|
null,
|
||||||
|
createElement(AutoApprovalProbe),
|
||||||
|
createElement(AutomaticActionProbe, {
|
||||||
|
ready,
|
||||||
|
completed: false,
|
||||||
|
confirm: () => {
|
||||||
|
confirmationCount += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderConversation("local-thread-1", false);
|
||||||
|
act(() => currentAutoApproval?.setEnabled(true));
|
||||||
|
await renderConversation("local-thread-2", true);
|
||||||
|
|
||||||
|
expect(confirmationCount).toBe(0);
|
||||||
|
expect(currentAutoApproval?.enabled).toBe(false);
|
||||||
|
expect(automaticActionInProgress).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
test("clears claimed calls when automatic approvals are turned off", async () => {
|
test("clears claimed calls when automatic approvals are turned off", async () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
containers.push(container);
|
containers.push(container);
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export const AssistantAutoApprovalProvider = ({
|
|||||||
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
|
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
|
||||||
const retryTimersRef = useRef(new Map<string, number>());
|
const retryTimersRef = useRef(new Map<string, number>());
|
||||||
const previousConversationIdRef = useRef(conversationId);
|
const previousConversationIdRef = useRef(conversationId);
|
||||||
|
const conversationMatches = previousConversationIdRef.current === conversationId;
|
||||||
|
const effectiveEnabled = conversationMatches && enabled;
|
||||||
|
|
||||||
const clearRetryTimer = useCallback((toolCallId: string) => {
|
const clearRetryTimer = useCallback((toolCallId: string) => {
|
||||||
const timer = retryTimersRef.current.get(toolCallId);
|
const timer = retryTimersRef.current.get(toolCallId);
|
||||||
@@ -86,7 +88,7 @@ export const AssistantAutoApprovalProvider = ({
|
|||||||
|
|
||||||
const requestAction = useCallback(
|
const requestAction = useCallback(
|
||||||
(toolCallId: string, confirm: () => void) => {
|
(toolCallId: string, confirm: () => void) => {
|
||||||
if (!enabled || !trackerRef.current.claim(toolCallId)) return false;
|
if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
confirm();
|
confirm();
|
||||||
@@ -105,7 +107,7 @@ export const AssistantAutoApprovalProvider = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearRetryTimer, enabled],
|
[clearRetryTimer, effectiveEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const completeAction = useCallback(
|
const completeAction = useCallback(
|
||||||
@@ -116,10 +118,15 @@ export const AssistantAutoApprovalProvider = ({
|
|||||||
[clearRetryTimer],
|
[clearRetryTimer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(() => {
|
||||||
() => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }),
|
return {
|
||||||
[completeAction, enabled, requestAction, retryRevision, setEnabled],
|
enabled: effectiveEnabled,
|
||||||
);
|
setEnabled,
|
||||||
|
requestAction,
|
||||||
|
completeAction,
|
||||||
|
retryRevision,
|
||||||
|
};
|
||||||
|
}, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]);
|
||||||
|
|
||||||
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
|
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ export interface AssistantContextValue {
|
|||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
|
hasVisibleWidget: boolean;
|
||||||
|
activeWidgetId: string | null;
|
||||||
open: () => void;
|
open: () => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
sendPrompt: (prompt: string) => boolean;
|
sendPrompt: (prompt: string) => boolean;
|
||||||
refreshCurrentView: () => Promise<void>;
|
refreshCurrentView: () => Promise<void>;
|
||||||
|
setWidgetVisible: (widgetId: string, visible: boolean) => void;
|
||||||
|
activateWidget: (widgetId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AssistantContext = createContext<AssistantContextValue | null>(null);
|
export const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ const DisabledAssistant = ({ children, description }: PropsWithChildren<{ descri
|
|||||||
isRunning: false,
|
isRunning: false,
|
||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
|
hasVisibleWidget: false,
|
||||||
|
activeWidgetId: null,
|
||||||
open: () => undefined,
|
open: () => undefined,
|
||||||
close: () => undefined,
|
close: () => undefined,
|
||||||
toggle: () => undefined,
|
toggle: () => undefined,
|
||||||
sendPrompt: () => false,
|
sendPrompt: () => false,
|
||||||
refreshCurrentView: () => Promise.resolve(),
|
refreshCurrentView: () => Promise.resolve(),
|
||||||
|
setWidgetVisible: () => undefined,
|
||||||
|
activateWidget: () => undefined,
|
||||||
}),
|
}),
|
||||||
[description],
|
[description],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { AppForm } from "@homarr/forms-collection";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { appManageSchema } from "@homarr/validation/app";
|
import type { appManageSchema } from "@homarr/validation/app";
|
||||||
|
|
||||||
import { getAssistantAffirmativeResult, getAssistantAskUserOptionKind } from "./assistant-ask-user";
|
import { getAssistantAskUserOptionKind } from "./assistant-ask-user";
|
||||||
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
|
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
|
||||||
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
||||||
import classes from "./assistant-panel.module.css";
|
import classes from "./assistant-panel.module.css";
|
||||||
@@ -67,7 +67,6 @@ export const AssistantAskUserTool = ({
|
|||||||
result,
|
result,
|
||||||
addResult,
|
addResult,
|
||||||
status,
|
status,
|
||||||
toolCallId,
|
|
||||||
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
|
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
|
||||||
const t = useScopedI18n("common.assistant.askUser");
|
const t = useScopedI18n("common.assistant.askUser");
|
||||||
const [showOther, setShowOther] = useState(false);
|
const [showOther, setShowOther] = useState(false);
|
||||||
@@ -75,17 +74,6 @@ export const AssistantAskUserTool = ({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||||
const options = Array.isArray(args?.options) ? args.options : [];
|
const options = Array.isArray(args?.options) ? args.options : [];
|
||||||
const affirmativeResult = getAssistantAffirmativeResult(options);
|
|
||||||
const hasValidQuestion = hasCompleteAssistantToolArguments(status) && Boolean(args?.question) && options.length >= 2;
|
|
||||||
const autoConfirming = useAssistantAutomaticAction({
|
|
||||||
toolCallId,
|
|
||||||
ready: result === undefined && hasValidQuestion && affirmativeResult !== undefined,
|
|
||||||
completed: result !== undefined,
|
|
||||||
confirm: () => {
|
|
||||||
if (!affirmativeResult) return;
|
|
||||||
addResult(affirmativeResult);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showOther) otherInputRef.current?.focus();
|
if (showOther) otherInputRef.current?.focus();
|
||||||
@@ -165,67 +153,62 @@ export const AssistantAskUserTool = ({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap="xs" mt="md">
|
<Stack gap="xs" mt="md">
|
||||||
{autoConfirming ? (
|
{options.map((option, index) => {
|
||||||
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
|
const kind = getAssistantAskUserOptionKind(option);
|
||||||
) : (
|
const icon =
|
||||||
options.map((option, index) => {
|
kind === "affirmative" ? (
|
||||||
const kind = getAssistantAskUserOptionKind(option);
|
<IconCheck size={17} />
|
||||||
const icon =
|
) : kind === "negative" ? (
|
||||||
kind === "affirmative" ? (
|
<IconX size={17} />
|
||||||
<IconCheck size={17} />
|
) : (
|
||||||
) : kind === "negative" ? (
|
<IconArrowRight size={17} />
|
||||||
<IconX size={17} />
|
|
||||||
) : (
|
|
||||||
<IconArrowRight size={17} />
|
|
||||||
);
|
|
||||||
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnstyledButton
|
|
||||||
key={`${option.id}:${index}`}
|
|
||||||
className={classes.humanToolOption}
|
|
||||||
data-kind={kind}
|
|
||||||
disabled={submitting}
|
|
||||||
onClick={() =>
|
|
||||||
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
|
|
||||||
{icon}
|
|
||||||
</ThemeIcon>
|
|
||||||
<Box className={classes.humanToolOptionText} ta="start">
|
|
||||||
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
|
|
||||||
{option.label}
|
|
||||||
</Text>
|
|
||||||
{option.description && (
|
|
||||||
<Text
|
|
||||||
component="span"
|
|
||||||
display="block"
|
|
||||||
size="xs"
|
|
||||||
c="dimmed"
|
|
||||||
fw={400}
|
|
||||||
className={classes.humanToolOptionDescription}
|
|
||||||
>
|
|
||||||
{option.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Badge
|
|
||||||
className={classes.humanToolOptionBadge}
|
|
||||||
classNames={{ label: classes.humanToolOptionBadgeLabel }}
|
|
||||||
variant="light"
|
|
||||||
color={color}
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{t(`optionKind.${kind}`)}
|
|
||||||
</Badge>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
);
|
||||||
})
|
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||||
)}
|
|
||||||
|
|
||||||
{!autoConfirming &&
|
return (
|
||||||
args.allowOther !== false &&
|
<UnstyledButton
|
||||||
|
key={`${option.id}:${index}`}
|
||||||
|
className={classes.humanToolOption}
|
||||||
|
data-kind={kind}
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() =>
|
||||||
|
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
|
||||||
|
{icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box className={classes.humanToolOptionText} ta="start">
|
||||||
|
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
{option.description && (
|
||||||
|
<Text
|
||||||
|
component="span"
|
||||||
|
display="block"
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
fw={400}
|
||||||
|
className={classes.humanToolOptionDescription}
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Badge
|
||||||
|
className={classes.humanToolOptionBadge}
|
||||||
|
classNames={{ label: classes.humanToolOptionBadgeLabel }}
|
||||||
|
variant="light"
|
||||||
|
color={color}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{t(`optionKind.${kind}`)}
|
||||||
|
</Badge>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{args.allowOther !== false &&
|
||||||
(showOther ? (
|
(showOther ? (
|
||||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -98,18 +98,18 @@
|
|||||||
container-type: size;
|
container-type: size;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 290;
|
z-index: 290;
|
||||||
inset-inline-start: 50%;
|
inset-block: 1rem;
|
||||||
bottom: 1rem;
|
inset-inline-end: 1rem;
|
||||||
|
inset-inline-start: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: min(46rem, calc(100vw - 2rem));
|
width: min(34rem, calc(100vw - 2rem));
|
||||||
height: min(36rem, calc(100dvh - 5rem));
|
height: auto;
|
||||||
min-height: min(22rem, calc(100dvh - 5rem));
|
min-height: min(30rem, calc(100dvh - 2rem));
|
||||||
margin: 0;
|
margin: 0;
|
||||||
transform: translateX(-50%);
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
border-radius: var(--mantine-radius-lg);
|
border-radius: var(--mantine-radius-lg);
|
||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-text);
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
@@ -157,27 +157,28 @@
|
|||||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panelIdentity {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelIdentityIcon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelIdentityTitle,
|
||||||
|
.panelThreadTitle {
|
||||||
|
max-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
.panelActions {
|
.panelActions {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.windowControls {
|
.newConversationButton {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.windowControl {
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.windowControl:focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelAction {
|
.panelAction {
|
||||||
@@ -191,10 +192,6 @@
|
|||||||
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composerHints {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.historyButton {
|
.historyButton {
|
||||||
width: 2.625rem;
|
width: 2.625rem;
|
||||||
max-width: 2.625rem;
|
max-width: 2.625rem;
|
||||||
@@ -210,6 +207,21 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.newConversationButton {
|
||||||
|
width: 2.625rem;
|
||||||
|
max-width: 2.625rem;
|
||||||
|
height: 2.625rem;
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newConversationButton :global(.mantine-Button-section) {
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newConversationButton :global(.mantine-Button-label) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.messages {
|
.messages {
|
||||||
padding: 0.5rem 0.65rem 1rem;
|
padding: 0.5rem 0.65rem 1rem;
|
||||||
}
|
}
|
||||||
@@ -246,9 +258,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@container assistant-widget (max-width: 36rem) {
|
||||||
|
.historyButton {
|
||||||
|
width: 2.625rem;
|
||||||
|
max-width: 2.625rem;
|
||||||
|
height: 2.625rem;
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyButtonSection {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyButtonLabel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@container assistant-widget (max-width: 22rem) {
|
@container assistant-widget (max-width: 22rem) {
|
||||||
.panelAction,
|
.panelAction,
|
||||||
.historyButton {
|
.historyButton,
|
||||||
|
.newConversationButton {
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
max-width: 2.25rem;
|
max-width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
@@ -268,7 +298,11 @@
|
|||||||
padding-inline: 0.15rem;
|
padding-inline: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composerContext > :last-child {
|
.composerContextLabel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelThreadTitle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,17 +332,70 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.historyMenu {
|
.historyMenu {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historyMenu :global(.mantine-ScrollArea-content) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.historyItem {
|
.historyItem {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.15rem 0.2rem 0.15rem 0.35rem;
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.historyItem:hover {
|
.historyItem:hover,
|
||||||
|
.historyItem[data-active] {
|
||||||
background: var(--mantine-color-default-hover);
|
background: var(--mantine-color-default-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historyItemTrigger {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.35rem 0.25rem;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyItemTrigger > span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyRenameInput {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyItemTrigger:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyItemMenu {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyItemActions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.thread {
|
.thread {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -341,6 +428,11 @@
|
|||||||
margin-block: 0.9rem;
|
margin-block: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message[data-static] {
|
||||||
|
contain-intrinsic-size: auto 12rem;
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.userMessageWrap {
|
.userMessageWrap {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 86%;
|
max-width: 86%;
|
||||||
@@ -706,24 +798,21 @@
|
|||||||
|
|
||||||
.pendingQuestionDock {
|
.pendingQuestionDock {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
max-height: min(22rem, 48dvh);
|
||||||
|
flex: 0 1 auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--mantine-color-default-border);
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread[data-pending-question] .viewport {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pendingQuestionTarget {
|
.pendingQuestionTarget {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100%;
|
|
||||||
max-width: 44rem;
|
max-width: 44rem;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
align-items: safe center;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pendingQuestionTarget .humanTool {
|
.pendingQuestionTarget .humanTool {
|
||||||
@@ -1091,6 +1180,43 @@
|
|||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.messageMoreMenu {
|
||||||
|
z-index: 410;
|
||||||
|
min-width: 11rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
color: var(--mantine-color-text);
|
||||||
|
background: var(--mantine-color-body);
|
||||||
|
box-shadow: var(--mantine-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageMoreItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageMoreItem[data-highlighted] {
|
||||||
|
background: var(--mantine-color-default-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageMoreItem:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageMoreSeparator {
|
||||||
|
height: 1px;
|
||||||
|
margin-block: 0.2rem;
|
||||||
|
background: var(--mantine-color-default-border);
|
||||||
|
}
|
||||||
|
|
||||||
.userActions {
|
.userActions {
|
||||||
min-height: 1.75rem;
|
min-height: 1.75rem;
|
||||||
}
|
}
|
||||||
@@ -1173,13 +1299,13 @@
|
|||||||
|
|
||||||
.composerContext {
|
.composerContext {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 1.75rem;
|
width: auto;
|
||||||
height: 1.75rem;
|
height: 1.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0;
|
padding-inline: 0.3rem 0.42rem;
|
||||||
border: 1px solid var(--mantine-color-default-border);
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
border-radius: 50%;
|
border-radius: var(--mantine-radius-sm);
|
||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-text);
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
@@ -1415,10 +1541,6 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composerHints {
|
|
||||||
padding-inline: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composerFooter {
|
.composerFooter {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -1641,12 +1763,12 @@
|
|||||||
@keyframes assistant-panel-in {
|
@keyframes assistant-panel-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, 0.75rem) scale(0.985);
|
transform: translateY(0.75rem) scale(0.985);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, 0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,10 +1778,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.floatingPanel {
|
.floatingPanel {
|
||||||
bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
|
inset: 0;
|
||||||
width: calc(100vw - 1rem);
|
width: 100vw;
|
||||||
height: min(44rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
|
height: 100dvh;
|
||||||
min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
|
min-height: 100dvh;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelHeader {
|
.panelHeader {
|
||||||
@@ -1686,10 +1809,6 @@
|
|||||||
padding-inline: 0.1rem;
|
padding-inline: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composerHints {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtimeSelectorTrigger {
|
.runtimeSelectorTrigger {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -1705,6 +1824,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 30em) {
|
@media (max-width: 30em) {
|
||||||
|
.historyItemMenu {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.historyButton {
|
.historyButton {
|
||||||
width: 2.625rem;
|
width: 2.625rem;
|
||||||
max-width: 2.625rem;
|
max-width: 2.625rem;
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import type {
|
|||||||
import type { MessageStatus } from "@assistant-ui/react";
|
import type { MessageStatus } from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
ActionBarPrimitive,
|
ActionBarPrimitive,
|
||||||
|
ActionBarMorePrimitive,
|
||||||
|
AuiIf,
|
||||||
AttachmentPrimitive,
|
AttachmentPrimitive,
|
||||||
BranchPickerPrimitive,
|
BranchPickerPrimitive,
|
||||||
ChainOfThoughtByIndicesProvider,
|
ChainOfThoughtByIndicesProvider,
|
||||||
@@ -66,6 +68,7 @@ import {
|
|||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
@@ -91,6 +94,7 @@ import {
|
|||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
|
IconDotsVertical,
|
||||||
IconFile,
|
IconFile,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
IconLink,
|
IconLink,
|
||||||
@@ -112,6 +116,8 @@ import {
|
|||||||
IconThumbUp,
|
IconThumbUp,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
|
IconVolume,
|
||||||
|
IconVolumeOff,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import remarkBreaks from "remark-breaks";
|
import remarkBreaks from "remark-breaks";
|
||||||
@@ -124,6 +130,7 @@ import { useTimeAgo } from "@homarr/common";
|
|||||||
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
||||||
import type { AssistantProvider } from "@homarr/definitions";
|
import type { AssistantProvider } from "@homarr/definitions";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import classes from "./assistant-panel.module.css";
|
import classes from "./assistant-panel.module.css";
|
||||||
@@ -184,6 +191,7 @@ interface AssistantPanelProps extends AssistantConversationControls {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDismissActivity: () => void;
|
onDismissActivity: () => void;
|
||||||
activityDismissed: boolean;
|
activityDismissed: boolean;
|
||||||
|
hasVisibleWidget: boolean;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
latestAssistantText: string;
|
latestAssistantText: string;
|
||||||
@@ -1107,63 +1115,43 @@ const downloadAssistantMarkdown = (markdown: string, filename: string) => {
|
|||||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssistantMessageExportAction = () => {
|
|
||||||
const t = useScopedI18n("common.assistant");
|
|
||||||
const message = useAuiState((state) => state.message);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={t("exportMarkdown")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
aria-label={t("exportMarkdown")}
|
|
||||||
onClick={() => {
|
|
||||||
const markdown = buildAssistantMessageMarkdown({
|
|
||||||
id: message.id,
|
|
||||||
parentId: message.parentId,
|
|
||||||
format: "assistant-ui/runtime",
|
|
||||||
content: message,
|
|
||||||
createdAt: message.createdAt,
|
|
||||||
});
|
|
||||||
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconFileExport size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AssistantMessageActions = () => {
|
const AssistantMessageActions = () => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
|
const aui = useAui();
|
||||||
|
const exportMessage = () => {
|
||||||
|
const message = aui.message().getState();
|
||||||
|
const markdown = buildAssistantMessageMarkdown({
|
||||||
|
id: message.id,
|
||||||
|
parentId: message.parentId,
|
||||||
|
format: "assistant-ui/runtime",
|
||||||
|
content: message,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
});
|
||||||
|
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classes.messageActions}>
|
<Box className={classes.messageActions}>
|
||||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.messageActionBar}>
|
<ActionBarPrimitive.Root
|
||||||
<Group gap={2} wrap="wrap">
|
hideWhenRunning
|
||||||
|
autohide="not-last"
|
||||||
|
autohideFloat="single-branch"
|
||||||
|
className={classes.messageActionBar}
|
||||||
|
>
|
||||||
|
<Group gap={2} wrap="nowrap">
|
||||||
<ProviderMessageInfo />
|
<ProviderMessageInfo />
|
||||||
<Tooltip label={t("copy")}>
|
<Tooltip label={t("copy")}>
|
||||||
<ActionBarPrimitive.Copy asChild>
|
<ActionBarPrimitive.Copy asChild>
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
|
||||||
<IconCopy size={14} />
|
<AuiIf condition={(state) => !state.message.isCopied}>
|
||||||
|
<IconCopy size={14} />
|
||||||
|
</AuiIf>
|
||||||
|
<AuiIf condition={(state) => state.message.isCopied}>
|
||||||
|
<IconCheck size={14} />
|
||||||
|
</AuiIf>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</ActionBarPrimitive.Copy>
|
</ActionBarPrimitive.Copy>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={t("helpful")}>
|
|
||||||
<ActionBarPrimitive.FeedbackPositive asChild>
|
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("helpful")}>
|
|
||||||
<IconThumbUp size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</ActionBarPrimitive.FeedbackPositive>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("notHelpful")}>
|
|
||||||
<ActionBarPrimitive.FeedbackNegative asChild>
|
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("notHelpful")}>
|
|
||||||
<IconThumbDown size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</ActionBarPrimitive.FeedbackNegative>
|
|
||||||
</Tooltip>
|
|
||||||
<AssistantMessageExportAction />
|
|
||||||
<Tooltip label={t("regenerate")}>
|
<Tooltip label={t("regenerate")}>
|
||||||
<ActionBarPrimitive.Reload asChild>
|
<ActionBarPrimitive.Reload asChild>
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
|
||||||
@@ -1171,6 +1159,50 @@ const AssistantMessageActions = () => {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</ActionBarPrimitive.Reload>
|
</ActionBarPrimitive.Reload>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<ActionBarMorePrimitive.Root>
|
||||||
|
<ActionBarMorePrimitive.Trigger asChild>
|
||||||
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("moreActions")}>
|
||||||
|
<IconDotsVertical size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</ActionBarMorePrimitive.Trigger>
|
||||||
|
<ActionBarMorePrimitive.Content className={classes.messageMoreMenu} sideOffset={4} align="end">
|
||||||
|
<ActionBarPrimitive.FeedbackPositive asChild>
|
||||||
|
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||||
|
<IconThumbUp size={15} />
|
||||||
|
{t("helpful")}
|
||||||
|
</ActionBarMorePrimitive.Item>
|
||||||
|
</ActionBarPrimitive.FeedbackPositive>
|
||||||
|
<ActionBarPrimitive.FeedbackNegative asChild>
|
||||||
|
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||||
|
<IconThumbDown size={15} />
|
||||||
|
{t("notHelpful")}
|
||||||
|
</ActionBarMorePrimitive.Item>
|
||||||
|
</ActionBarPrimitive.FeedbackNegative>
|
||||||
|
<AuiIf condition={(state) => state.thread.capabilities.speech && state.message.speech === undefined}>
|
||||||
|
<ActionBarPrimitive.Speak asChild>
|
||||||
|
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||||
|
<IconVolume size={15} />
|
||||||
|
{t("readAloud")}
|
||||||
|
</ActionBarMorePrimitive.Item>
|
||||||
|
</ActionBarPrimitive.Speak>
|
||||||
|
</AuiIf>
|
||||||
|
<AuiIf condition={(state) => state.message.speech !== undefined}>
|
||||||
|
<ActionBarPrimitive.StopSpeaking asChild>
|
||||||
|
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||||
|
<IconVolumeOff size={15} />
|
||||||
|
{t("stopReading")}
|
||||||
|
</ActionBarMorePrimitive.Item>
|
||||||
|
</ActionBarPrimitive.StopSpeaking>
|
||||||
|
</AuiIf>
|
||||||
|
<ActionBarMorePrimitive.Separator className={classes.messageMoreSeparator} />
|
||||||
|
<ActionBarPrimitive.ExportMarkdown onExport={() => exportMessage()} asChild>
|
||||||
|
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||||
|
<IconFileExport size={15} />
|
||||||
|
{t("exportMarkdown")}
|
||||||
|
</ActionBarMorePrimitive.Item>
|
||||||
|
</ActionBarPrimitive.ExportMarkdown>
|
||||||
|
</ActionBarMorePrimitive.Content>
|
||||||
|
</ActionBarMorePrimitive.Root>
|
||||||
</Group>
|
</Group>
|
||||||
</ActionBarPrimitive.Root>
|
</ActionBarPrimitive.Root>
|
||||||
<BranchPicker />
|
<BranchPicker />
|
||||||
@@ -1181,7 +1213,7 @@ const AssistantMessageActions = () => {
|
|||||||
const UserMessageActions = () => {
|
const UserMessageActions = () => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
return (
|
return (
|
||||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
|
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className={classes.userActions}>
|
||||||
<Group gap={2}>
|
<Group gap={2}>
|
||||||
<Tooltip label={t("copyMessage")}>
|
<Tooltip label={t("copyMessage")}>
|
||||||
<ActionBarPrimitive.Copy asChild>
|
<ActionBarPrimitive.Copy asChild>
|
||||||
@@ -1225,7 +1257,7 @@ const UserMessage = () => {
|
|||||||
const isEditing = useAuiState((state) => state.composer.isEditing);
|
const isEditing = useAuiState((state) => state.composer.isEditing);
|
||||||
if (isEditing) return <EditComposer />;
|
if (isEditing) return <EditComposer />;
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
|
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`} data-static>
|
||||||
<Box className={classes.userMessage}>
|
<Box className={classes.userMessage}>
|
||||||
<MessagePrimitive.Quote>
|
<MessagePrimitive.Quote>
|
||||||
{(quote) => (
|
{(quote) => (
|
||||||
@@ -1732,12 +1764,17 @@ const ConversationContext = () => {
|
|||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
onClick={() => setOpened((value) => !value)}
|
onClick={() => setOpened((value) => !value)}
|
||||||
>
|
>
|
||||||
<RingProgress
|
<Group gap={5} wrap="nowrap">
|
||||||
size={24}
|
<RingProgress
|
||||||
thickness={3}
|
size={22}
|
||||||
roundCaps
|
thickness={3}
|
||||||
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
|
roundCaps
|
||||||
/>
|
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
|
||||||
|
/>
|
||||||
|
<Text className={classes.composerContextLabel} component="span" size="xs" fw={650}>
|
||||||
|
{hasContext ? `${contextPercentage}%` : t("mentions.context")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown className={classes.conversationContextPopover}>
|
<Popover.Dropdown className={classes.conversationContextPopover}>
|
||||||
@@ -1902,60 +1939,80 @@ const WebSearchActivity = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssistantMessage = () => (
|
const AssistantMessage = () => {
|
||||||
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
|
const isComplete = useAuiState((state) => state.message.status?.type === "complete");
|
||||||
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
|
||||||
{({ part, children }) => {
|
return (
|
||||||
switch (part.type) {
|
<MessagePrimitive.Root
|
||||||
case "group-agent-trace": {
|
className={`${classes.message} ${classes.assistantMessage}`}
|
||||||
const startIndex = part.indices[0];
|
data-static={isComplete || undefined}
|
||||||
const endIndex = part.indices.at(-1);
|
>
|
||||||
if (startIndex === undefined || endIndex === undefined) return null;
|
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
||||||
return (
|
{({ part, children }) => {
|
||||||
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
switch (part.type) {
|
||||||
<AssistantChainOfThought>{children}</AssistantChainOfThought>
|
case "group-agent-trace": {
|
||||||
</ChainOfThoughtByIndicesProvider>
|
const startIndex = part.indices[0];
|
||||||
);
|
const endIndex = part.indices.at(-1);
|
||||||
|
if (startIndex === undefined || endIndex === undefined) return null;
|
||||||
|
return (
|
||||||
|
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
||||||
|
<AssistantChainOfThought>{children}</AssistantChainOfThought>
|
||||||
|
</ChainOfThoughtByIndicesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "group-reasoning":
|
||||||
|
return <>{children}</>;
|
||||||
|
case "group-tool":
|
||||||
|
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
|
||||||
|
case "indicator":
|
||||||
|
return <AssistantMessagePending status={{ type: "running" }} />;
|
||||||
|
case "text":
|
||||||
|
return <AssistantTextPart />;
|
||||||
|
case "reasoning":
|
||||||
|
return <ReasoningPart {...part} />;
|
||||||
|
case "tool-call":
|
||||||
|
return part.toolUI ?? <ToolPart {...part} />;
|
||||||
|
case "source":
|
||||||
|
return <SourcePart {...part} />;
|
||||||
|
case "file":
|
||||||
|
return <FilePart {...part} />;
|
||||||
|
case "image":
|
||||||
|
return <ImagePart {...part} />;
|
||||||
|
case "data":
|
||||||
|
return part.dataRendererUI;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
case "group-reasoning":
|
}}
|
||||||
return <>{children}</>;
|
</MessagePrimitive.GroupedParts>
|
||||||
case "group-tool":
|
<RuntimeError />
|
||||||
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
|
<WebSearchActivity />
|
||||||
case "indicator":
|
<AssistantMessageActions />
|
||||||
return <AssistantMessagePending status={{ type: "running" }} />;
|
</MessagePrimitive.Root>
|
||||||
case "text":
|
);
|
||||||
return <AssistantTextPart />;
|
};
|
||||||
case "reasoning":
|
|
||||||
return <ReasoningPart {...part} />;
|
const assistantThreadMessageComponents = { UserMessage, AssistantMessage };
|
||||||
case "tool-call":
|
|
||||||
return part.toolUI ?? <ToolPart {...part} />;
|
|
||||||
case "source":
|
|
||||||
return <SourcePart {...part} />;
|
|
||||||
case "file":
|
|
||||||
return <FilePart {...part} />;
|
|
||||||
case "image":
|
|
||||||
return <ImagePart {...part} />;
|
|
||||||
case "data":
|
|
||||||
return part.dataRendererUI;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
</MessagePrimitive.GroupedParts>
|
|
||||||
<RuntimeError />
|
|
||||||
<WebSearchActivity />
|
|
||||||
<AssistantMessageActions />
|
|
||||||
</MessagePrimitive.Root>
|
|
||||||
);
|
|
||||||
|
|
||||||
const HistorySelectContext = createContext<() => void>(() => undefined);
|
const HistorySelectContext = createContext<() => void>(() => undefined);
|
||||||
|
|
||||||
const ThreadListItem = () => {
|
const ThreadListItem = () => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
|
const aui = useAui();
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const onSelect = useContext(HistorySelectContext);
|
const onSelect = useContext(HistorySelectContext);
|
||||||
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
|
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
|
||||||
const title = useAuiState((state) => state.threadListItem.title);
|
const title = useAuiState((state) => state.threadListItem.title);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [actionsOpened, setActionsOpened] = useState(false);
|
||||||
|
const [renameOpened, setRenameOpened] = useState(false);
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [nextTitle, setNextTitle] = useState(title ?? "");
|
||||||
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renameOpened) renameInputRef.current?.focus();
|
||||||
|
}, [renameOpened]);
|
||||||
|
|
||||||
const exportConversation = async () => {
|
const exportConversation = async () => {
|
||||||
if (!remoteId || exporting) return;
|
if (!remoteId || exporting) return;
|
||||||
@@ -1977,23 +2034,109 @@ const ThreadListItem = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renameConversation = async () => {
|
||||||
|
const trimmedTitle = nextTitle.trim();
|
||||||
|
if (!trimmedTitle || renaming) return;
|
||||||
|
setRenaming(true);
|
||||||
|
try {
|
||||||
|
await aui.threadListItem().rename(trimmedTitle);
|
||||||
|
setRenameOpened(false);
|
||||||
|
setActionsOpened(false);
|
||||||
|
} catch {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("renameConversation.failedTitle"),
|
||||||
|
message: t("renameConversation.failedDescription"),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setRenaming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteConversation = () => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("deleteConversation.title"),
|
||||||
|
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
|
||||||
|
confirmProps: { color: "red" },
|
||||||
|
labels: { confirm: t("delete"), cancel: t("cancel") },
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await aui.threadListItem().delete();
|
||||||
|
} catch {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("deleteConversation.failedTitle"),
|
||||||
|
message: t("deleteConversation.failedDescription"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (renameOpened) {
|
||||||
|
return (
|
||||||
|
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||||
|
<TextInput
|
||||||
|
ref={renameInputRef}
|
||||||
|
className={classes.historyRenameInput}
|
||||||
|
value={nextTitle}
|
||||||
|
onChange={(event) => setNextTitle(event.currentTarget.value)}
|
||||||
|
aria-label={t("renameConversation.label")}
|
||||||
|
maxLength={72}
|
||||||
|
size="xs"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") void renameConversation();
|
||||||
|
if (event.key === "Escape" && !renaming) setRenameOpened(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="green"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("renameConversation.save")}
|
||||||
|
disabled={!nextTitle.trim()}
|
||||||
|
loading={renaming}
|
||||||
|
loaderProps={{ type: "bars" }}
|
||||||
|
onClick={() => void renameConversation()}
|
||||||
|
>
|
||||||
|
<IconCheck size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("cancel")}
|
||||||
|
disabled={renaming}
|
||||||
|
onClick={() => setRenameOpened(false)}
|
||||||
|
>
|
||||||
|
<IconX size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</ThreadListItemPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||||
<Group gap={4} wrap="nowrap">
|
<ThreadListItemPrimitive.Trigger asChild>
|
||||||
<ThreadListItemPrimitive.Trigger asChild>
|
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}>
|
||||||
<Button
|
<Text component="span" size="sm" fw={500} truncate>
|
||||||
|
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</ThreadListItemPrimitive.Trigger>
|
||||||
|
{actionsOpened && (
|
||||||
|
<Group className={classes.historyItemActions} gap={1} wrap="nowrap">
|
||||||
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="compact-sm"
|
size="sm"
|
||||||
justify="flex-start"
|
title={t("renameConversation.action")}
|
||||||
flex={1}
|
aria-label={t("renameConversation.action")}
|
||||||
style={{ minWidth: 0 }}
|
onClick={() => {
|
||||||
onClick={onSelect}
|
setNextTitle(title ?? "");
|
||||||
|
setRenameOpened(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
<IconPencil size={14} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
</ThreadListItemPrimitive.Trigger>
|
|
||||||
<Tooltip label={t("exportConversation.action")}>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -2001,24 +2144,49 @@ const ThreadListItem = () => {
|
|||||||
loading={exporting}
|
loading={exporting}
|
||||||
loaderProps={{ type: "bars" }}
|
loaderProps={{ type: "bars" }}
|
||||||
disabled={!remoteId}
|
disabled={!remoteId}
|
||||||
|
title={t("exportConversation.action")}
|
||||||
aria-label={t("exportConversation.action")}
|
aria-label={t("exportConversation.action")}
|
||||||
onClick={() => void exportConversation()}
|
onClick={() => void exportConversation()}
|
||||||
>
|
>
|
||||||
<IconFileExport size={14} />
|
<IconFileExport size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
<ActionIcon
|
||||||
<ThreadListItemPrimitive.Delete asChild>
|
variant="subtle"
|
||||||
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
title={t("delete")}
|
||||||
|
aria-label={t("delete")}
|
||||||
|
onClick={deleteConversation}
|
||||||
|
>
|
||||||
<IconTrash size={14} />
|
<IconTrash size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</ThreadListItemPrimitive.Delete>
|
</Group>
|
||||||
</Group>
|
)}
|
||||||
|
<ActionIcon
|
||||||
|
className={classes.historyItemMenu}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
title={t("conversationActions")}
|
||||||
|
aria-label={t("conversationActions")}
|
||||||
|
aria-expanded={actionsOpened}
|
||||||
|
onClick={() => setActionsOpened((current) => !current)}
|
||||||
|
>
|
||||||
|
{actionsOpened ? <IconX size={15} /> : <IconDotsVertical size={15} />}
|
||||||
|
</ActionIcon>
|
||||||
</ThreadListItemPrimitive.Root>
|
</ThreadListItemPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
|
const threadItems = useAuiState((state) => state.threads.threadItems);
|
||||||
|
const hasMatchingConversation = threadItems.some((item) => {
|
||||||
|
const itemTitle = item.title ?? t("newConversation");
|
||||||
|
return itemTitle.toLocaleLowerCase().includes(normalizedQuery);
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<HistorySelectContext.Provider value={onSelect}>
|
<HistorySelectContext.Provider value={onSelect}>
|
||||||
<Stack className={classes.historyMenu} gap="xs" p="xs">
|
<Stack className={classes.historyMenu} gap="xs" p="xs">
|
||||||
@@ -2035,9 +2203,28 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
|||||||
{t("conversations")}
|
{t("conversations")}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" offsetScrollbars>
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||||
|
leftSection={<IconSearch size={15} />}
|
||||||
|
placeholder={t("searchConversations")}
|
||||||
|
aria-label={t("searchConversations")}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" scrollbars="y" offsetScrollbars>
|
||||||
<Stack gap={3}>
|
<Stack gap={3}>
|
||||||
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
|
<ThreadListPrimitive.Items>
|
||||||
|
{({ threadListItem }) => {
|
||||||
|
const itemTitle = threadListItem.title ?? t("newConversation");
|
||||||
|
if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null;
|
||||||
|
return <ThreadListItem />;
|
||||||
|
}}
|
||||||
|
</ThreadListPrimitive.Items>
|
||||||
|
{normalizedQuery && !hasMatchingConversation && (
|
||||||
|
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||||
|
{t("noMatchingConversations")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea.Autosize>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -2052,7 +2239,6 @@ const ConversationHistory = () => {
|
|||||||
<Popover
|
<Popover
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onChange={setOpened}
|
onChange={setOpened}
|
||||||
onDismiss={() => setOpened(false)}
|
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
width="min(22rem, calc(100vw - 1rem))"
|
width="min(22rem, calc(100vw - 1rem))"
|
||||||
shadow="md"
|
shadow="md"
|
||||||
@@ -2191,7 +2377,7 @@ const EmptyThread = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box className={classes.suggestions}>
|
<Box className={classes.suggestions}>
|
||||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
|
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send={false} clearComposer asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
className={classes.suggestion}
|
className={classes.suggestion}
|
||||||
@@ -2200,17 +2386,17 @@ const EmptyThread = () => {
|
|||||||
{t("suggestions.health.label")}
|
{t("suggestions.health.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</ThreadPrimitive.Suggestion>
|
</ThreadPrimitive.Suggestion>
|
||||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
|
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send={false} clearComposer asChild>
|
||||||
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
|
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
|
||||||
{t("suggestions.explore.label")}
|
{t("suggestions.explore.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</ThreadPrimitive.Suggestion>
|
</ThreadPrimitive.Suggestion>
|
||||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
|
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send={false} clearComposer asChild>
|
||||||
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
|
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
|
||||||
{t("suggestions.media.label")}
|
{t("suggestions.media.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</ThreadPrimitive.Suggestion>
|
</ThreadPrimitive.Suggestion>
|
||||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send asChild>
|
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send={false} clearComposer asChild>
|
||||||
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
|
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
|
||||||
{t("suggestions.style.label")}
|
{t("suggestions.style.label")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2792,14 +2978,6 @@ const Composer = (props: ComposerProps) => {
|
|||||||
<HomarrProviderQuota />
|
<HomarrProviderQuota />
|
||||||
<ConversationContext />
|
<ConversationContext />
|
||||||
</Group>
|
</Group>
|
||||||
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("mentions.hint")}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("commands.hint")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
</ComposerPrimitive.Root>
|
</ComposerPrimitive.Root>
|
||||||
</ComposerPrimitive.AttachmentDropzone>
|
</ComposerPrimitive.AttachmentDropzone>
|
||||||
@@ -2979,9 +3157,30 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
|
|||||||
pendingAction: AssistantPendingAction | undefined;
|
pendingAction: AssistantPendingAction | undefined;
|
||||||
onExpand?: () => void;
|
onExpand?: () => void;
|
||||||
onMinimize?: () => void;
|
onMinimize?: () => void;
|
||||||
onWindowClose?: () => void;
|
onDismiss?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AssistantPanelIdentity = () => {
|
||||||
|
const t = useScopedI18n("common.assistant");
|
||||||
|
const threadTitle = useAuiState((state) => state.threadListItem.title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
|
||||||
|
<ThemeIcon className={classes.panelIdentityIcon} variant="light" color="red" radius="xl" size="md">
|
||||||
|
<IconRobot size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box miw={0}>
|
||||||
|
<Text className={classes.panelIdentityTitle} size="sm" fw={700} truncate>
|
||||||
|
{t("title")}
|
||||||
|
</Text>
|
||||||
|
<Text className={classes.panelThreadTitle} size="xs" c="dimmed" truncate>
|
||||||
|
{threadTitle ?? t("newConversation")}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AssistantConversationSurface = ({
|
export const AssistantConversationSurface = ({
|
||||||
isRunning,
|
isRunning,
|
||||||
pendingAction,
|
pendingAction,
|
||||||
@@ -2996,7 +3195,7 @@ export const AssistantConversationSurface = ({
|
|||||||
onReasoningChange,
|
onReasoningChange,
|
||||||
onExpand,
|
onExpand,
|
||||||
onMinimize,
|
onMinimize,
|
||||||
onWindowClose,
|
onDismiss,
|
||||||
}: AssistantConversationSurfaceProps) => {
|
}: AssistantConversationSurfaceProps) => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
@@ -3011,52 +3210,23 @@ export const AssistantConversationSurface = ({
|
|||||||
return (
|
return (
|
||||||
<AssistantDirectiveEntitiesProvider>
|
<AssistantDirectiveEntitiesProvider>
|
||||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||||
{onWindowClose && onMinimize && (
|
<AssistantPanelIdentity />
|
||||||
<Group
|
|
||||||
component="fieldset"
|
|
||||||
className={classes.windowControls}
|
|
||||||
gap={4}
|
|
||||||
wrap="nowrap"
|
|
||||||
aria-label={t("windowControls")}
|
|
||||||
>
|
|
||||||
<Tooltip label={t("close")}>
|
|
||||||
<ActionIcon
|
|
||||||
className={classes.windowControl}
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={onWindowClose}
|
|
||||||
aria-label={t("close")}
|
|
||||||
>
|
|
||||||
<IconX size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("minimize")}>
|
|
||||||
<ActionIcon
|
|
||||||
className={classes.windowControl}
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={onMinimize}
|
|
||||||
aria-label={t("minimize")}
|
|
||||||
>
|
|
||||||
<IconMinus size={17} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||||
<ConversationHistory />
|
<ConversationHistory />
|
||||||
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
||||||
<AutoApprovalControl />
|
<AutoApprovalControl />
|
||||||
<Tooltip label={t("newConversation")}>
|
<Tooltip label={t("newConversation")}>
|
||||||
<ThreadListPrimitive.New asChild>
|
<ThreadListPrimitive.New asChild>
|
||||||
<ActionIcon
|
<Button
|
||||||
className={classes.panelAction}
|
className={classes.newConversationButton}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
leftSection={<IconPlus size={17} />}
|
||||||
aria-label={t("newConversation")}
|
aria-label={t("newConversation")}
|
||||||
>
|
>
|
||||||
<IconPlus size={17} />
|
{t("newConversation")}
|
||||||
</ActionIcon>
|
</Button>
|
||||||
</ThreadListPrimitive.New>
|
</ThreadListPrimitive.New>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{onExpand && (
|
{onExpand && (
|
||||||
@@ -3072,6 +3242,32 @@ export const AssistantConversationSurface = ({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{onMinimize && (
|
||||||
|
<Tooltip label={t("minimize")}>
|
||||||
|
<ActionIcon
|
||||||
|
className={classes.panelAction}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={onMinimize}
|
||||||
|
aria-label={t("minimize")}
|
||||||
|
>
|
||||||
|
<IconMinus size={17} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{onDismiss && (
|
||||||
|
<Tooltip label={t("close")}>
|
||||||
|
<ActionIcon
|
||||||
|
className={classes.panelAction}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label={t("close")}
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<AssistantQuestionPortalProvider target={questionPortalTarget}>
|
<AssistantQuestionPortalProvider target={questionPortalTarget}>
|
||||||
@@ -3083,7 +3279,7 @@ export const AssistantConversationSurface = ({
|
|||||||
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
|
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
|
||||||
<Box className={classes.messages}>
|
<Box className={classes.messages}>
|
||||||
<EmptyThread />
|
<EmptyThread />
|
||||||
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
<ThreadPrimitive.Messages components={assistantThreadMessageComponents} />
|
||||||
</Box>
|
</Box>
|
||||||
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
|
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
|
||||||
<SelectionToolbarPrimitive.Quote asChild>
|
<SelectionToolbarPrimitive.Quote asChild>
|
||||||
@@ -3130,6 +3326,7 @@ export const AssistantPanel = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onDismissActivity,
|
onDismissActivity,
|
||||||
activityDismissed,
|
activityDismissed,
|
||||||
|
hasVisibleWidget,
|
||||||
isRunning,
|
isRunning,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
latestAssistantText,
|
latestAssistantText,
|
||||||
@@ -3147,8 +3344,18 @@ export const AssistantPanel = ({
|
|||||||
onReasoningChange,
|
onReasoningChange,
|
||||||
}: AssistantPanelProps) => {
|
}: AssistantPanelProps) => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
|
const aui = useAui();
|
||||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const panelRef = useRef<HTMLDialogElement>(null);
|
const panelRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const stopSpeechWithoutSurface = () => {
|
||||||
|
if (hasVisibleWidget) return;
|
||||||
|
const thread = aui.thread();
|
||||||
|
if (thread.getState().speech !== undefined) thread.stopSpeaking();
|
||||||
|
};
|
||||||
|
const minimize = () => {
|
||||||
|
stopSpeechWithoutSurface();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
useWindowEvent("keydown", (event) => {
|
useWindowEvent("keydown", (event) => {
|
||||||
if (
|
if (
|
||||||
@@ -3160,7 +3367,7 @@ export const AssistantPanel = ({
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onClose();
|
minimize();
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
@@ -3182,7 +3389,7 @@ export const AssistantPanel = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!opened && (
|
{!opened && !hasVisibleWidget && (
|
||||||
<AssistantActivityBar
|
<AssistantActivityBar
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onDismissActivity={onDismissActivity}
|
onDismissActivity={onDismissActivity}
|
||||||
@@ -3210,8 +3417,9 @@ export const AssistantPanel = ({
|
|||||||
onModelChange={onModelChange}
|
onModelChange={onModelChange}
|
||||||
onReasoningChange={onReasoningChange}
|
onReasoningChange={onReasoningChange}
|
||||||
autoFocusComposer
|
autoFocusComposer
|
||||||
onMinimize={onClose}
|
onMinimize={minimize}
|
||||||
onWindowClose={() => {
|
onDismiss={() => {
|
||||||
|
stopSpeechWithoutSurface();
|
||||||
onDismissActivity();
|
onDismissActivity();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,11 +7,19 @@ import type {
|
|||||||
AttachmentAdapter,
|
AttachmentAdapter,
|
||||||
MessageFormatAdapter,
|
MessageFormatAdapter,
|
||||||
RemoteThreadListAdapter,
|
RemoteThreadListAdapter,
|
||||||
|
SpeechSynthesisAdapter,
|
||||||
ThreadHistoryAdapter,
|
ThreadHistoryAdapter,
|
||||||
ThreadMessage,
|
ThreadMessage,
|
||||||
Toolkit,
|
Toolkit,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react";
|
import {
|
||||||
|
defineToolkit,
|
||||||
|
useAui,
|
||||||
|
useAuiEvent,
|
||||||
|
useAuiState,
|
||||||
|
useRemoteThreadListRuntime,
|
||||||
|
WebSpeechSynthesisAdapter,
|
||||||
|
} from "@assistant-ui/react";
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
|
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
@@ -41,7 +49,8 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi
|
|||||||
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
||||||
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
|
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
|
||||||
import {
|
import {
|
||||||
AssistantComposerSurfaceProvider,
|
AssistantComposerRuntimeProvider,
|
||||||
|
AssistantComposerSurfaceBoundary,
|
||||||
AssistantRunFocusPreserver,
|
AssistantRunFocusPreserver,
|
||||||
AssistantRuntimeProviderWithTools,
|
AssistantRuntimeProviderWithTools,
|
||||||
} from "./assistant-runtime-provider";
|
} from "./assistant-runtime-provider";
|
||||||
@@ -475,6 +484,12 @@ const AssistantThreadRuntime = () => {
|
|||||||
),
|
),
|
||||||
[selectedModel],
|
[selectedModel],
|
||||||
);
|
);
|
||||||
|
const [speech, setSpeech] = useState<SpeechSynthesisAdapter>();
|
||||||
|
useEffect(() => {
|
||||||
|
if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") {
|
||||||
|
setSpeech(new WebSpeechSynthesisAdapter());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const feedback = useMemo(
|
const feedback = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
|
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
|
||||||
@@ -512,6 +527,7 @@ const AssistantThreadRuntime = () => {
|
|||||||
history,
|
history,
|
||||||
attachments,
|
attachments,
|
||||||
feedback,
|
feedback,
|
||||||
|
speech,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
transport.setRuntime(runtime);
|
transport.setRuntime(runtime);
|
||||||
@@ -667,6 +683,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [activityDismissed, setActivityDismissed] = useState(true);
|
const [activityDismissed, setActivityDismissed] = useState(true);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [visibleWidgetIds, setVisibleWidgetIds] = useState<Set<string>>(() => new Set());
|
||||||
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
|
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
const preferences = useAssistantPreferences();
|
const preferences = useAssistantPreferences();
|
||||||
@@ -714,6 +731,21 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
setOpened(true);
|
setOpened(true);
|
||||||
}, [markRead]);
|
}, [markRead]);
|
||||||
const close = useCallback(() => setOpened(false), []);
|
const close = useCallback(() => setOpened(false), []);
|
||||||
|
const setWidgetVisible = useCallback((widgetId: string, visible: boolean) => {
|
||||||
|
setVisibleWidgetIds((current) => {
|
||||||
|
if (current.has(widgetId) === visible) return current;
|
||||||
|
const next = new Set(current);
|
||||||
|
if (visible) next.add(widgetId);
|
||||||
|
else next.delete(widgetId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const activateWidget = useCallback((widgetId: string) => {
|
||||||
|
setVisibleWidgetIds((current) => {
|
||||||
|
if (!current.has(widgetId) || current.values().next().value === widgetId) return current;
|
||||||
|
return new Set([widgetId, ...Array.from(current).filter((id) => id !== widgetId)]);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (opened) close();
|
if (opened) close();
|
||||||
else open();
|
else open();
|
||||||
@@ -835,6 +867,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
|
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
|
||||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||||
|
|
||||||
|
const activeWidgetId = visibleWidgetIds.values().next().value ?? null;
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -843,46 +876,67 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
isRunning: assistantIsRunning,
|
isRunning: assistantIsRunning,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
hasVisibleWidget: visibleWidgetIds.size > 0,
|
||||||
|
activeWidgetId,
|
||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
toggle,
|
toggle,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
refreshCurrentView,
|
refreshCurrentView,
|
||||||
|
setWidgetVisible,
|
||||||
|
activateWidget,
|
||||||
}),
|
}),
|
||||||
[assistantIsRunning, close, isRefreshing, open, opened, refreshCurrentView, sendPrompt, toggle, unreadCount],
|
[
|
||||||
|
assistantIsRunning,
|
||||||
|
activateWidget,
|
||||||
|
activeWidgetId,
|
||||||
|
close,
|
||||||
|
isRefreshing,
|
||||||
|
open,
|
||||||
|
opened,
|
||||||
|
refreshCurrentView,
|
||||||
|
sendPrompt,
|
||||||
|
setWidgetVisible,
|
||||||
|
toggle,
|
||||||
|
unreadCount,
|
||||||
|
visibleWidgetIds.size,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AssistantAutoApprovalProvider conversationId={conversationId}>
|
<AssistantAutoApprovalProvider conversationId={conversationId}>
|
||||||
<AssistantContext.Provider value={value}>
|
<AssistantContext.Provider value={value}>
|
||||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
<AssistantComposerRuntimeProvider>
|
||||||
<AssistantComposerSurfaceProvider surfaceId="floating-panel">
|
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||||
<AssistantPanel
|
<AssistantComposerSurfaceBoundary surfaceId="assistant-panel">
|
||||||
opened={opened}
|
<AssistantPanel
|
||||||
onOpen={open}
|
opened={opened}
|
||||||
onClose={close}
|
onOpen={open}
|
||||||
onDismissActivity={() => {
|
onClose={close}
|
||||||
markRead();
|
onDismissActivity={() => {
|
||||||
setActivityDismissed(true);
|
markRead();
|
||||||
}}
|
setActivityDismissed(true);
|
||||||
activityDismissed={activityDismissed}
|
}}
|
||||||
isRunning={assistantIsRunning}
|
activityDismissed={activityDismissed}
|
||||||
unreadCount={unreadCount}
|
hasVisibleWidget={visibleWidgetIds.size > 0}
|
||||||
latestAssistantText={latestAssistantText}
|
isRunning={assistantIsRunning}
|
||||||
latestAssistantPartType={latestAssistantPartType}
|
unreadCount={unreadCount}
|
||||||
latestUserText={queuedPrompt ?? latestUserText}
|
latestAssistantText={latestAssistantText}
|
||||||
latestStatus={latestStatus}
|
latestAssistantPartType={latestAssistantPartType}
|
||||||
pendingAction={pendingAction}
|
latestUserText={queuedPrompt ?? latestUserText}
|
||||||
modelId={preferences.modelId}
|
latestStatus={latestStatus}
|
||||||
models={preferences.models}
|
pendingAction={pendingAction}
|
||||||
modelOptionsLoading={preferences.isLoading}
|
modelId={preferences.modelId}
|
||||||
reasoning={preferences.reasoning}
|
models={preferences.models}
|
||||||
isRefreshing={isRefreshing}
|
modelOptionsLoading={preferences.isLoading}
|
||||||
onRefresh={refreshCurrentView}
|
reasoning={preferences.reasoning}
|
||||||
onModelChange={selectModel}
|
isRefreshing={isRefreshing}
|
||||||
onReasoningChange={preferences.setReasoning}
|
onRefresh={refreshCurrentView}
|
||||||
/>
|
onModelChange={selectModel}
|
||||||
</AssistantComposerSurfaceProvider>
|
onReasoningChange={preferences.setReasoning}
|
||||||
|
/>
|
||||||
|
</AssistantComposerSurfaceBoundary>
|
||||||
|
</AssistantComposerRuntimeProvider>
|
||||||
</AssistantContext.Provider>
|
</AssistantContext.Provider>
|
||||||
</AssistantAutoApprovalProvider>
|
</AssistantAutoApprovalProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import { z } from "zod/v4";
|
|||||||
import { showErrorNotification } from "@homarr/notifications";
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AssistantComposerSurfaceProvider,
|
AssistantComposerRuntimeProvider,
|
||||||
|
AssistantComposerSurfaceBoundary,
|
||||||
AssistantRunFocusPreserver,
|
AssistantRunFocusPreserver,
|
||||||
AssistantRuntimeProviderWithTools,
|
AssistantRuntimeProviderWithTools,
|
||||||
assistantSurfaceComposerCacheLimit,
|
assistantSurfaceComposerCacheLimit,
|
||||||
@@ -148,14 +149,22 @@ const SurfaceTestAssistant = ({
|
|||||||
{ runtime, toolkit },
|
{ runtime, toolkit },
|
||||||
createElement(AssistantRunFocusPreserver),
|
createElement(AssistantRunFocusPreserver),
|
||||||
createElement(
|
createElement(
|
||||||
AssistantComposerSurfaceProvider,
|
AssistantComposerRuntimeProvider,
|
||||||
{ surfaceId: "surface-a" },
|
null,
|
||||||
createElement(SurfaceProbe, { id: "a", onReady }),
|
createElement(
|
||||||
|
AssistantComposerSurfaceBoundary,
|
||||||
|
{ surfaceId: "surface-a" },
|
||||||
|
createElement(SurfaceProbe, { id: "a", onReady }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
createElement(
|
createElement(
|
||||||
AssistantComposerSurfaceProvider,
|
AssistantComposerRuntimeProvider,
|
||||||
{ surfaceId: "surface-b" },
|
null,
|
||||||
createElement(SurfaceProbe, { id: "b", onReady }),
|
createElement(
|
||||||
|
AssistantComposerSurfaceBoundary,
|
||||||
|
{ surfaceId: "surface-b" },
|
||||||
|
createElement(SurfaceProbe, { id: "b", onReady }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
|
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -157,9 +157,6 @@ const createSurfaceComposerHost = (
|
|||||||
get attachments() {
|
get attachments() {
|
||||||
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
|
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
|
||||||
},
|
},
|
||||||
get dictation() {
|
|
||||||
return getThreadAdapters(threadCore)?.dictation;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Proxy(threadCore as ThreadCoreWithAdapters, {
|
return new Proxy(threadCore as ThreadCoreWithAdapters, {
|
||||||
@@ -294,18 +291,21 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
|
|||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
let activeCore = sharedBinding.getState();
|
let activeCore = sharedBinding.getState();
|
||||||
let activeConversationId = getConversationId();
|
let activeConversationId = getConversationId();
|
||||||
|
let activeComposer = getComposer(activeConversationId, activeCore);
|
||||||
retainConversation(activeConversationId);
|
retainConversation(activeConversationId);
|
||||||
let unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
let unsubscribeComposer = activeComposer.subscribe(callback);
|
||||||
const handleSharedUpdate = () => {
|
const handleSharedUpdate = () => {
|
||||||
const nextCore = sharedBinding.getState();
|
const nextCore = sharedBinding.getState();
|
||||||
const nextConversationId = getConversationId();
|
const nextConversationId = getConversationId();
|
||||||
if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
|
if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
|
||||||
|
if (activeCore.speech !== undefined) activeCore.stopSpeaking();
|
||||||
unsubscribeComposer();
|
unsubscribeComposer();
|
||||||
releaseConversation(activeConversationId);
|
releaseConversation(activeConversationId);
|
||||||
activeCore = nextCore;
|
activeCore = nextCore;
|
||||||
activeConversationId = nextConversationId;
|
activeConversationId = nextConversationId;
|
||||||
retainConversation(activeConversationId);
|
retainConversation(activeConversationId);
|
||||||
unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
activeComposer = getComposer(activeConversationId, activeCore);
|
||||||
|
unsubscribeComposer = activeComposer.subscribe(callback);
|
||||||
}
|
}
|
||||||
callback();
|
callback();
|
||||||
};
|
};
|
||||||
@@ -360,6 +360,13 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
|
|||||||
registerModelContextProvider: () => () => {},
|
registerModelContextProvider: () => () => {},
|
||||||
dispose() {
|
dispose() {
|
||||||
activeConversationCounts.clear();
|
activeConversationCounts.clear();
|
||||||
|
const threadCores = new Set([
|
||||||
|
...Array.from(surfaceCoreByConversation.values(), (entry) => entry.source),
|
||||||
|
...Array.from(composerByConversation.values(), (entry) => entry.source),
|
||||||
|
]);
|
||||||
|
for (const threadCore of threadCores) {
|
||||||
|
if (threadCore.speech !== undefined) threadCore.stopSpeaking();
|
||||||
|
}
|
||||||
for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry);
|
for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -373,7 +380,7 @@ const AssistantComposerSurfaceContext = createContext<AssistantComposerSurfaceVa
|
|||||||
|
|
||||||
export const useAssistantComposerSurface = () => {
|
export const useAssistantComposerSurface = () => {
|
||||||
const value = useContext(AssistantComposerSurfaceContext);
|
const value = useContext(AssistantComposerSurfaceContext);
|
||||||
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceProvider");
|
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceBoundary");
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -437,21 +444,23 @@ const AssistantComposerSurfaceEvents = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
|
export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
|
const instanceId = useId();
|
||||||
|
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
const contextValue = useMemo(() => ({ id: surfaceId }), [surfaceId]);
|
const contextValue = useMemo(() => ({ id: resolvedSurfaceId }), [resolvedSurfaceId]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const element = elementRef.current;
|
const element = elementRef.current;
|
||||||
if (!element) return undefined;
|
if (!element) return undefined;
|
||||||
surfaceElements.set(surfaceId, element);
|
surfaceElements.set(resolvedSurfaceId, element);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (surfaceElements.get(surfaceId) === element) surfaceElements.delete(surfaceId);
|
if (surfaceElements.get(resolvedSurfaceId) === element) surfaceElements.delete(resolvedSurfaceId);
|
||||||
};
|
};
|
||||||
}, [surfaceId]);
|
}, [resolvedSurfaceId]);
|
||||||
|
|
||||||
const handlePasteCapture = (event: ClipboardEvent<HTMLDivElement>) => {
|
const handlePasteCapture = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
@@ -507,7 +516,6 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil
|
|||||||
}}
|
}}
|
||||||
onPasteCapture={handlePasteCapture}
|
onPasteCapture={handlePasteCapture}
|
||||||
>
|
>
|
||||||
<AssistantComposerSurfaceEvents />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</AssistantComposerSurfaceContext.Provider>
|
</AssistantComposerSurfaceContext.Provider>
|
||||||
@@ -566,18 +574,12 @@ export const AssistantRunFocusPreserver = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AssistantComposerSurfaceProviderProps extends PropsWithChildren {
|
|
||||||
surfaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gives one rendered assistant surface a local composer while preserving the shared conversation.
|
* Gives every rendered assistant surface one shared local composer while preserving the shared
|
||||||
* Every component below this boundary can keep using the normal assistant-ui primitives and hooks.
|
* conversation. Individual surfaces should add AssistantComposerSurfaceBoundary around their UI.
|
||||||
*/
|
*/
|
||||||
export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => {
|
export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren) => {
|
||||||
const parentAui = useAui();
|
const parentAui = useAui();
|
||||||
const instanceId = useId();
|
|
||||||
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
|
|
||||||
// oxlint-disable-next-line no-underscore-dangle -- required to derive a scoped runtime without cloning the shared thread.
|
// oxlint-disable-next-line no-underscore-dangle -- required to derive a scoped runtime without cloning the shared thread.
|
||||||
const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.();
|
const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.();
|
||||||
const surfaceRuntime = useMemo(() => {
|
const surfaceRuntime = useMemo(() => {
|
||||||
@@ -588,7 +590,8 @@ export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: Assist
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
|
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
|
||||||
<AssistantComposerSurfaceBoundary surfaceId={resolvedSurfaceId}>{children}</AssistantComposerSurfaceBoundary>
|
<AssistantComposerSurfaceEvents />
|
||||||
|
{children}
|
||||||
</AssistantRuntimeProvider>
|
</AssistantRuntimeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import { useEffect, useId, useRef } from "react";
|
||||||
import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||||
import { IconArrowsMaximize, IconRobot } from "@tabler/icons-react";
|
import { IconArrowsMaximize, IconArrowsMinimize, IconRobot } from "@tabler/icons-react";
|
||||||
import { useAui, useAuiState } from "@assistant-ui/react";
|
import { useAui, useAuiState } from "@assistant-ui/react";
|
||||||
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
@@ -13,23 +14,47 @@ import classes from "./assistant-panel.module.css";
|
|||||||
import { AssistantConversationSurface } from "./assistant-panel";
|
import { AssistantConversationSurface } from "./assistant-panel";
|
||||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||||
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
|
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
|
||||||
import { AssistantComposerSurfaceProvider } from "./assistant-runtime-provider";
|
import { AssistantComposerSurfaceBoundary } from "./assistant-runtime-provider";
|
||||||
|
|
||||||
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
||||||
const assistant = useHomarrAssistant();
|
const assistant = useHomarrAssistant();
|
||||||
|
const { enabled, setWidgetVisible } = assistant;
|
||||||
|
const generatedWidgetId = useId();
|
||||||
|
const widgetId = props.itemId ?? generatedWidgetId;
|
||||||
|
const widgetRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const widget = widgetRef.current;
|
||||||
|
if (!enabled || !widget) return;
|
||||||
|
const observer = new IntersectionObserver(([entry]) => setWidgetVisible(widgetId, entry?.isIntersecting === true), {
|
||||||
|
threshold: 0.15,
|
||||||
|
});
|
||||||
|
observer.observe(widget);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
setWidgetVisible(widgetId, false);
|
||||||
|
};
|
||||||
|
}, [enabled, setWidgetVisible, widgetId]);
|
||||||
|
|
||||||
if (!assistant.enabled) {
|
if (!assistant.enabled) {
|
||||||
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
|
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AssistantComposerSurfaceProvider surfaceId={`board-widget-${props.itemId ?? "preview"}`}>
|
<AssistantComposerSurfaceBoundary surfaceId={`assistant-widget-${widgetId}`}>
|
||||||
<EnabledAssistantBoardWidget {...props} />
|
<Box ref={widgetRef} w="100%" h="100%">
|
||||||
</AssistantComposerSurfaceProvider>
|
<EnabledAssistantBoardWidget {...props} assistantWidgetId={widgetId} />
|
||||||
|
</Box>
|
||||||
|
</AssistantComposerSurfaceBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
|
const EnabledAssistantBoardWidget = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
isEditMode,
|
||||||
|
assistantWidgetId,
|
||||||
|
}: WidgetComponentProps<"assistant"> & { assistantWidgetId: string }) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const assistant = useHomarrAssistant();
|
const assistant = useHomarrAssistant();
|
||||||
const preferences = useAssistantPreferences();
|
const preferences = useAssistantPreferences();
|
||||||
@@ -45,6 +70,14 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon
|
|||||||
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
|
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (assistant.opened) {
|
||||||
|
return <CompactAssistantWidget onOpen={assistant.close} location="panel" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assistant.activeWidgetId !== null && assistant.activeWidgetId !== assistantWidgetId) {
|
||||||
|
return <CompactAssistantWidget onOpen={() => assistant.activateWidget(assistantWidgetId)} location="widget" />;
|
||||||
|
}
|
||||||
|
|
||||||
if (width < 300 || height < 280) {
|
if (width < 300 || height < 280) {
|
||||||
return <CompactAssistantWidget onOpen={assistant.open} />;
|
return <CompactAssistantWidget onOpen={assistant.open} />;
|
||||||
}
|
}
|
||||||
@@ -75,8 +108,30 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
|
const CompactAssistantWidget = ({
|
||||||
|
onOpen,
|
||||||
|
location = "compact",
|
||||||
|
}: {
|
||||||
|
onOpen: () => void;
|
||||||
|
location?: "compact" | "panel" | "widget";
|
||||||
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
let title = t("widget.assistant.compact.title");
|
||||||
|
let description = t("widget.assistant.compact.description");
|
||||||
|
let actionLabel = t("widget.assistant.compact.open");
|
||||||
|
let actionIcon = <IconArrowsMaximize size={18} />;
|
||||||
|
|
||||||
|
if (location === "panel") {
|
||||||
|
title = t("widget.assistant.compact.openTitle");
|
||||||
|
description = t("widget.assistant.compact.openDescription");
|
||||||
|
actionLabel = t("widget.assistant.compact.returnToWidget");
|
||||||
|
actionIcon = <IconArrowsMinimize size={18} />;
|
||||||
|
}
|
||||||
|
if (location === "widget") {
|
||||||
|
title = t("widget.assistant.compact.otherWidgetTitle");
|
||||||
|
description = t("widget.assistant.compact.otherWidgetDescription");
|
||||||
|
actionLabel = t("widget.assistant.compact.useHere");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classes.widgetPanel}>
|
<Box className={classes.widgetPanel}>
|
||||||
@@ -87,23 +142,16 @@ const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack className={classes.compactCopy} gap={1}>
|
<Stack className={classes.compactCopy} gap={1}>
|
||||||
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
|
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
|
||||||
{t("widget.assistant.compact.title")}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
|
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
|
||||||
{t("widget.assistant.compact.description")}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<Tooltip label={t("widget.assistant.compact.open")}>
|
<Tooltip label={actionLabel}>
|
||||||
<ActionIcon
|
<ActionIcon variant="light" color="red" size={44} radius="md" onClick={onOpen} aria-label={actionLabel}>
|
||||||
variant="light"
|
{actionIcon}
|
||||||
color="red"
|
|
||||||
size={44}
|
|
||||||
radius="md"
|
|
||||||
onClick={onOpen}
|
|
||||||
aria-label={t("widget.assistant.compact.open")}
|
|
||||||
>
|
|
||||||
<IconArrowsMaximize size={18} />
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Procedural UI sounds for Homarr using the Web Audio API.
|
||||||
|
* Zero audio files shipped. Pure runtime synthesis with oscillators, noise buffers, and gain envelopes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
|
||||||
|
const getAudioContext = (): AudioContext | null => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!audioContext) {
|
||||||
|
const AudioContextClass =
|
||||||
|
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (AudioContextClass) audioContext = new AudioContextClass();
|
||||||
|
}
|
||||||
|
if (audioContext?.state === "suspended") void audioContext.resume().catch(() => undefined);
|
||||||
|
return audioContext;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a white noise audio buffer for percussive textures
|
||||||
|
*/
|
||||||
|
const createNoiseBuffer = (ctx: AudioContext, durationSeconds = 0.2): AudioBuffer => {
|
||||||
|
const bufferSize = Math.floor(ctx.sampleRate * durationSeconds);
|
||||||
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data[i] = Math.random() * 2 - 1;
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procedural trash / delete crunch sound.
|
||||||
|
* Synthesizes a crunchy bin/paper crumple noise burst and downward pitch thud.
|
||||||
|
*/
|
||||||
|
export const playTrashSound = (volume = 0.35) => {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
// Master Gain
|
||||||
|
const masterGain = ctx.createGain();
|
||||||
|
masterGain.gain.setValueAtTime(volume, now);
|
||||||
|
masterGain.connect(ctx.destination);
|
||||||
|
|
||||||
|
// 1. Downward sweep sub-oscillator (physical thud)
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const oscGain = ctx.createGain();
|
||||||
|
osc.type = "triangle";
|
||||||
|
osc.frequency.setValueAtTime(260, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(35, now + 0.18);
|
||||||
|
oscGain.gain.setValueAtTime(0.7, now);
|
||||||
|
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
|
||||||
|
osc.connect(oscGain);
|
||||||
|
oscGain.connect(masterGain);
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.2);
|
||||||
|
|
||||||
|
// 2. High-crunch noise burst (crush / crumple texture)
|
||||||
|
try {
|
||||||
|
const noiseBuffer = createNoiseBuffer(ctx, 0.22);
|
||||||
|
const noiseSource = ctx.createBufferSource();
|
||||||
|
noiseSource.buffer = noiseBuffer;
|
||||||
|
|
||||||
|
const noiseFilter = ctx.createBiquadFilter();
|
||||||
|
noiseFilter.type = "bandpass";
|
||||||
|
noiseFilter.frequency.setValueAtTime(1400, now);
|
||||||
|
noiseFilter.frequency.exponentialRampToValueAtTime(320, now + 0.2);
|
||||||
|
noiseFilter.Q.setValueAtTime(2.2, now);
|
||||||
|
|
||||||
|
const noiseGain = ctx.createGain();
|
||||||
|
noiseGain.gain.setValueAtTime(0.9, now);
|
||||||
|
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
|
||||||
|
|
||||||
|
noiseSource.connect(noiseFilter);
|
||||||
|
noiseFilter.connect(noiseGain);
|
||||||
|
noiseGain.connect(masterGain);
|
||||||
|
|
||||||
|
noiseSource.start(now);
|
||||||
|
noiseSource.stop(now + 0.22);
|
||||||
|
} catch {
|
||||||
|
// The noise layer is optional; retain the oscillator when buffers are constrained.
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Sound feedback must never interrupt deleting board items.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procedural select / pop sound.
|
||||||
|
*/
|
||||||
|
export const playPopSound = (volume = 0.25) => {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.setValueAtTime(460, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(780, now + 0.05);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(volume, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.07);
|
||||||
|
} catch {
|
||||||
|
// Sound feedback must never interrupt selecting board items.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,6 +11,19 @@
|
|||||||
border-color: var(--border-color) !important;
|
border-color: var(--border-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settingsButton {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-type="item"]:hover) .settingsButton,
|
||||||
|
:global([data-type="item"]:focus-within) .settingsButton,
|
||||||
|
.settingsButton[data-menu-open="true"] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.editModeInertContent {
|
.editModeInertContent {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
@@ -39,3 +52,9 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
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 { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
|
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||||
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
||||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||||
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
|
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
|
||||||
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
|
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
|
||||||
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
||||||
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
||||||
|
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
|
||||||
import classes from "../sections/item.module.css";
|
import classes from "../sections/item.module.css";
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
@@ -103,9 +105,11 @@ const WidgetDefinitionLoadError = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
const { ref, width: measuredWidth, height: measuredHeight } = useElementSize<HTMLDivElement>();
|
||||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
const { getEntryRuntime } = useBoardGridPortalHost();
|
||||||
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
|
const { widgetStateRef, widgetRuntimeRef } = getEntryRuntime(item.id, createBoardItemRuntime);
|
||||||
|
const width = measuredWidth || getLogicalTrackSize(item.width);
|
||||||
|
const height = measuredHeight || getLogicalTrackSize(item.height);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<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 {
|
interface LoadedBoardItemContentProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
width: number;
|
width: number;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-ite
|
|||||||
import { useSectionContext } from "../sections/section-context";
|
import { useSectionContext } from "../sections/section-context";
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||||
|
import itemContentClasses from "./item-content.module.css";
|
||||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||||
|
|
||||||
interface BoardItemMenuProps {
|
interface BoardItemMenuProps {
|
||||||
@@ -105,9 +106,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
|||||||
size={24}
|
size={24}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
top={12}
|
top={4}
|
||||||
right={8}
|
right={4}
|
||||||
style={{ zIndex: 30 }}
|
style={{ zIndex: 30 }}
|
||||||
|
className={itemContentClasses.settingsButton}
|
||||||
|
data-menu-open={isMenuOpen || undefined}
|
||||||
data-board-widget-settings
|
data-board-widget-settings
|
||||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getWidgetConnectionStatus,
|
getWidgetConnectionStatus,
|
||||||
resolveMatchingIntegrationsAsync,
|
resolveMatchingIntegrationsAsync,
|
||||||
@@ -8,95 +10,254 @@ import {
|
|||||||
} from "./item-select-data";
|
} from "./item-select-data";
|
||||||
|
|
||||||
describe("getWidgetConnectionStatus", () => {
|
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", () => {
|
describe("ready status", () => {
|
||||||
expect(
|
it("returns 'ready' when at least one supported integration is in availableKinds", () => {
|
||||||
getWidgetConnectionStatus({
|
expect(
|
||||||
supportedIntegrations: ["sonarr", "radarr"],
|
getWidgetConnectionStatus({
|
||||||
availableKinds,
|
supportedIntegrations: ["sonarr", "radarr"],
|
||||||
connectionOptional: false,
|
availableKinds,
|
||||||
}),
|
connectionOptional: false,
|
||||||
).toBe("ready");
|
}),
|
||||||
|
).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'ready' when all supported integrations are in availableKinds", () => {
|
||||||
|
expect(
|
||||||
|
getWidgetConnectionStatus({
|
||||||
|
supportedIntegrations: ["sonarr", "plex"],
|
||||||
|
availableKinds,
|
||||||
|
connectionOptional: false,
|
||||||
|
}),
|
||||||
|
).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'ready' even if connectionOptional is true as long as a matching integration exists", () => {
|
||||||
|
expect(
|
||||||
|
getWidgetConnectionStatus({
|
||||||
|
supportedIntegrations: ["sonarr"],
|
||||||
|
availableKinds,
|
||||||
|
connectionOptional: true,
|
||||||
|
}),
|
||||||
|
).toBe("ready");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports required integrations that are not configured as needing setup", () => {
|
describe("needsSetup status", () => {
|
||||||
expect(
|
it("returns 'needsSetup' when supported integrations are non-empty, none are available, and connectionOptional is false", () => {
|
||||||
getWidgetConnectionStatus({
|
expect(
|
||||||
supportedIntegrations: ["radarr"],
|
getWidgetConnectionStatus({
|
||||||
availableKinds,
|
supportedIntegrations: ["radarr", "sabNzbd"],
|
||||||
connectionOptional: false,
|
availableKinds,
|
||||||
}),
|
connectionOptional: false,
|
||||||
).toBe("needsSetup");
|
}),
|
||||||
|
).toBe("needsSetup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'needsSetup' when availableKinds is empty and connection is required", () => {
|
||||||
|
expect(
|
||||||
|
getWidgetConnectionStatus({
|
||||||
|
supportedIntegrations: ["sonarr"],
|
||||||
|
availableKinds: new Set<IntegrationKind>(),
|
||||||
|
connectionOptional: false,
|
||||||
|
}),
|
||||||
|
).toBe("needsSetup");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
describe("noConnectionRequired status", () => {
|
||||||
{ supportedIntegrations: [], connectionOptional: false },
|
it("returns 'noConnectionRequired' when supportedIntegrations is empty regardless of connectionOptional", () => {
|
||||||
{ supportedIntegrations: ["radarr" as const], connectionOptional: true },
|
expect(
|
||||||
])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => {
|
getWidgetConnectionStatus({
|
||||||
expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe(
|
supportedIntegrations: [],
|
||||||
"noConnectionRequired",
|
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", () => {
|
describe("selection concurrency locks", () => {
|
||||||
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
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 };
|
const lock = { current: false };
|
||||||
|
|
||||||
expect(tryLockSelection(lock)).toBe(true);
|
expect(tryLockSelection(lock)).toBe(true);
|
||||||
expect(tryLockSelection(lock)).toBe(false);
|
expect(tryLockSelection(lock)).toBe(false);
|
||||||
|
|
||||||
unlockSelection(lock);
|
unlockSelection(lock);
|
||||||
|
expect(lock.current).toBe(false);
|
||||||
|
|
||||||
expect(tryLockSelection(lock)).toBe(true);
|
expect(tryLockSelection(lock)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles repeated unlock safely without throwing", () => {
|
||||||
|
const lock = { current: false };
|
||||||
|
expect(() => {
|
||||||
|
unlockSelection(lock);
|
||||||
|
unlockSelection(lock);
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(lock.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents multiple concurrent callers from proceeding simultaneously", () => {
|
||||||
|
const lock = { current: false };
|
||||||
|
const results: boolean[] = [];
|
||||||
|
|
||||||
|
// Simulate 5 simultaneous selection attempts
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
results.push(tryLockSelection(lock));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(results).toEqual([true, false, false, false, false]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveMatchingIntegrationsAsync", () => {
|
describe("resolveMatchingIntegrationsAsync", () => {
|
||||||
it("waits for the shared integration query before a cold integration-backed selection", async () => {
|
it("returns empty array immediately when widget does not support integrations without calling ensureDataAsync", async () => {
|
||||||
const ensureDataAsync = vi.fn(async () => [
|
const ensureDataAsync = vi.fn(async () => [{ id: "1", kind: "sonarr" as IntegrationKind }]);
|
||||||
{ id: "matching", kind: "sonarr" as const },
|
|
||||||
{ id: "other", kind: "radarr" as const },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(
|
const result = await resolveMatchingIntegrationsAsync({
|
||||||
resolveMatchingIntegrationsAsync({
|
hasIntegrationSupport: false,
|
||||||
hasIntegrationSupport: true,
|
supportedIntegrations: ["sonarr"],
|
||||||
supportedIntegrations: ["sonarr"],
|
currentData: undefined,
|
||||||
currentData: undefined,
|
ensureDataAsync,
|
||||||
ensureDataAsync,
|
});
|
||||||
}),
|
|
||||||
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
|
|
||||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not wait for integrations when the widget has no integration support", async () => {
|
expect(result).toEqual([]);
|
||||||
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
resolveMatchingIntegrationsAsync({
|
|
||||||
hasIntegrationSupport: false,
|
|
||||||
supportedIntegrations: [],
|
|
||||||
currentData: undefined,
|
|
||||||
ensureDataAsync,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual([]);
|
|
||||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
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 = [
|
const currentData = [
|
||||||
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
|
{ id: "1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||||
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
|
{ id: "2", kind: "radarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||||
|
{ id: "3", kind: "plex" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||||
];
|
];
|
||||||
|
const ensureDataAsync = vi.fn();
|
||||||
|
|
||||||
|
const result = await resolveMatchingIntegrationsAsync({
|
||||||
|
hasIntegrationSupport: true,
|
||||||
|
supportedIntegrations: ["sonarr", "radarr"],
|
||||||
|
currentData,
|
||||||
|
ensureDataAsync,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([currentData[0], currentData[1]]);
|
||||||
|
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls ensureDataAsync when currentData is undefined and filters by supported kinds", async () => {
|
||||||
|
const fetchedData = [
|
||||||
|
{ id: "1", kind: "sonarr" as IntegrationKind },
|
||||||
|
{ id: "2", kind: "radarr" as IntegrationKind },
|
||||||
|
{ id: "3", kind: "sabNzbd" as IntegrationKind },
|
||||||
|
];
|
||||||
|
const ensureDataAsync = vi.fn(async () => fetchedData);
|
||||||
|
|
||||||
|
const result = await resolveMatchingIntegrationsAsync({
|
||||||
|
hasIntegrationSupport: true,
|
||||||
|
supportedIntegrations: ["radarr", "sabNzbd"],
|
||||||
|
currentData: undefined,
|
||||||
|
ensureDataAsync,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([fetchedData[1], fetchedData[2]]);
|
||||||
|
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes integrations where permissions.hasUseAccess is explicitly false", async () => {
|
||||||
|
const currentData = [
|
||||||
|
{ id: "allowed-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||||
|
{ id: "blocked-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: false } },
|
||||||
|
{ id: "allowed-no-perms", kind: "sonarr" as IntegrationKind },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await resolveMatchingIntegrationsAsync({
|
||||||
|
hasIntegrationSupport: true,
|
||||||
|
supportedIntegrations: ["sonarr"],
|
||||||
|
currentData,
|
||||||
|
ensureDataAsync: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([currentData[0], currentData[2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when none of the fetched integrations match supported kinds", async () => {
|
||||||
|
const fetchedData = [
|
||||||
|
{ id: "1", kind: "plex" as IntegrationKind },
|
||||||
|
{ id: "2", kind: "jellyfin" as IntegrationKind },
|
||||||
|
];
|
||||||
|
const ensureDataAsync = vi.fn(async () => fetchedData);
|
||||||
|
|
||||||
|
const result = await resolveMatchingIntegrationsAsync({
|
||||||
|
hasIntegrationSupport: true,
|
||||||
|
supportedIntegrations: ["sonarr", "radarr"],
|
||||||
|
currentData: undefined,
|
||||||
|
ensureDataAsync,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates error when ensureDataAsync rejects", async () => {
|
||||||
|
const ensureDataAsync = vi.fn(async () => {
|
||||||
|
throw new Error("Network timeout");
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
resolveMatchingIntegrationsAsync({
|
resolveMatchingIntegrationsAsync({
|
||||||
hasIntegrationSupport: true,
|
hasIntegrationSupport: true,
|
||||||
supportedIntegrations: ["sonarr"],
|
supportedIntegrations: ["sonarr"],
|
||||||
currentData,
|
currentData: undefined,
|
||||||
ensureDataAsync: vi.fn(),
|
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 {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -6,16 +6,18 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Divider,
|
Divider,
|
||||||
Flex,
|
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
|
Input,
|
||||||
Select,
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
import { IconApi, IconBuildingStore, IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -23,8 +25,8 @@ import { useSession } from "@homarr/auth/client";
|
|||||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import {
|
import {
|
||||||
getIconUrl,
|
|
||||||
getBoardLaneColumnCount,
|
getBoardLaneColumnCount,
|
||||||
|
getIconUrl,
|
||||||
getIntegrationName,
|
getIntegrationName,
|
||||||
getRootSectionLane,
|
getRootSectionLane,
|
||||||
widgetIntegrationSupport,
|
widgetIntegrationSupport,
|
||||||
@@ -36,14 +38,14 @@ import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
|||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
import { SelectableCard } from "@homarr/ui";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
|
||||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
|
||||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
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 { useItemActions } from "./item-actions";
|
||||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +66,16 @@ interface ItemSelectModalContentProps {
|
|||||||
initialSearch?: string;
|
initialSearch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WidgetItemData {
|
||||||
|
kind: WidgetKind;
|
||||||
|
supportedIntegrations: IntegrationKind[];
|
||||||
|
icon: TablerIcon;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
||||||
|
|
||||||
const ItemSelectModalContent = ({
|
const ItemSelectModalContent = ({
|
||||||
actions,
|
actions,
|
||||||
integrationData,
|
integrationData,
|
||||||
@@ -108,15 +120,14 @@ const ItemSelectModalContent = ({
|
|||||||
[board.sections, currentLayout, mainCanvasSection, t],
|
[board.sections, currentLayout, mainCanvasSection, t],
|
||||||
);
|
);
|
||||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
|
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
|
||||||
|
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
|
||||||
|
? targetSectionId
|
||||||
|
: (placementOptions[0]?.value ?? null);
|
||||||
|
|
||||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||||
const selectionLock = useRef(false);
|
const selectionLock = useRef(false);
|
||||||
const { createItem, removeItem } = useItemActions();
|
const { createItem, removeItem } = useItemActions();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
|
|
||||||
setTargetSectionId(placementOptions[0]?.value ?? null);
|
|
||||||
}, [placementOptions, targetSectionId]);
|
|
||||||
|
|
||||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
@@ -128,7 +139,7 @@ const ItemSelectModalContent = ({
|
|||||||
[integrationData],
|
[integrationData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo<WidgetItemData[]>(
|
||||||
() =>
|
() =>
|
||||||
widgetKinds
|
widgetKinds
|
||||||
.filter((kind) => kind !== "customApi")
|
.filter((kind) => kind !== "customApi")
|
||||||
@@ -188,7 +199,7 @@ const ItemSelectModalContent = ({
|
|||||||
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
|
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
|
||||||
createdItem.layouts[0]?.sectionId;
|
createdItem.layouts[0]?.sectionId;
|
||||||
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
|
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
|
||||||
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
|
const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination;
|
||||||
const notificationId = `board-item-created:${itemId}`;
|
const notificationId = `board-item-created:${itemId}`;
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
id: notificationId,
|
id: notificationId,
|
||||||
@@ -247,7 +258,7 @@ const ItemSelectModalContent = ({
|
|||||||
kind: "customApi",
|
kind: "customApi",
|
||||||
integrationIds: [],
|
integrationIds: [],
|
||||||
options: configuredOptions,
|
options: configuredOptions,
|
||||||
targetSectionId: targetSectionId ?? undefined,
|
targetSectionId: effectiveSectionId ?? undefined,
|
||||||
advancedOptions,
|
advancedOptions,
|
||||||
});
|
});
|
||||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||||
@@ -310,7 +321,7 @@ const ItemSelectModalContent = ({
|
|||||||
kind,
|
kind,
|
||||||
options,
|
options,
|
||||||
integrationIds: newIntegrationIds,
|
integrationIds: newIntegrationIds,
|
||||||
targetSectionId: targetSectionId ?? undefined,
|
targetSectionId: effectiveSectionId ?? undefined,
|
||||||
advancedOptions,
|
advancedOptions,
|
||||||
});
|
});
|
||||||
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
|
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
|
||||||
@@ -377,125 +388,233 @@ const ItemSelectModalContent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex gap="md" align="stretch">
|
<Stack gap="md">
|
||||||
<Box style={{ flex: "1 1 0%", minWidth: 0 }}>
|
{/* Top Search & Destination Selector */}
|
||||||
<SelectGridLayout
|
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
|
||||||
search={search}
|
<Box style={{ flex: "1 1 240px", minWidth: 200 }}>
|
||||||
onSearchChange={setSearch}
|
<Input
|
||||||
placeholder={`${t("item.create.search")}...`}
|
value={search}
|
||||||
ariaLabel={t("item.create.search")}
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
onSearchKeyDown={(event) => {
|
leftSection={<IconSearch size={16} />}
|
||||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
placeholder={`${t("item.create.search")}...`}
|
||||||
const [item] = filteredItems;
|
aria-label={t("item.create.search")}
|
||||||
if (item) void handleAdd(item.kind);
|
data-autofocus
|
||||||
}
|
onKeyDown={(event) => {
|
||||||
}}
|
if (
|
||||||
>
|
event.key === "Enter" &&
|
||||||
{placementOptions.length > 1 && (
|
loadingSelection === null &&
|
||||||
<Box style={{ gridColumn: "1 / -1" }}>
|
filteredItems.length === 1 &&
|
||||||
<Select
|
filteredItems[0]
|
||||||
label={t("item.create.destination.label")}
|
) {
|
||||||
description={t("item.create.destination.description")}
|
void handleAdd(filteredItems[0].kind);
|
||||||
data={placementOptions}
|
}
|
||||||
value={targetSectionId}
|
}}
|
||||||
onChange={setTargetSectionId}
|
/>
|
||||||
allowDeselect={false}
|
</Box>
|
||||||
/>
|
|
||||||
</Box>
|
{placementOptions.length > 1 && (
|
||||||
)}
|
<Box style={{ flex: "0 1 280px", minWidth: 200 }}>
|
||||||
{filteredItems.map((item) => (
|
<Select
|
||||||
<WidgetItem
|
label={t("item.create.destination.label")}
|
||||||
key={item.kind}
|
data={placementOptions}
|
||||||
item={item}
|
value={effectiveSectionId}
|
||||||
onSelect={() => void handleAdd(item.kind)}
|
onChange={setTargetSectionId}
|
||||||
onFocus={() => {
|
allowDeselect={false}
|
||||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
size="xs"
|
||||||
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),
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
))}
|
</Box>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
{isAdmin && (
|
{/* Grid of Widgets */}
|
||||||
<>
|
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||||
<Divider
|
{filteredItems.map((item) => (
|
||||||
label={t("customWidget.page.list.title")}
|
<WidgetItem
|
||||||
labelPosition="center"
|
key={item.kind}
|
||||||
my="sm"
|
item={item}
|
||||||
style={{ gridColumn: "1 / -1" }}
|
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
|
<Box mt="xs" style={{ gridColumn: "1 / -1" }}>
|
||||||
key={def.id}
|
<Button
|
||||||
height={selectGridCardHeight}
|
component="a"
|
||||||
label={def.name}
|
href="/manage/custom-widgets/workshop"
|
||||||
status={t("item.create.connectionStatus.noConnectionRequired")}
|
target="_blank"
|
||||||
disabled={loadingSelection !== null}
|
rel="noopener noreferrer"
|
||||||
busy={loadingSelection === `custom:${def.id}`}
|
variant="default"
|
||||||
onSelect={() => void handleAddCustomWidget(def)}
|
fullWidth
|
||||||
onFocus={() => {
|
leftSection={<IconBuildingStore size={16} />}
|
||||||
void loadWidgetDefinition("customApi").catch(() => undefined);
|
>
|
||||||
}}
|
{t("workshop.browseWorkshop")}
|
||||||
onPointerEnter={() => {
|
</Button>
|
||||||
void loadWidgetDefinition("customApi").catch(() => undefined);
|
</Box>
|
||||||
}}
|
</>
|
||||||
>
|
)}
|
||||||
<Stack h="100%" gap="xs">
|
</SimpleGrid>
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
|
||||||
{def.iconUrl ? (
|
|
||||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
) : (
|
|
||||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
)}
|
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
|
||||||
{def.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
|
||||||
{def.description ?? ""}
|
|
||||||
</Text>
|
|
||||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
|
||||||
</Stack>
|
|
||||||
</CatalogItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Box style={{ gridColumn: "1 / -1" }}>
|
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||||
{/* Opens in a new tab so unsaved board changes survive the detour. */}
|
<Center p="xl">
|
||||||
<Button
|
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||||
component="a"
|
</Center>
|
||||||
href="/manage/custom-widgets/workshop"
|
)}
|
||||||
target="_blank"
|
</Stack>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 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 = ({
|
const ItemSelectModalFrame = ({
|
||||||
actions,
|
actions,
|
||||||
innerProps,
|
innerProps,
|
||||||
@@ -541,109 +660,3 @@ export const ItemSelectModal = createModal<{
|
|||||||
defaultTitle: (t) => t("item.create.title"),
|
defaultTitle: (t) => t("item.create.title"),
|
||||||
size: modalSizeSelect,
|
size: modalSizeSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
const WidgetItem = ({
|
|
||||||
item,
|
|
||||||
onSelect,
|
|
||||||
onFocus,
|
|
||||||
onPointerEnter,
|
|
||||||
disabled,
|
|
||||||
loading,
|
|
||||||
connectionStatus,
|
|
||||||
}: {
|
|
||||||
item: {
|
|
||||||
kind: WidgetKind;
|
|
||||||
supportedIntegrations: IntegrationKind[];
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
icon: TablerIcon;
|
|
||||||
};
|
|
||||||
onSelect: () => void;
|
|
||||||
onFocus: () => void;
|
|
||||||
onPointerEnter: () => void;
|
|
||||||
disabled: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
connectionStatus: WidgetConnectionStatus;
|
|
||||||
}) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CatalogItem
|
|
||||||
height={selectGridCardHeight}
|
|
||||||
label={item.name}
|
|
||||||
status={statusLabel}
|
|
||||||
onFocus={onFocus}
|
|
||||||
onPointerEnter={onPointerEnter}
|
|
||||||
onSelect={onSelect}
|
|
||||||
disabled={disabled}
|
|
||||||
busy={loading}
|
|
||||||
>
|
|
||||||
<Stack h="100%" gap="xs">
|
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
|
||||||
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
|
||||||
{item.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
|
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
|
||||||
{item.description}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
<ConnectionStatusBadge status={connectionStatus} />
|
|
||||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
|
||||||
</Stack>
|
|
||||||
</CatalogItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
|
|
||||||
{t(`item.create.connectionStatus.${status}`)}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
|
|
||||||
if (integrations.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const countToShow = integrations.length >= 8 ? 6 : 7;
|
|
||||||
const moreCount = integrations.length - countToShow;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group gap={2}>
|
|
||||||
<Tooltip.Group closeDelay={100}>
|
|
||||||
<Group gap={2}>
|
|
||||||
{integrations.slice(0, countToShow).map((integration) => (
|
|
||||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
|
||||||
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
{moreCount > 0 && (
|
|
||||||
<Tooltip
|
|
||||||
withArrow
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
{integrations.slice(countToShow).map((integration) => (
|
|
||||||
<div key={integration}>{getIntegrationName(integration)}</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Avatar radius="xl" size="xs">
|
|
||||||
+{moreCount}
|
|
||||||
</Avatar>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Tooltip.Group>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, use } from "react";
|
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
||||||
import { Center, Loader } from "@mantine/core";
|
import { Center, Loader } from "@mantine/core";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import type { FallbackProps } from "react-error-boundary";
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
import { WidgetError } from "@homarr/widgets/errors";
|
||||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||||
|
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||||
|
|
||||||
|
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||||
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
||||||
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
||||||
|
|
||||||
@@ -80,13 +83,46 @@ interface LazyWidgetEditModalContentProps {
|
|||||||
|
|
||||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||||
|
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||||
const Component = WidgetEditModal.component;
|
const Component = WidgetEditModal.component;
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const [createdIntegrations, setCreatedIntegrations] = useState<IntegrationSelectOption[]>([]);
|
||||||
|
|
||||||
|
const supportedKinds = useMemo(() => {
|
||||||
|
if (!("supportedIntegrations" in props.innerProps.definition)) return [];
|
||||||
|
return (props.innerProps.definition.supportedIntegrations ?? []).filter((k) => k !== "mock");
|
||||||
|
}, [props.innerProps.definition]);
|
||||||
|
|
||||||
|
const handleOpenNewIntegration = useCallback(
|
||||||
|
(onCreated?: (id: string) => void) => {
|
||||||
|
openIntegrationModal({
|
||||||
|
allowedKinds: supportedKinds,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result?.integration) {
|
||||||
|
setCreatedIntegrations((prev) => [...prev, result.integration]);
|
||||||
|
void utils.integration.all.invalidate();
|
||||||
|
onCreated?.(result.integration.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[openIntegrationModal, supportedKinds, utils],
|
||||||
|
);
|
||||||
|
|
||||||
|
const combinedIntegrationData = useMemo(() => {
|
||||||
|
const existing = props.innerProps.integrationData;
|
||||||
|
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
||||||
|
return [...existing, ...additional];
|
||||||
|
}, [props.innerProps.integrationData, createdIntegrations]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
{...props}
|
{...props}
|
||||||
innerProps={{
|
innerProps={{
|
||||||
...props.innerProps,
|
...props.innerProps,
|
||||||
|
integrationData: combinedIntegrationData,
|
||||||
integrationEditForm: LazyIntegrationEditForm,
|
integrationEditForm: LazyIntegrationEditForm,
|
||||||
|
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -122,6 +158,5 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
|||||||
return t("item.edit.title");
|
return t("item.edit.title");
|
||||||
},
|
},
|
||||||
size: modalSizeForm,
|
size: modalSizeForm,
|
||||||
presentation: "inspector",
|
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
});
|
});
|
||||||
@@ -62,7 +62,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
p={0}
|
p={0}
|
||||||
>
|
>
|
||||||
{options.collapsible && !isEditMode && (
|
{options.collapsible && (
|
||||||
<Button
|
<Button
|
||||||
className={classes.containerToggle}
|
className={classes.containerToggle}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
@@ -71,7 +71,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
w="100%"
|
w="100%"
|
||||||
h={isVisuallyCollapsed ? "100%" : 24}
|
h={isVisuallyCollapsed ? "100%" : 24}
|
||||||
px={12}
|
px={12}
|
||||||
pe={options.showOpenAll ? 40 : 12}
|
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
justify={options.showLabel ? "flex-start" : "center"}
|
justify={options.showLabel ? "flex-start" : "center"}
|
||||||
@@ -101,7 +101,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
|
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
||||||
<Badge
|
<Badge
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
top={-24}
|
top={-24}
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import type { ComponentType, PropsWithChildren } from "react";
|
import type { ComponentType, PropsWithChildren } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
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";
|
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
|
||||||
|
|
||||||
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
||||||
|
type LoadedGridEditorModule = Awaited<ReturnType<typeof loadGridEditorAsync>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the dnd-kit runtime outside the read-only bundle while guaranteeing
|
* 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) => {
|
export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const [Provider, setProvider] = useState<BoardGridEditorProviderComponent | null>(null);
|
const [EditorModule, setEditorModule] = useState<LoadedGridEditorModule | null>(
|
||||||
|
getLoadedGridEditorModule() ?? null,
|
||||||
|
);
|
||||||
const [hasLoadError, setHasLoadError] = useState(false);
|
const [hasLoadError, setHasLoadError] = useState(false);
|
||||||
|
const resolvedEditorModule = EditorModule ?? getLoadedGridEditorModule() ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditMode || Provider) return;
|
if (!isEditMode || resolvedEditorModule) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setHasLoadError(false);
|
setHasLoadError(false);
|
||||||
void loadGridEditorAsync()
|
void loadGridEditorAsync()
|
||||||
.then((module) => {
|
.then((module) => {
|
||||||
if (!cancelled) setProvider(() => module.BoardGridEditorProvider);
|
if (!cancelled) setEditorModule(module);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setHasLoadError(true);
|
if (!cancelled) setHasLoadError(true);
|
||||||
@@ -35,23 +41,34 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [Provider, isEditMode]);
|
}, [isEditMode, resolvedEditorModule]);
|
||||||
|
|
||||||
if (!isEditMode) {
|
const runtimeStatus = !isEditMode
|
||||||
return <GridEditorRuntimeProvider status="idle">{children}</GridEditorRuntimeProvider>;
|
? "idle"
|
||||||
}
|
: hasLoadError
|
||||||
|
? "error"
|
||||||
if (hasLoadError) {
|
: resolvedEditorModule
|
||||||
return <GridEditorRuntimeProvider status="error">{children}</GridEditorRuntimeProvider>;
|
? "ready"
|
||||||
}
|
: "loading";
|
||||||
|
const Provider: BoardGridEditorProviderComponent | null = resolvedEditorModule?.BoardGridEditorProvider ?? null;
|
||||||
if (!Provider) {
|
const GridEditor = resolvedEditorModule?.default ?? null;
|
||||||
return <GridEditorRuntimeProvider status="loading">{children}</GridEditorRuntimeProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridEditorRuntimeProvider status="ready">
|
<GridEditorRegistryProvider>
|
||||||
<Provider>{children}</Provider>
|
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
|
||||||
</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";
|
"use client";
|
||||||
|
|
||||||
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
|
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 { Box } from "@mantine/core";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "~/components/board/layout";
|
} from "~/components/board/layout";
|
||||||
import type { GridPlacement } from "~/components/board/layout";
|
import type { GridPlacement } from "~/components/board/layout";
|
||||||
import { useSectionContext } from "../section-context";
|
import { useSectionContext } from "../section-context";
|
||||||
|
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
|
||||||
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
|
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
|
||||||
import type { GridTransaction } from "./dnd";
|
import type { GridTransaction } from "./dnd";
|
||||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||||
@@ -33,7 +34,7 @@ interface FixedGridItemProps {
|
|||||||
/**
|
/**
|
||||||
* Static SSR geometry in view mode and the stable content/keyboard handle in
|
* 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
|
* 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 = ({
|
export const FixedGridItem = ({
|
||||||
item,
|
item,
|
||||||
@@ -45,19 +46,33 @@ export const FixedGridItem = ({
|
|||||||
const runtimeStatus = useGridEditorRuntimeStatus();
|
const runtimeStatus = useGridEditorRuntimeStatus();
|
||||||
const currentLayoutId = useCurrentLayout();
|
const currentLayoutId = useCurrentLayout();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
|
const { section, columnCount, maxRowCount, placements, interactionDisabled, announce, entryElementStore } =
|
||||||
|
useSectionContext();
|
||||||
const { commitSectionGrid } = useGridLayoutActions();
|
const { commitSectionGrid } = useGridLayoutActions();
|
||||||
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
|
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
|
||||||
|
const { isSelected, toggleSelectItem } = useBoardSelection();
|
||||||
|
const selected = item.type === "item" && isSelected(item.id);
|
||||||
|
const 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 instructionsId = useId();
|
||||||
const isEditorActive = isEditMode && runtimeStatus === "ready";
|
const isEditorActive = isEditMode && runtimeStatus === "ready" && !interactionDisabled;
|
||||||
const placement = toPlacement(item);
|
const placement = toPlacement(item);
|
||||||
const renderedPlacements = [...items, ...innerSections].map(toPlacement);
|
|
||||||
const keyboardGridRef = useRef<{
|
const keyboardGridRef = useRef<{
|
||||||
source: readonly SectionGridPlacement[];
|
source: readonly SectionGridPlacement[];
|
||||||
current: readonly SectionGridPlacement[];
|
current: readonly SectionGridPlacement[];
|
||||||
}>({
|
}>({
|
||||||
source: renderedPlacements,
|
source: placements,
|
||||||
current: renderedPlacements,
|
current: placements,
|
||||||
});
|
});
|
||||||
const displayName =
|
const displayName =
|
||||||
item.type === "item"
|
item.type === "item"
|
||||||
@@ -100,14 +115,14 @@ export const FixedGridItem = ({
|
|||||||
setKeyboardEditing(false);
|
setKeyboardEditing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!arePlacementListsEqual(keyboardGridRef.current.source, renderedPlacements)) {
|
if (!arePlacementListsEqual(keyboardGridRef.current.source, placements)) {
|
||||||
keyboardGridRef.current = {
|
keyboardGridRef.current = {
|
||||||
source: renderedPlacements,
|
source: placements,
|
||||||
current: renderedPlacements,
|
current: placements,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const placements = keyboardGridRef.current.current;
|
const currentPlacements = keyboardGridRef.current.current;
|
||||||
const current = placements.find((candidate) => candidate.id === item.id);
|
const current = currentPlacements.find((candidate) => candidate.id === item.id);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
|
|
||||||
let transaction: GridTransaction<SectionGridPlacement>;
|
let transaction: GridTransaction<SectionGridPlacement>;
|
||||||
@@ -119,7 +134,7 @@ export const FixedGridItem = ({
|
|||||||
id: section.id,
|
id: section.id,
|
||||||
columnCount,
|
columnCount,
|
||||||
maxRowCount,
|
maxRowCount,
|
||||||
placements,
|
placements: currentPlacements,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -127,8 +142,8 @@ export const FixedGridItem = ({
|
|||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
keyboardGridRef.current = {
|
keyboardGridRef.current = {
|
||||||
source: renderedPlacements,
|
source: currentPlacements,
|
||||||
current: renderedPlacements,
|
current: currentPlacements,
|
||||||
};
|
};
|
||||||
setKeyboardEditing(false);
|
setKeyboardEditing(false);
|
||||||
return;
|
return;
|
||||||
@@ -177,16 +192,28 @@ export const FixedGridItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
ref={registerElement}
|
||||||
{...semantics}
|
{...semantics}
|
||||||
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
|
onClickCapture={handleClickCapture}
|
||||||
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
|
data-board-item-selected={selected ? "true" : undefined}
|
||||||
data-grid-item-id={isEditorActive ? undefined : item.id}
|
className={combineClasses(
|
||||||
data-grid-item-type={isEditorActive ? undefined : item.type}
|
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-editor-grid-entry={isEditorActive ? "true" : undefined}
|
||||||
data-type={item.type}
|
data-type={item.type}
|
||||||
data-kind={item.kind}
|
data-kind={item.kind}
|
||||||
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
|
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
|
||||||
aria-describedby={isEditorActive ? instructionsId : undefined}
|
aria-describedby={isEditorActive ? instructionsId : undefined}
|
||||||
|
aria-selected={isEditorActive && item.type === "item" ? selected : undefined}
|
||||||
aria-keyshortcuts={
|
aria-keyshortcuts={
|
||||||
isEditorActive
|
isEditorActive
|
||||||
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
|
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
|
||||||
@@ -195,7 +222,7 @@ export const FixedGridItem = ({
|
|||||||
onBlur={isEditorActive ? handleBlur : undefined}
|
onBlur={isEditorActive ? handleBlur : undefined}
|
||||||
onKeyDown={isEditorActive ? handleKeyboard : undefined}
|
onKeyDown={isEditorActive ? handleKeyboard : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
<div className={combineClasses("board-grid-content-mount", classes.contentMount)}>{children}</div>
|
||||||
{isEditorActive && (
|
{isEditorActive && (
|
||||||
<span id={instructionsId} className={classes.liveRegion}>
|
<span id={instructionsId} className={classes.liveRegion}>
|
||||||
{t("item.moveResize.keyboard.instructions")}
|
{t("item.moveResize.keyboard.instructions")}
|
||||||
|
|||||||
@@ -3,16 +3,24 @@
|
|||||||
import type * as GridEditorModule from "./grid-editor";
|
import type * as GridEditorModule from "./grid-editor";
|
||||||
|
|
||||||
let editorModulePromise: Promise<typeof GridEditorModule> | undefined;
|
let editorModulePromise: Promise<typeof GridEditorModule> | undefined;
|
||||||
|
let editorModule: typeof GridEditorModule | undefined;
|
||||||
|
|
||||||
export const loadGridEditorAsync = () => {
|
export const loadGridEditorAsync = () => {
|
||||||
editorModulePromise ??= import("./grid-editor").catch((error: unknown) => {
|
editorModulePromise ??= import("./grid-editor")
|
||||||
editorModulePromise = undefined;
|
.then((loadedModule) => {
|
||||||
throw error;
|
editorModule = loadedModule;
|
||||||
});
|
return loadedModule;
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
editorModulePromise = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
return editorModulePromise;
|
return editorModulePromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLoadedGridEditorModule = () => editorModule;
|
||||||
|
|
||||||
export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => {
|
export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => {
|
||||||
let animationFrameId: number | undefined;
|
let animationFrameId: number | undefined;
|
||||||
let idleCallbackId: 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;
|
inset: 0 auto auto 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-entry {
|
.board-grid-entry {
|
||||||
@@ -22,6 +23,13 @@
|
|||||||
will-change: translate;
|
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-active="true"],
|
||||||
.board-grid-entry[data-dnd-drag-source="true"] {
|
.board-grid-entry[data-dnd-drag-source="true"] {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -32,6 +40,27 @@
|
|||||||
opacity: 0.42;
|
opacity: 0.42;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-grid-entry[data-board-item-selected="true"] {
|
||||||
|
z-index: 15;
|
||||||
|
opacity: 0.56;
|
||||||
|
filter: saturate(0.72) brightness(0.82);
|
||||||
|
transition:
|
||||||
|
opacity 140ms ease,
|
||||||
|
filter 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-grid-entry[data-board-item-selected="true"]::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
content: "";
|
||||||
|
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
||||||
|
border: 0.125rem dashed var(--mantine-primary-color-filled);
|
||||||
|
border-radius: var(--board-item-radius, var(--mantine-radius-md));
|
||||||
|
box-shadow: inset 0 0 1.125rem color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.board-grid-drag-overlay[data-dnd-dragging] {
|
.board-grid-drag-overlay[data-dnd-dragging] {
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
@@ -80,24 +109,12 @@
|
|||||||
zoom: var(--board-grid-overlay-content-zoom, 1);
|
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 {
|
body[data-board-grid-interacting] .board-grid-placeholder {
|
||||||
transition: none;
|
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 {
|
body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount {
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
}
|
}
|
||||||
@@ -177,138 +194,115 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
|
|
||||||
.board-grid-resize-handle {
|
.board-grid-resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 20;
|
z-index: 25;
|
||||||
display: block;
|
display: grid;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: transparent;
|
background: var(--mantine-color-body);
|
||||||
border: 0;
|
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;
|
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;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
.board-grid-entry:has(> .board-grid-resize-handle:hover) > .board-grid-resize-handle,
|
||||||
opacity: 0;
|
.board-grid-entry:has(> .board-grid-resize-handle:active) > .board-grid-resize-handle {
|
||||||
}
|
border-color: var(--mantine-color-dimmed);
|
||||||
|
|
||||||
.board-grid-resize-handle::after {
|
|
||||||
position: absolute;
|
|
||||||
inset: 50% auto auto 50%;
|
|
||||||
width: 12px;
|
|
||||||
height: 3px;
|
|
||||||
content: "";
|
|
||||||
background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
|
|
||||||
border-radius: var(--mantine-radius-xl);
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
transition: opacity 100ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board-grid-entry:hover > .board-grid-resize-handle::after,
|
|
||||||
.board-grid-resize-handle:focus-visible::after {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
scale: 1;
|
||||||
|
|
||||||
.board-grid-resize-handle:focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
|
||||||
outline-offset: -8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="n"],
|
.board-grid-resize-handle[data-grid-resize-handle="n"],
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||||
right: 44px;
|
left: 50%;
|
||||||
left: 44px;
|
width: 1.75rem;
|
||||||
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
height: 1rem;
|
||||||
|
border-radius: var(--mantine-radius-xl);
|
||||||
|
translate: -50% 0;
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||||
top: 0;
|
top: -0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
.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="e"],
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||||
top: 44px;
|
top: 50%;
|
||||||
bottom: 44px;
|
width: 1rem;
|
||||||
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
height: 1.75rem;
|
||||||
|
border-radius: var(--mantine-radius-xl);
|
||||||
|
translate: 0 -50%;
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||||
right: 0;
|
right: -0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||||
left: 0;
|
left: -0.25rem;
|
||||||
}
|
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
|
|
||||||
width: 3px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="se"],
|
.board-grid-resize-handle[data-grid-resize-handle="se"],
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="sw"],
|
.board-grid-resize-handle[data-grid-resize-handle="sw"],
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||||
z-index: 9;
|
width: 1.875rem;
|
||||||
width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
height: 1.875rem;
|
||||||
height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
border-radius: var(--board-item-radius, var(--mantine-radius-md));
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||||
top: 0;
|
top: -0.625rem;
|
||||||
right: 0;
|
right: -0.625rem;
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||||
right: 0;
|
right: -0.625rem;
|
||||||
bottom: 0;
|
bottom: -0.625rem;
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||||
bottom: 0;
|
bottom: -0.625rem;
|
||||||
left: 0;
|
left: -0.625rem;
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||||
top: 0;
|
top: -0.625rem;
|
||||||
left: 0;
|
left: -0.625rem;
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
|
|
||||||
transform: translate(-50%, -50%) rotate(-45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
|
|
||||||
transform: translate(-50%, -50%) rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none), (pointer: coarse) {
|
|
||||||
.board-grid-resize-handle::after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.board-grid-entry,
|
.board-grid-entry,
|
||||||
.board-grid-placeholder,
|
.board-grid-placeholder,
|
||||||
.board-grid-resize-outline,
|
.board-grid-resize-outline,
|
||||||
.board-grid-resize-handle::after {
|
.board-grid-resize-handle {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
|
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
|
||||||
|
|
||||||
@@ -32,12 +33,12 @@ import type {
|
|||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
} from "@dnd-kit/react";
|
} from "@dnd-kit/react";
|
||||||
import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
|
import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
|
||||||
|
import { IconGripHorizontal, IconGripVertical } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
import { useSectionContext } from "../section-context";
|
|
||||||
import {
|
import {
|
||||||
beginGridTransaction,
|
beginGridTransaction,
|
||||||
cancelGridTransaction,
|
cancelGridTransaction,
|
||||||
@@ -56,6 +57,7 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
|
|||||||
import type { DndGridEntryProps } from "./grid-entry-memo";
|
import type { DndGridEntryProps } from "./grid-entry-memo";
|
||||||
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
|
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
|
||||||
import { getGridDepth } from "./grid-depth";
|
import { getGridDepth } from "./grid-depth";
|
||||||
|
import type { GridEditorRenderProps } from "./grid-editor-registry";
|
||||||
import { createGridInteractionStore } from "./grid-interaction-store";
|
import { createGridInteractionStore } from "./grid-interaction-store";
|
||||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||||
import type { GridInteraction } from "./grid-preview-layer";
|
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_ENTRY_TYPE = "homarr-board-grid-entry";
|
||||||
const GRID_TARGET_TYPE = "homarr-board-grid-target";
|
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 {
|
interface GridEntryData {
|
||||||
kind: typeof GRID_ENTRY_TYPE;
|
kind: typeof GRID_ENTRY_TYPE;
|
||||||
sectionId: string;
|
sectionId: string;
|
||||||
@@ -725,14 +718,22 @@ export default function GridEditor({
|
|||||||
rowCount,
|
rowCount,
|
||||||
maxRowCount,
|
maxRowCount,
|
||||||
placements,
|
placements,
|
||||||
|
transactionPlacements,
|
||||||
className,
|
className,
|
||||||
}: GridEditorProps) {
|
section,
|
||||||
|
items,
|
||||||
|
innerSections,
|
||||||
|
entryElementStore,
|
||||||
|
}: GridEditorRenderProps) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { section, items, innerSections } = useSectionContext();
|
|
||||||
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
|
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
|
||||||
const interactionStore = useBoardGridEditorInteractionStore();
|
const interactionStore = useBoardGridEditorInteractionStore();
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
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 parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
|
||||||
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
|
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
|
||||||
const depth = getDepth(sectionId);
|
const depth = getDepth(sectionId);
|
||||||
@@ -767,14 +768,6 @@ export default function GridEditor({
|
|||||||
},
|
},
|
||||||
[droppableRef],
|
[droppableRef],
|
||||||
);
|
);
|
||||||
const registerEntryElement = useCallback(
|
|
||||||
(id: string, element: HTMLElement | null) => {
|
|
||||||
if (element) entryElements.set(id, element);
|
|
||||||
else entryElements.delete(id);
|
|
||||||
},
|
|
||||||
[entryElements],
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const element = gridRef.current;
|
const element = gridRef.current;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
@@ -784,11 +777,11 @@ export default function GridEditor({
|
|||||||
maxRowCount,
|
maxRowCount,
|
||||||
parentGridId,
|
parentGridId,
|
||||||
ownerPlacementId,
|
ownerPlacementId,
|
||||||
placements,
|
placements: transactionPlacements,
|
||||||
depth,
|
depth,
|
||||||
element,
|
element,
|
||||||
});
|
});
|
||||||
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
|
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, registerGrid, sectionId, transactionPlacements]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -813,7 +806,7 @@ export default function GridEditor({
|
|||||||
label={labelById.get(placement.id) ?? placement.id}
|
label={labelById.get(placement.id) ?? placement.id}
|
||||||
columnCount={columnCount}
|
columnCount={columnCount}
|
||||||
maxRowCount={maxRowCount}
|
maxRowCount={maxRowCount}
|
||||||
registerElement={registerEntryElement}
|
element={entryElements.get(placement.id) ?? null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -842,21 +835,18 @@ const DndGridEntryComponent = ({
|
|||||||
label,
|
label,
|
||||||
columnCount,
|
columnCount,
|
||||||
maxRowCount,
|
maxRowCount,
|
||||||
registerElement,
|
element,
|
||||||
}: DndGridEntryProps) => {
|
}: DndGridEntryProps) => {
|
||||||
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
|
|
||||||
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
|
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
|
||||||
const mountRef = useRef<HTMLDivElement>(null);
|
|
||||||
const shellRef = useRef<HTMLDivElement>(null);
|
|
||||||
const placementRef = useRef(placement);
|
const placementRef = useRef(placement);
|
||||||
placementRef.current = placement;
|
placementRef.current = placement;
|
||||||
const resizeOutlineController = useMemo(
|
const resizeOutlineController = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createGridResizeOutlineController(
|
createGridResizeOutlineController(
|
||||||
() => shellRef.current,
|
() => element,
|
||||||
() => placementRef.current,
|
() => placementRef.current,
|
||||||
),
|
),
|
||||||
[],
|
[element],
|
||||||
);
|
);
|
||||||
const sourceData = useMemo<GridEntryData>(
|
const sourceData = useMemo<GridEntryData>(
|
||||||
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
|
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
|
||||||
@@ -877,61 +867,39 @@ const DndGridEntryComponent = ({
|
|||||||
const modifiers = useMemo(
|
const modifiers = useMemo(
|
||||||
() => [
|
() => [
|
||||||
RestrictToElement.configure({
|
RestrictToElement.configure({
|
||||||
element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null,
|
element: () => element?.closest('[data-testid="board-canvas"]') ?? null,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[],
|
[element],
|
||||||
);
|
);
|
||||||
const {
|
const { isDragSource, isDropping } = useDraggable<GridEntryData>({
|
||||||
ref: draggableRef,
|
|
||||||
handleRef,
|
|
||||||
isDragSource,
|
|
||||||
isDropping,
|
|
||||||
} = useDraggable<GridEntryData>({
|
|
||||||
id: placement.id,
|
id: placement.id,
|
||||||
type: GRID_ENTRY_TYPE,
|
type: GRID_ENTRY_TYPE,
|
||||||
data: sourceData,
|
data: sourceData,
|
||||||
|
element,
|
||||||
|
handle: element,
|
||||||
|
disabled: element === null,
|
||||||
sensors,
|
sensors,
|
||||||
modifiers,
|
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]);
|
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const mount = mountRef.current;
|
if (!element) return;
|
||||||
if (!mount) return;
|
if (isDragSource) element.dataset.dndDragSource = "true";
|
||||||
const container = acquireContainer(placement.id);
|
else delete element.dataset.dndDragSource;
|
||||||
mount.replaceChildren(container);
|
if (isDropping) element.dataset.dndDropping = "true";
|
||||||
|
else delete element.dataset.dndDropping;
|
||||||
return () => {
|
return () => {
|
||||||
if (container.parentElement === mount) container.remove();
|
delete element.dataset.dndDragSource;
|
||||||
|
delete element.dataset.dndDropping;
|
||||||
};
|
};
|
||||||
}, [acquireContainer, placement.id, structureRevision]);
|
}, [element, isDragSource, isDropping]);
|
||||||
|
|
||||||
return (
|
if (!element) return null;
|
||||||
<div
|
|
||||||
ref={setShellRef}
|
return createPortal(
|
||||||
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" />
|
|
||||||
<GridResizeHandles
|
<GridResizeHandles
|
||||||
sectionId={sectionId}
|
sectionId={sectionId}
|
||||||
placement={placement}
|
placement={placement}
|
||||||
@@ -944,7 +912,9 @@ const DndGridEntryComponent = ({
|
|||||||
cancelResize={cancelResize}
|
cancelResize={cancelResize}
|
||||||
scheduleResizeOutline={resizeOutlineController.schedule}
|
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);
|
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
|
||||||
};
|
};
|
||||||
|
|
||||||
return RESIZE_DIRECTIONS.map((direction) => (
|
return RESIZE_DIRECTIONS.map((direction) => {
|
||||||
<span
|
const isHorizontalSide = direction === "n" || direction === "s";
|
||||||
key={direction}
|
const isVerticalSide = direction === "e" || direction === "w";
|
||||||
className="board-grid-resize-handle"
|
const GripIcon = isHorizontalSide ? IconGripHorizontal : IconGripVertical;
|
||||||
data-grid-resize-handle={direction}
|
|
||||||
aria-hidden="true"
|
return (
|
||||||
onPointerDown={(event) => handlePointerDown(event, direction)}
|
<span
|
||||||
onPointerMove={handlePointerMove}
|
key={direction}
|
||||||
onPointerUp={(event) => handlePointerEnd(event, false)}
|
className="board-grid-resize-handle"
|
||||||
onPointerCancel={(event) => handlePointerEnd(event, true)}
|
data-grid-resize-handle={direction}
|
||||||
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
|
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 = (
|
const getProjectedDragShape = (
|
||||||
@@ -1239,6 +1217,7 @@ const getPreferredRegisteredGridTarget = (
|
|||||||
|
|
||||||
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
|
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
|
||||||
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
|
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
|
||||||
|
if (event.metaKey || event.ctrlKey) return true;
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
const activationElement = source.handle ?? source.element;
|
const activationElement = source.handle ?? source.element;
|
||||||
if (!(target instanceof Element) || target === activationElement) return false;
|
if (!(target instanceof Element) || target === activationElement) return false;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface DndGridEntryProps {
|
|||||||
label: string;
|
label: string;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
maxRowCount: number | null;
|
maxRowCount: number | null;
|
||||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
element: HTMLElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
|
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
|
||||||
@@ -14,7 +14,7 @@ export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: Dnd
|
|||||||
previous.label === next.label &&
|
previous.label === next.label &&
|
||||||
previous.columnCount === next.columnCount &&
|
previous.columnCount === next.columnCount &&
|
||||||
previous.maxRowCount === next.maxRowCount &&
|
previous.maxRowCount === next.maxRowCount &&
|
||||||
previous.registerElement === next.registerElement &&
|
previous.element === next.element &&
|
||||||
previous.placement.id === next.placement.id &&
|
previous.placement.id === next.placement.id &&
|
||||||
previous.placement.type === next.placement.type &&
|
previous.placement.type === next.placement.type &&
|
||||||
previous.placement.x === next.placement.x &&
|
previous.placement.x === next.placement.x &&
|
||||||
|
|||||||
@@ -2,38 +2,23 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/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 { 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";
|
import classes from "./section-grid.module.css";
|
||||||
|
|
||||||
interface GridPortalHostContextValue {
|
interface GridPortalHostContextValue {
|
||||||
announce: (message: string) => void;
|
announce: (message: string) => void;
|
||||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||||
containers: ReadonlyMap<string, HTMLElement>;
|
getEntryRuntime: <TRuntime>(entryId: string, createRuntime: () => TRuntime) => TRuntime;
|
||||||
acquireContainer: (id: string) => HTMLElement;
|
|
||||||
structureRevision: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
||||||
|
|
||||||
/**
|
/** Shared board-grid services for editor announcements and integration data. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@@ -41,51 +26,27 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
|||||||
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
|
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
|
||||||
enabled: Boolean(session) && isEditMode,
|
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 [announcement, setAnnouncement] = useState({ id: 0, message: "" });
|
||||||
|
const entryRuntimesRef = useRef(new Map<string, unknown>());
|
||||||
const announce = useCallback((message: string) => {
|
const announce = useCallback((message: string) => {
|
||||||
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
|
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
|
||||||
}, []);
|
}, []);
|
||||||
const liveEntryIds = useMemo(
|
const getEntryRuntime = useCallback(<TRuntime,>(entryId: string, createRuntime: () => TRuntime): TRuntime => {
|
||||||
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
|
const existing = entryRuntimesRef.current.get(entryId);
|
||||||
[board.items, board.sections],
|
if (existing !== undefined) return existing as TRuntime;
|
||||||
);
|
const runtime = createRuntime();
|
||||||
const structureRevision = useMemo(() => [...liveEntryIds].toSorted().join(":"), [liveEntryIds]);
|
entryRuntimesRef.current.set(entryId, runtime);
|
||||||
|
return runtime;
|
||||||
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 liveItemIds = useMemo(() => new Set(board.items.map((item) => item.id)), [board.items]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A cross-grid move can briefly have no shell, but the entry still exists
|
for (const entryId of entryRuntimesRef.current.keys()) {
|
||||||
// on the board. Only remove containers whose board entity was deleted.
|
if (!liveItemIds.has(entryId)) entryRuntimesRef.current.delete(entryId);
|
||||||
let didPrune = false;
|
|
||||||
for (const id of containersRef.current.keys()) {
|
|
||||||
if (liveEntryIds.has(id)) continue;
|
|
||||||
containersRef.current.delete(id);
|
|
||||||
didPrune = true;
|
|
||||||
}
|
}
|
||||||
if (didPrune) setContainers(new Map(containersRef.current));
|
}, [liveItemIds]);
|
||||||
}, [liveEntryIds]);
|
|
||||||
|
|
||||||
const value = useMemo<GridPortalHostContextValue>(
|
const value = useMemo<GridPortalHostContextValue>(
|
||||||
() => ({
|
() => ({ announce, integrations, getEntryRuntime }),
|
||||||
announce,
|
[announce, getEntryRuntime, integrations],
|
||||||
integrations,
|
|
||||||
containers,
|
|
||||||
acquireContainer,
|
|
||||||
structureRevision,
|
|
||||||
}),
|
|
||||||
[acquireContainer, announce, containers, integrations, structureRevision],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 = () => {
|
export const useBoardGridPortalHost = () => {
|
||||||
const context = useContext(GridPortalHostContext);
|
const context = useContext(GridPortalHostContext);
|
||||||
if (!context) throw new Error("BoardGridPortalHost is required");
|
if (!context) throw new Error("BoardGridPortalHost is required");
|
||||||
return context;
|
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;
|
inset: 0 auto auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editorPortalHost {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
.staticItem {
|
.staticItem {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
transition:
|
}
|
||||||
left 220ms ease,
|
|
||||||
top 220ms ease,
|
.contentMount {
|
||||||
width 220ms ease,
|
position: absolute;
|
||||||
height 220ms ease;
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorEntry {
|
.editorEntry {
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -43,19 +47,6 @@
|
|||||||
border-radius: var(--mantine-radius-md);
|
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 {
|
.liveRegion {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -67,9 +58,3 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.staticItem {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, LoadingOverlay } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
|
|
||||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||||
import {
|
import {
|
||||||
@@ -15,56 +14,21 @@ import {
|
|||||||
getEditableCanvasAttributes,
|
getEditableCanvasAttributes,
|
||||||
getGridRowCountForVisualHeight,
|
getGridRowCountForVisualHeight,
|
||||||
getLayoutRowCount,
|
getLayoutRowCount,
|
||||||
getLogicalItemStyle,
|
|
||||||
getLogicalTrackSize,
|
getLogicalTrackSize,
|
||||||
getReadonlyCanvasAttributes,
|
getReadonlyCanvasAttributes,
|
||||||
normalizeGridPlacement,
|
normalizeGridPlacement,
|
||||||
} from "~/components/board/layout";
|
} from "~/components/board/layout";
|
||||||
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
|
||||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||||
|
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
|
||||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||||
import { SectionContent } from "../content";
|
import { SectionContent } from "../content";
|
||||||
import { useCollapsedSectionIds } from "../section-collapse";
|
import { useCollapsedSectionIds, useExpandSectionsForEditing } from "../section-collapse";
|
||||||
import { SectionProvider, useSectionContext } from "../section-context";
|
import { SectionProvider } from "../section-context";
|
||||||
import { useSectionItems } from "../use-section-items";
|
import { useSectionItems } from "../use-section-items";
|
||||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||||
import classes from "./section-grid.module.css";
|
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 {
|
interface SectionGridProps {
|
||||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
@@ -85,21 +49,19 @@ export const SectionGrid = ({
|
|||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const canvasScale = useBoardCanvasScale();
|
const canvasScale = useBoardCanvasScale();
|
||||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||||
|
const editorRegistry = useGridEditorRegistry();
|
||||||
|
const editorHostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [entryElementStore] = useState(createGridEntryElementStore);
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const currentLayoutId = useCurrentLayout();
|
const currentLayoutId = useCurrentLayout();
|
||||||
const { items, innerSections } = useSectionItems(section.id);
|
const { items, innerSections } = useSectionItems(section.id);
|
||||||
const { announce, integrations } = useBoardGridPortalHost();
|
const { announce, integrations } = useBoardGridPortalHost();
|
||||||
const collapsedSectionIds = useCollapsedSectionIds();
|
const collapsedSectionIds = useCollapsedSectionIds();
|
||||||
const minimumBySectionId = useMemo(
|
const expandSectionsForEditing = useExpandSectionsForEditing();
|
||||||
() =>
|
const minimumBySectionId = useMemo(() => {
|
||||||
new Map(
|
const minimumSizes = getContainerMinimumSizes(board, currentLayoutId);
|
||||||
innerSections.map((innerSection) => [
|
return new Map(innerSections.map((innerSection) => [innerSection.id, minimumSizes.get(innerSection.id)]));
|
||||||
innerSection.id,
|
}, [board, currentLayoutId, innerSections]);
|
||||||
getContainerMinimumSize(board, currentLayoutId, innerSection.id),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
[board, currentLayoutId, innerSections],
|
|
||||||
);
|
|
||||||
|
|
||||||
const placements = useMemo(
|
const placements = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -121,41 +83,112 @@ export const SectionGrid = ({
|
|||||||
}),
|
}),
|
||||||
[columnCount, innerSections, items, minimumBySectionId],
|
[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 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;
|
if (directCollapsedIds.size === 0) return placements;
|
||||||
|
|
||||||
return getCollapsedDisplayLayout(placements, {
|
return getCollapsedDisplayLayout(placements, {
|
||||||
columnCount,
|
columnCount,
|
||||||
collapsedItemIds: directCollapsedIds,
|
collapsedItemIds: directCollapsedIds,
|
||||||
});
|
});
|
||||||
}, [collapsedSectionIds, columnCount, innerSections, placements]);
|
}, [columnCount, directCollapsedIds, placements]);
|
||||||
|
|
||||||
const placementById = useMemo(
|
const placementById = useMemo(
|
||||||
() => new Map(displayPlacements.map((placement) => [placement.id, placement])),
|
() => new Map(displayPlacements.map((placement) => [placement.id, placement])),
|
||||||
[displayPlacements],
|
[displayPlacements],
|
||||||
);
|
);
|
||||||
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
|
const displayedItems = useMemo(
|
||||||
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
|
() => 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 minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
|
||||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||||
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
||||||
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
|
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
|
||||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||||
const canvasAttributes = isEditMode
|
// A collapsed container's compact coordinates are display-only. Its own
|
||||||
? getEditableCanvasAttributes({ label, columnCount, rowCount })
|
// nested grid stays inactive until an explicit edit interaction expands it.
|
||||||
: getReadonlyCanvasAttributes({ label });
|
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 (
|
return (
|
||||||
<SectionProvider
|
<SectionProvider
|
||||||
@@ -166,47 +199,48 @@ export const SectionGrid = ({
|
|||||||
integrations,
|
integrations,
|
||||||
columnCount,
|
columnCount,
|
||||||
maxRowCount,
|
maxRowCount,
|
||||||
|
placements: displayPlacements,
|
||||||
|
interactionDisabled: isInteractionDisabled,
|
||||||
announce,
|
announce,
|
||||||
|
entryElementStore,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
{...canvasAttributes}
|
{...canvasAttributes}
|
||||||
className={combineClasses(classes.viewport, className)}
|
className={combineClasses(classes.viewport, className)}
|
||||||
style={{
|
style={
|
||||||
width: logicalWidth,
|
{
|
||||||
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
|
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-id={section.id}
|
||||||
data-section-kind={section.kind}
|
data-section-kind={section.kind}
|
||||||
data-rail-placement={railPlacement}
|
data-rail-placement={railPlacement}
|
||||||
|
data-grid-interaction-disabled={isEditMode && isInteractionDisabled ? "true" : undefined}
|
||||||
|
onPointerDownCapture={expandCollapsedSectionsForPointerEdit}
|
||||||
|
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
|
||||||
>
|
>
|
||||||
{isEditMode && editorRuntimeStatus === "ready" ? (
|
<Box
|
||||||
<GridEditor
|
className={classes.staticGrid}
|
||||||
sectionId={section.id}
|
style={{ width: logicalWidth, height: logicalHeight }}
|
||||||
columnCount={columnCount}
|
data-grid-section-id={section.id}
|
||||||
rowCount={rowCount}
|
data-kind={section.kind}
|
||||||
maxRowCount={maxRowCount}
|
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
|
||||||
placements={displayPlacements}
|
>
|
||||||
className={combineClasses("board-grid-editor", classes.editorGrid)}
|
<SectionContent />
|
||||||
/>
|
</Box>
|
||||||
) : isEditMode && editorRuntimeStatus === "loading" ? (
|
<div ref={editorHostRef} className={classes.editorPortalHost} />
|
||||||
<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>
|
</Box>
|
||||||
</SectionProvider>
|
</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 useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
||||||
const [visualHeight, setVisualHeight] = useState(0);
|
const [visualHeight, setVisualHeight] = useState(0);
|
||||||
|
|
||||||
@@ -225,37 +259,70 @@ const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
|||||||
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
|
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getContainerMinimumSize = (
|
const containerMinimumSizeCache = new WeakMap<
|
||||||
board: ReturnType<typeof useRequiredBoard>,
|
ReturnType<typeof useRequiredBoard>,
|
||||||
layoutId: string,
|
Map<string, ReadonlyMap<string, { width: number; height: number }>>
|
||||||
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];
|
|
||||||
|
|
||||||
return {
|
const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, layoutId: string) => {
|
||||||
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
|
const cachedByLayout = containerMinimumSizeCache.get(board);
|
||||||
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
|
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 {
|
interface PlacementBounds {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { FixedGridItem } from "../fixed-grid-item";
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
announce: vi.fn(),
|
announce: vi.fn(),
|
||||||
commitSectionGrid: vi.fn(),
|
commitSectionGrid: vi.fn(),
|
||||||
|
isSelected: vi.fn(() => false),
|
||||||
|
toggleSelectItem: vi.fn(),
|
||||||
|
registerElement: vi.fn(),
|
||||||
editMode: true,
|
editMode: true,
|
||||||
maxRowCount: null as number | null,
|
maxRowCount: null as number | null,
|
||||||
items: [
|
items: [
|
||||||
@@ -44,6 +47,13 @@ vi.mock("@homarr/boards/edit-mode", () => ({
|
|||||||
useEditMode: () => [mocks.editMode],
|
useEditMode: () => [mocks.editMode],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/components/board/selection/board-selection-context", () => ({
|
||||||
|
useBoardSelection: () => ({
|
||||||
|
isSelected: mocks.isSelected,
|
||||||
|
toggleSelectItem: mocks.toggleSelectItem,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@homarr/boards/context", () => ({
|
vi.mock("@homarr/boards/context", () => ({
|
||||||
useCurrentLayout: () => "layout",
|
useCurrentLayout: () => "layout",
|
||||||
}));
|
}));
|
||||||
@@ -70,7 +80,17 @@ vi.mock("../../section-context", () => ({
|
|||||||
innerSections: [],
|
innerSections: [],
|
||||||
columnCount: 3,
|
columnCount: 3,
|
||||||
maxRowCount: mocks.maxRowCount,
|
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,
|
announce: mocks.announce,
|
||||||
|
entryElementStore: { register: mocks.registerElement },
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -89,6 +109,7 @@ describe("fixed grid item behavior", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.announce.mockReset();
|
mocks.announce.mockReset();
|
||||||
mocks.commitSectionGrid.mockReset();
|
mocks.commitSectionGrid.mockReset();
|
||||||
|
mocks.registerElement.mockReset();
|
||||||
mocks.editMode = true;
|
mocks.editMode = true;
|
||||||
mocks.maxRowCount = null;
|
mocks.maxRowCount = null;
|
||||||
getWeatherMock().advancedOptions.title = null;
|
getWeatherMock().advancedOptions.title = null;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ describe("grid entry memoization", () => {
|
|||||||
{
|
{
|
||||||
"data-grid-id": props.placement.id,
|
"data-grid-id": props.placement.id,
|
||||||
"data-x": props.placement.x,
|
"data-x": props.placement.x,
|
||||||
ref: (element: HTMLSpanElement | null) => props.registerElement(props.placement.id, element),
|
|
||||||
},
|
},
|
||||||
createElement(WidgetProbe),
|
createElement(WidgetProbe),
|
||||||
);
|
);
|
||||||
@@ -79,21 +78,17 @@ describe("grid entry memoization", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("keeps 100 entry and widget renders frozen across interaction store previews", () => {
|
test("keeps 100 entry and widget renders frozen across interaction store previews", () => {
|
||||||
const elements = new Map<string, HTMLElement>();
|
|
||||||
const interactionStore = createGridInteractionStore<GridInteraction>();
|
const interactionStore = createGridInteractionStore<GridInteraction>();
|
||||||
const gridRef = { current: null as HTMLDivElement | null };
|
const gridRef = { current: null as HTMLDivElement | null };
|
||||||
const setGridRef = (element: HTMLDivElement | null) => {
|
const setGridRef = (element: HTMLDivElement | null) => {
|
||||||
gridRef.current = element;
|
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) => ({
|
const entries = Array.from({ length: 100 }, (_, index) => ({
|
||||||
...createProps(),
|
...createProps(),
|
||||||
registerElement,
|
element: document.createElement("span"),
|
||||||
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
|
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[]) => {
|
const renderPreviewBoard = (nextEntries: DndGridEntryProps[]) => {
|
||||||
act(() =>
|
act(() =>
|
||||||
root.render(
|
root.render(
|
||||||
@@ -160,7 +155,7 @@ describe("grid entry memoization", () => {
|
|||||||
test.each([
|
test.each([
|
||||||
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
|
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
|
||||||
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
|
["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",
|
"minimum size",
|
||||||
(props: DndGridEntryProps) => ({
|
(props: DndGridEntryProps) => ({
|
||||||
@@ -198,5 +193,5 @@ const createProps = (): DndGridEntryProps => ({
|
|||||||
label: "Weather",
|
label: "Weather",
|
||||||
columnCount: 12,
|
columnCount: 12,
|
||||||
maxRowCount: null,
|
maxRowCount: null,
|
||||||
registerElement: () => undefined,
|
element: null,
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest";
|
|||||||
const gridDirectory = resolve(import.meta.dirname, "..");
|
const gridDirectory = resolve(import.meta.dirname, "..");
|
||||||
const nextjsSourceDirectory = resolve(gridDirectory, "../../../../");
|
const nextjsSourceDirectory = resolve(gridDirectory, "../../../../");
|
||||||
const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
|
const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
|
||||||
|
const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.tsx");
|
||||||
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
||||||
|
|
||||||
describe("board grid import boundary", () => {
|
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", () => {
|
test("loads the editor only in edit mode without replacing static board content", () => {
|
||||||
const source = readFileSync(sectionGridPath, "utf8");
|
const sectionSource = readFileSync(sectionGridPath, "utf8");
|
||||||
|
const boundarySource = readFileSync(editorBoundaryPath, "utf8");
|
||||||
|
|
||||||
expect(source).toMatch(/import dynamic from ["']next\/dynamic["']/);
|
expect(sectionSource).not.toMatch(/import dynamic from ["']next\/dynamic["']/);
|
||||||
expect(source).toMatch(
|
expect(sectionSource).toMatch(/<SectionContent\s*\/>/);
|
||||||
/const GridEditor = dynamic\(loadGridEditorAsync,\s*{[\s\S]*?loading:\s*GridEditorLoading,[\s\S]*?ssr:\s*false,[\s\S]*?}\);/,
|
expect(sectionSource).toMatch(/<div ref={editorHostRef} className={classes\.editorPortalHost}\s*\/>/);
|
||||||
);
|
expect(boundarySource).toMatch(/loadGridEditorAsync\(\)/);
|
||||||
expect(source.match(/<GridEditor\b/g)).toHaveLength(1);
|
expect(boundarySource).toMatch(/isEditMode\s*&&\s*Provider\s*&&\s*GridEditor/);
|
||||||
expect(source).toMatch(/isEditMode\s*&&\s*editorRuntimeStatus\s*===\s*["']ready["']\s*\?\s*\(\s*<GridEditor\b/);
|
expect(boundarySource).toMatch(/createPortal\(<GridEditor \{\.\.\.props} \/>, host, props\.sectionId\)/);
|
||||||
expect(getRuntimeImports(sectionGridPath).filter(({ specifier }) => specifier === "./grid-editor")).toEqual([]);
|
expect(getRuntimeImports(sectionGridPath).filter(({ specifier }) => specifier === "./grid-editor")).toEqual([]);
|
||||||
expect(
|
expect(
|
||||||
getRuntimeImports(editorLoaderPath)
|
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 {
|
interface SectionCollapseContextValue {
|
||||||
collapsedSectionIds: ReadonlySet<string>;
|
collapsedSectionIds: ReadonlySet<string>;
|
||||||
|
visuallyCollapsedSectionIds: ReadonlySet<string>;
|
||||||
setCollapsed: (sectionId: string, collapsed: boolean) => void;
|
setCollapsed: (sectionId: string, collapsed: boolean) => void;
|
||||||
|
expandForEditing: (sectionIds: ReadonlySet<string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
|
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
|
||||||
|
|
||||||
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
|
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const [isEditMode] = useEditMode();
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||||
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
|
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
|
||||||
@@ -27,8 +30,13 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const collapsedByIdRef = useRef(collapsedById);
|
const collapsedByIdRef = useRef(collapsedById);
|
||||||
|
const [temporarilyExpandedIds, setTemporarilyExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
collapsedByIdRef.current = collapsedById;
|
collapsedByIdRef.current = collapsedById;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditMode) setTemporarilyExpandedIds((current) => (current.size === 0 ? current : new Set()));
|
||||||
|
}, [isEditMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCollapsedById((previous) => {
|
setCollapsedById((previous) => {
|
||||||
const next = { ...previous };
|
const next = { ...previous };
|
||||||
@@ -57,6 +65,12 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
|||||||
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
|
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
|
||||||
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed };
|
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed };
|
||||||
setCollapsedById((previous) => ({ ...previous, [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") {
|
if (status === "authenticated") {
|
||||||
mutate(
|
mutate(
|
||||||
@@ -76,47 +90,70 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
|||||||
[mutate, status],
|
[mutate, status],
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = useMemo<SectionCollapseContextValue>(
|
const expandForEditing = useCallback(
|
||||||
() => ({
|
(sectionIds: ReadonlySet<string>) => {
|
||||||
collapsedSectionIds: new Set(
|
if (!isEditMode || sectionIds.size === 0) return;
|
||||||
Object.entries(collapsedById)
|
setTemporarilyExpandedIds((current) => {
|
||||||
.filter(([, collapsed]) => collapsed)
|
const next = new Set(current);
|
||||||
.map(([sectionId]) => sectionId),
|
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,
|
setCollapsed,
|
||||||
}),
|
expandForEditing,
|
||||||
[collapsedById, setCollapsed],
|
};
|
||||||
);
|
}, [collapsedById, expandForEditing, isEditMode, setCollapsed, temporarilyExpandedIds]);
|
||||||
|
|
||||||
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
|
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: string; collapsible: boolean }) => {
|
export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: string; collapsible: boolean }) => {
|
||||||
const context = useContext(SectionCollapseContext);
|
const context = useContext(SectionCollapseContext);
|
||||||
const [isEditMode] = useEditMode();
|
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("BoardSectionCollapseProvider is required");
|
throw new Error("BoardSectionCollapseProvider is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
|
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
|
||||||
|
const isVisuallyCollapsed = collapsible && context.visuallyCollapsedSectionIds.has(sectionId);
|
||||||
return {
|
return {
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
isVisuallyCollapsed: isCollapsed && !isEditMode,
|
isVisuallyCollapsed,
|
||||||
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
|
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
|
||||||
toggle: () => context.setCollapsed(sectionId, !isCollapsed),
|
toggle: () => context.setCollapsed(sectionId, !isVisuallyCollapsed),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCollapsedSectionIds = () => {
|
export const useCollapsedSectionIds = () => {
|
||||||
const context = useContext(SectionCollapseContext);
|
const context = useContext(SectionCollapseContext);
|
||||||
const [isEditMode] = useEditMode();
|
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("BoardSectionCollapseProvider is required");
|
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 { RouterOutputs } from "@homarr/api";
|
||||||
|
|
||||||
import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
|
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 {
|
interface SectionContextProps {
|
||||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||||
@@ -11,7 +13,10 @@ interface SectionContextProps {
|
|||||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
maxRowCount: number | null;
|
maxRowCount: number | null;
|
||||||
|
placements: readonly SectionGridPlacement[];
|
||||||
|
interactionDisabled: boolean;
|
||||||
announce: (message: string) => void;
|
announce: (message: string) => void;
|
||||||
|
entryElementStore: GridEntryElementStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SectionContext = createContext<SectionContextProps | null>(null);
|
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[] } => {
|
export const useSectionItems = (sectionId: string): { innerSections: ContainerSectionItem[]; items: SectionItem[] } => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const currentLayoutId = useCurrentLayout();
|
const currentLayoutId = useCurrentLayout();
|
||||||
|
const index = useMemo(() => getSectionItemsIndex(board, currentLayoutId), [board, currentLayoutId]);
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
innerSections,
|
innerSections: index.innerSectionsByParentId.get(sectionId) ?? EMPTY_INNER_SECTIONS,
|
||||||
items,
|
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() {
|
onError() {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
title: t("common.error"),
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
|
import { Alert, Button, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||||
import { IconArrowLeft, IconX } from "@tabler/icons-react";
|
import { IconArrowLeft, IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||||
@@ -27,9 +28,28 @@ interface IntegrationSelectModalProps {
|
|||||||
|
|
||||||
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
|
const directKind =
|
||||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
|
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 { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
|
||||||
|
const { data: integrationData, isError: isIntegrationDataError, refetch } = clientApi.integration.all.useQuery();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === "form" && !isDirectForm) {
|
||||||
|
actions.setCloseInterceptor?.(() => {
|
||||||
|
setStep("select");
|
||||||
|
setSelectedKind(null);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
actions.setCloseInterceptor?.(null);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
actions.setCloseInterceptor?.(null);
|
||||||
|
};
|
||||||
|
}, [step, isDirectForm, actions]);
|
||||||
|
|
||||||
const handleSelect = (kind: IntegrationKind) => {
|
const handleSelect = (kind: IntegrationKind) => {
|
||||||
setSelectedKind(kind);
|
setSelectedKind(kind);
|
||||||
@@ -40,7 +60,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
|||||||
setStep("select");
|
setStep("select");
|
||||||
setSelectedKind(null);
|
setSelectedKind(null);
|
||||||
};
|
};
|
||||||
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
|
const handleFormBack = isDirectForm ? actions.closeModal : handleBack;
|
||||||
|
|
||||||
const handleSuccess = (result?: CreatedIntegrationResult) => {
|
const handleSuccess = (result?: CreatedIntegrationResult) => {
|
||||||
actions.closeModal();
|
actions.closeModal();
|
||||||
@@ -52,12 +72,8 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (step === "form" && selectedKind) {
|
if (step === "form" && selectedKind) {
|
||||||
let HeaderIcon = IconArrowLeft;
|
const HeaderIcon = isDirectForm ? IconX : IconArrowLeft;
|
||||||
let headerLabel = t("common.action.previous");
|
const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous");
|
||||||
if (innerProps.initialKind) {
|
|
||||||
HeaderIcon = IconX;
|
|
||||||
headerLabel = t("common.action.close");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea.Autosize mah="80vh">
|
<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 (
|
return (
|
||||||
<IntegrationSelectGrid
|
<IntegrationSelectGrid
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
enableMockIntegration={innerProps.enableMockIntegration}
|
enableMockIntegration={innerProps.enableMockIntegration}
|
||||||
allowedKinds={innerProps.allowedKinds}
|
allowedKinds={innerProps.allowedKinds}
|
||||||
|
integrationData={integrationData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}).withOptions({
|
}).withOptions({
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
|||||||
|
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger";
|
|
||||||
import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight";
|
|
||||||
import { ClientBurger } from "./header/burger";
|
import { ClientBurger } from "./header/burger";
|
||||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||||
import { TourTarget } from "./header/tour-target";
|
import { TourTarget } from "./header/tour-target";
|
||||||
@@ -38,7 +36,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
|||||||
<DesktopSearchInput />
|
<DesktopSearchInput />
|
||||||
</TourTarget>
|
</TourTarget>
|
||||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||||
{hasNavigation && <UniversalCreateHeaderAction />}
|
|
||||||
{actions}
|
{actions}
|
||||||
<MobileSearchButton />
|
<MobileSearchButton />
|
||||||
<TourTarget id="board-user-menu">
|
<TourTarget id="board-user-menu">
|
||||||
@@ -46,8 +43,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
|||||||
</TourTarget>
|
</TourTarget>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<UniversalCreateQueryGate />
|
|
||||||
<UniversalCreateSpotlightResult />
|
|
||||||
<LazySpotlight />
|
<LazySpotlight />
|
||||||
</AppShellHeader>
|
</AppShellHeader>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -78,11 +78,15 @@ describe("lazy widget application graph", () => {
|
|||||||
await page.goto(`${baseUrl}/auth/login`);
|
await page.goto(`${baseUrl}/auth/login`);
|
||||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||||
await page.locator("#password").fill(adminCredentials.password);
|
await page.locator("#password").fill(adminCredentials.password);
|
||||||
const signedIn = page.waitForURL(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 page.locator("css=button[type='submit']").click();
|
||||||
await signedIn;
|
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(
|
const clockWidget = page.locator(
|
||||||
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
|||||||
|
|
||||||
const logger = createLogger({ module: "logRouter" });
|
const logger = createLogger({ module: "logRouter" });
|
||||||
|
|
||||||
|
type LogSubscriptionEvent =
|
||||||
|
| { type: "history"; messages: LoggerMessage[] }
|
||||||
|
| { type: "message"; message: LoggerMessage };
|
||||||
|
|
||||||
export const logRouter = createTRPCRouter({
|
export const logRouter = createTRPCRouter({
|
||||||
subscribe: permissionRequiredProcedure
|
subscribe: permissionRequiredProcedure
|
||||||
.requiresPermission("other-view-logs")
|
.requiresPermission("other-view-logs")
|
||||||
@@ -20,15 +24,61 @@ export const logRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.subscription(({ input }) => {
|
.subscription(({ input }) => {
|
||||||
return observable<LoggerMessage>((emit) => {
|
return observable<LogSubscriptionEvent>((emit) => {
|
||||||
const unsubscribe = loggingChannel.subscribe((data) => {
|
let isClosed = false;
|
||||||
if (!input.levels.includes(data.level)) return;
|
let unsubscribe: (() => void) | null = null;
|
||||||
emit.next(data);
|
let isHistoryLoaded = false;
|
||||||
});
|
const bufferedMessages: LoggerMessage[] = [];
|
||||||
logger.info("A tRPC client has connected to the logging procedure");
|
|
||||||
|
const onMessage = (message: LoggerMessage) => {
|
||||||
|
if (!input.levels.includes(message.level)) return;
|
||||||
|
|
||||||
|
if (!isHistoryLoaded) {
|
||||||
|
bufferedMessages.push(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit.next({ type: "message", message });
|
||||||
|
};
|
||||||
|
|
||||||
|
void loggingChannel
|
||||||
|
.subscribeAsync(onMessage)
|
||||||
|
.then(async (unsubscribeFromLogging) => {
|
||||||
|
if (isClosed) {
|
||||||
|
unsubscribeFromLogging();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe = unsubscribeFromLogging;
|
||||||
|
logger.info("A tRPC client has connected to the logging procedure");
|
||||||
|
|
||||||
|
const history = (await loggingChannel.getRecentAsync()).filter((message) =>
|
||||||
|
input.levels.includes(message.level),
|
||||||
|
);
|
||||||
|
if (isClosed) return;
|
||||||
|
|
||||||
|
emit.next({ type: "history", messages: history });
|
||||||
|
|
||||||
|
const historyIds = new Set(history.map((message) => message.id));
|
||||||
|
isHistoryLoaded = true;
|
||||||
|
for (const message of bufferedMessages) {
|
||||||
|
if (!historyIds.has(message.id)) {
|
||||||
|
emit.next({ type: "message", message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bufferedMessages.length = 0;
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
if (!isClosed) {
|
||||||
|
emit.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
isClosed = true;
|
||||||
|
unsubscribe?.();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { Session } from "@homarr/auth";
|
||||||
|
import type { LoggerMessage } from "@homarr/redis";
|
||||||
|
|
||||||
|
import { logRouter } from "../log";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getRecentAsync: vi.fn(),
|
||||||
|
subscribeAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@homarr/redis", () => ({
|
||||||
|
loggingChannel: {
|
||||||
|
getRecentAsync: mocks.getRecentAsync,
|
||||||
|
subscribeAsync: mocks.subscribeAsync,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
user: {
|
||||||
|
id: "log-viewer",
|
||||||
|
permissions: ["other-view-logs"],
|
||||||
|
colorScheme: "light",
|
||||||
|
},
|
||||||
|
expires: new Date(0).toISOString(),
|
||||||
|
} satisfies Session;
|
||||||
|
|
||||||
|
const createMessage = (id: string, timestamp: number): LoggerMessage => ({
|
||||||
|
id,
|
||||||
|
timestamp: new Date(timestamp),
|
||||||
|
level: "info",
|
||||||
|
message: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("log.subscribe", () => {
|
||||||
|
test("emits history before buffered live messages without duplicates", async () => {
|
||||||
|
const historyDeferred = Promise.withResolvers<LoggerMessage[]>();
|
||||||
|
let onMessage: ((message: LoggerMessage) => void) | undefined;
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
|
||||||
|
mocks.subscribeAsync.mockImplementation(async (callback: (message: LoggerMessage) => void) => {
|
||||||
|
onMessage = callback;
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
mocks.getRecentAsync.mockReturnValue(historyDeferred.promise);
|
||||||
|
|
||||||
|
const caller = logRouter.createCaller({
|
||||||
|
db: null as never,
|
||||||
|
deviceType: undefined,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
const subscription = await caller.subscribe({ levels: ["info"] });
|
||||||
|
const events: object[] = [];
|
||||||
|
const observer = subscription.subscribe({
|
||||||
|
next(event) {
|
||||||
|
events.push(event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mocks.getRecentAsync).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
const retainedMessage = createMessage("retained", 1);
|
||||||
|
const liveMessage = createMessage("live", 2);
|
||||||
|
onMessage?.(retainedMessage);
|
||||||
|
onMessage?.(liveMessage);
|
||||||
|
historyDeferred.resolve([retainedMessage]);
|
||||||
|
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: "history", messages: [retainedMessage] },
|
||||||
|
{ type: "message", message: liveMessage },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.unsubscribe();
|
||||||
|
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
|
import z from "zod/v4";
|
||||||
|
|
||||||
export const logLevels = ["error", "warn", "info", "debug"] as const;
|
export const logLevels = ["error", "warn", "info", "debug"] as const;
|
||||||
export type LogLevel = (typeof logLevels)[number];
|
export type LogLevel = (typeof logLevels)[number];
|
||||||
|
|
||||||
|
export const loggerMessageSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
timestamp: z.date(),
|
||||||
|
message: z.string(),
|
||||||
|
level: z.enum(logLevels),
|
||||||
|
});
|
||||||
|
export type LoggerMessage = z.infer<typeof loggerMessageSchema>;
|
||||||
|
|
||||||
|
export const LOG_HISTORY_MAX_ENTRIES = 5_000;
|
||||||
|
export const LOG_HISTORY_KEY = "list:logging:history";
|
||||||
|
export const LOG_PUBLISH_CHANNEL = "pubSub:logging";
|
||||||
|
|
||||||
export const logLevelConfiguration = {
|
export const logLevelConfiguration = {
|
||||||
error: {
|
error: {
|
||||||
prefix: "🔴",
|
prefix: "🔴",
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import superjson from "superjson";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { stringify } from "superjson";
|
||||||
import Transport from "winston-transport";
|
import Transport from "winston-transport";
|
||||||
|
|
||||||
import type { RedisClient } from "../../redis/client";
|
import type { RedisClient } from "../../redis/client";
|
||||||
import { createRedisClient } from "../../redis/client";
|
import { createRedisClient } from "../../redis/client";
|
||||||
|
import type { LoggerMessage } from "../constants";
|
||||||
|
import { LOG_HISTORY_KEY, LOG_HISTORY_MAX_ENTRIES, LOG_PUBLISH_CHANNEL } from "../constants";
|
||||||
|
|
||||||
const messageSymbol = Symbol.for("message");
|
const messageSymbol = Symbol.for("message");
|
||||||
const levelSymbol = Symbol.for("level");
|
const levelSymbol = Symbol.for("level");
|
||||||
@@ -13,32 +16,41 @@ const levelSymbol = Symbol.for("level");
|
|||||||
//
|
//
|
||||||
export class RedisTransport extends Transport {
|
export class RedisTransport extends Transport {
|
||||||
private redis: RedisClient | null = null;
|
private redis: RedisClient | null = null;
|
||||||
public static readonly publishChannel = "pubSub:logging";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log the info to the Redis channel
|
* Retain the log in a bounded Redis list and publish it to live subscribers.
|
||||||
*/
|
*/
|
||||||
log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
|
log(info: { [messageSymbol]: string; [levelSymbol]: LoggerMessage["level"] }, callback: () => void) {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
this.emit("logged", info);
|
this.emit("logged", info);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
const message: LoggerMessage = {
|
||||||
this.redis ??= createRedisClient();
|
id: randomUUID(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
message: info[messageSymbol],
|
||||||
|
level: info[levelSymbol],
|
||||||
|
};
|
||||||
|
const serializedMessage = stringify(message);
|
||||||
|
|
||||||
this.redis
|
callback();
|
||||||
.publish(
|
|
||||||
RedisTransport.publishChannel,
|
void Promise.resolve()
|
||||||
superjson.stringify({
|
|
||||||
message: info[messageSymbol],
|
|
||||||
level: info[levelSymbol],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
callback();
|
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
||||||
|
if (!this.redis) {
|
||||||
|
this.redis = createRedisClient();
|
||||||
|
this.redis.options.maxRetriesPerRequest = 1;
|
||||||
|
this.redis.on("error", () => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.redis
|
||||||
|
.multi()
|
||||||
|
.lpush(LOG_HISTORY_KEY, serializedMessage)
|
||||||
|
.ltrim(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1)
|
||||||
|
.publish(LOG_PUBLISH_CHANNEL, serializedMessage)
|
||||||
|
.exec();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => undefined);
|
||||||
// Ignore errors
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,7 +336,6 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/whats-up-docker"
|
| "/docs/integrations/whats-up-docker"
|
||||||
| "/docs/widgets/clock"
|
| "/docs/widgets/clock"
|
||||||
| "/docs/widgets/weather"
|
| "/docs/widgets/weather"
|
||||||
| "/docs/widgets/today"
|
|
||||||
| "/docs/widgets/air-quality"
|
| "/docs/widgets/air-quality"
|
||||||
| "/docs/widgets/countdown"
|
| "/docs/widgets/countdown"
|
||||||
| "/docs/widgets/timer"
|
| "/docs/widgets/timer"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { WidgetKind } from "../widget";
|
|||||||
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||||
clock: "clock",
|
clock: "clock",
|
||||||
weather: "weather",
|
weather: "weather",
|
||||||
today: "today",
|
|
||||||
airQuality: "air-quality",
|
airQuality: "air-quality",
|
||||||
countdown: "countdown",
|
countdown: "countdown",
|
||||||
timer: "timer",
|
timer: "timer",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export const widgetKinds = [
|
export const widgetKinds = [
|
||||||
"clock",
|
"clock",
|
||||||
"weather",
|
"weather",
|
||||||
"today",
|
|
||||||
"airQuality",
|
"airQuality",
|
||||||
"countdown",
|
"countdown",
|
||||||
"timer",
|
"timer",
|
||||||
@@ -63,7 +62,6 @@ export const widgetKinds = [
|
|||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|
||||||
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
|
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
|
||||||
today: { width: 2, height: 1 },
|
|
||||||
airQuality: { width: 2, height: 1 },
|
airQuality: { width: 2, height: 1 },
|
||||||
countdown: { width: 2, height: 1 },
|
countdown: { width: 2, height: 1 },
|
||||||
timer: { width: 2, height: 1 },
|
timer: { width: 2, height: 1 },
|
||||||
|
|||||||
@@ -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 { useMemo, useState } from "react";
|
||||||
import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
import {
|
||||||
import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
|
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 type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
import { selectGridCols, SelectableCard } from "@homarr/ui";
|
||||||
|
|
||||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
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 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) {
|
if (multiSelect) {
|
||||||
setSelectedAppIds((current) => {
|
setSelectedAppIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@@ -44,22 +65,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
|||||||
else next.add(app.id);
|
else next.add(app.id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
innerProps.onSelect?.(app);
|
|
||||||
actions.closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (multiSelect) {
|
|
||||||
innerProps.onSelectMany?.(selectedApps);
|
|
||||||
actions.closeModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const [first] = filteredApps;
|
|
||||||
if (first) {
|
|
||||||
innerProps.onSelect?.(first);
|
|
||||||
actions.closeModal();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,100 +81,145 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMultiSubmit = () => {
|
||||||
|
innerProps.onSelectMany?.(selectedApps);
|
||||||
|
actions.closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack gap="md">
|
||||||
<SelectGridLayout
|
{/* Top Search Input & Multi-Select Tip */}
|
||||||
search={search}
|
<Stack gap={6}>
|
||||||
onSearchChange={setSearch}
|
<Input
|
||||||
placeholder={`${t("app.action.select.search")}...`}
|
value={search}
|
||||||
ariaLabel={t("app.action.select.search")}
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
onSearchKeyDown={(event) => {
|
leftSection={<IconSearch size={16} />}
|
||||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
placeholder={`${t("app.action.select.search")}...`}
|
||||||
handleSelect(filteredApps[0]);
|
aria-label={t("app.action.select.search")}
|
||||||
}
|
data-autofocus
|
||||||
}}
|
onKeyDown={(event) => {
|
||||||
>
|
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||||
{innerProps.withCreate && (
|
handleSelect(filteredApps[0]);
|
||||||
<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 })
|
|
||||||
}
|
}
|
||||||
selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
|
}}
|
||||||
onSelect={() => handleSelect(app)}
|
/>
|
||||||
>
|
{multiSelect && (
|
||||||
<Stack h="100%" gap="xs">
|
<Group justify="space-between" px={2}>
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
<Text size="xs" c="dimmed">
|
||||||
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
{t("app.action.select.multiSelectTip")}
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
</Text>
|
||||||
{app.name}
|
{selectedAppIds.size > 0 && (
|
||||||
</Text>
|
<Badge variant="light" color="primaryColor" size="xs">
|
||||||
{multiSelect && selectedAppIds.has(app.id) && (
|
{t("app.action.select.selectedCount", { count: selectedAppIds.size })}
|
||||||
<IconCheck size={18} color="var(--mantine-primary-color-filled)" />
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</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>
|
|
||||||
)}
|
)}
|
||||||
</SelectGridLayout>
|
</Stack>
|
||||||
{multiSelect && (
|
|
||||||
<Group justify="flex-end" mt="md">
|
{/* Scrollable Container with App Cards */}
|
||||||
<Button leftSection={<IconApps size={16} />} disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
|
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
|
||||||
{t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
|
<Stack gap="md" pt="xs" pr="xs" px={4}>
|
||||||
</Button>
|
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||||
</Group>
|
{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({
|
}).withOptions({
|
||||||
defaultTitle: (t) => t("app.action.select.title"),
|
defaultTitle: (t) => t("app.action.select.title"),
|
||||||
size: modalSizeSelect,
|
size: modalSizeSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AppCard: Variant 3 (Dashboard Inset) with Medium Title & Large App Icon
|
||||||
|
// =========================================================================
|
||||||
|
const AppCard = ({
|
||||||
|
app,
|
||||||
|
isSelected,
|
||||||
|
multiSelect,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
app: SelectableApp;
|
||||||
|
isSelected: boolean;
|
||||||
|
multiSelect: boolean;
|
||||||
|
onSelect: (app: SelectableApp, event?: React.MouseEvent) => void;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectableCard
|
||||||
|
onClick={(event) => onSelect(app, event)}
|
||||||
|
aria-label={app.name}
|
||||||
|
selected={isSelected}
|
||||||
|
icon={<Image src={app.iconUrl} alt={app.name} w={28} h={28} fit="contain" style={{ flexShrink: 0 }} />}
|
||||||
|
title={app.name}
|
||||||
|
topRight={
|
||||||
|
isSelected ? (
|
||||||
|
<Badge variant="dot" color="primaryColor" size="xs">
|
||||||
|
{t("app.action.select.selected")}
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
description={app.description}
|
||||||
|
footerLeft={
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{multiSelect && isSelected ? t("app.action.select.selected") : t("app.action.select.application")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -102,6 +102,13 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
|||||||
content: presentationClasses.inspectorContent,
|
content: presentationClasses.inspectorContent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
const canClose = modal.reference.onCloseRequest ? modal.reference.onCloseRequest() : true;
|
||||||
|
if (canClose) {
|
||||||
|
handleCloseModal();
|
||||||
|
}
|
||||||
|
}, [handleCloseModal, modal.reference]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
key={modal.id}
|
key={modal.id}
|
||||||
@@ -123,7 +130,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
|||||||
{...otherModalProps}
|
{...otherModalProps}
|
||||||
title={translateIfNecessary(t, modal.props.defaultTitle)}
|
title={translateIfNecessary(t, modal.props.defaultTitle)}
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={handleCloseModal}
|
onClose={handleClose}
|
||||||
>
|
>
|
||||||
{modal.reference.content}
|
{modal.reference.content}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -144,7 +151,6 @@ export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) =>
|
|||||||
return {
|
return {
|
||||||
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
|
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
|
||||||
// void actually is undefined
|
// void actually is undefined
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
context.openModalInner({ modal, innerProps, options: options ?? {} });
|
context.openModalInner({ modal, innerProps, options: options ?? {} });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
|||||||
const ModalContent = modal.modal.component;
|
const ModalContent = modal.modal.component;
|
||||||
|
|
||||||
const { innerProps, ...rest } = modal.props;
|
const { innerProps, ...rest } = modal.props;
|
||||||
|
let closeInterceptor: (() => boolean) | null = null;
|
||||||
|
|
||||||
const FullModal = () => {
|
const FullModal = () => {
|
||||||
const context = useContext(ModalContext);
|
const context = useContext(ModalContext);
|
||||||
|
|
||||||
@@ -106,6 +108,9 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
|||||||
innerProps={innerProps}
|
innerProps={innerProps}
|
||||||
actions={{
|
actions={{
|
||||||
closeModal: () => context.closeModal(modal.id),
|
closeModal: () => context.closeModal(modal.id),
|
||||||
|
setCloseInterceptor: (interceptor: (() => boolean) | null) => {
|
||||||
|
closeInterceptor = interceptor;
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -114,5 +119,11 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
|||||||
return {
|
return {
|
||||||
modalProps: rest,
|
modalProps: rest,
|
||||||
content: <FullModal />,
|
content: <FullModal />,
|
||||||
|
onCloseRequest: () => {
|
||||||
|
if (closeInterceptor) {
|
||||||
|
return closeInterceptor();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -5,10 +5,12 @@ import type { stringOrTranslation } from "@homarr/translation";
|
|||||||
|
|
||||||
import type { ModalPresentation } from "./modal-presentation";
|
import type { ModalPresentation } from "./modal-presentation";
|
||||||
|
|
||||||
export type ModalComponent<TInnerProps> = (props: {
|
export interface ModalActions {
|
||||||
actions: { closeModal: () => void };
|
closeModal: () => void;
|
||||||
innerProps: TInnerProps;
|
setCloseInterceptor?: (interceptor: (() => boolean) | null) => void;
|
||||||
}) => ReactNode;
|
}
|
||||||
|
|
||||||
|
export type ModalComponent<TInnerProps> = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode;
|
||||||
|
|
||||||
export type CreateModalOptions = Pick<
|
export type CreateModalOptions = Pick<
|
||||||
ModalOptions<unknown>,
|
ModalOptions<unknown>,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ const CredentialsSetup = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AccountShell title={t("title")} description={t("description")}>
|
<AccountShell title={t("title")} description={t("description")}>
|
||||||
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
|
<form onSubmit={form.onSubmit((values) => submitAsync(values))}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
|
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
|
||||||
<UserCreatePasswordFields
|
<UserCreatePasswordFields
|
||||||
@@ -119,7 +119,7 @@ const CredentialsSetup = () => {
|
|||||||
{submitError}
|
{submitError}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
|
<Button type="submit" size="md" loading={form.submitting} rightSection={<IconArrowRight size={18} />}>
|
||||||
{t("create")}
|
{t("create")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
import { parse } from "superjson";
|
||||||
|
|
||||||
|
import type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
|
||||||
|
import {
|
||||||
|
loggerMessageSchema,
|
||||||
|
LOG_HISTORY_KEY,
|
||||||
|
LOG_HISTORY_MAX_ENTRIES,
|
||||||
|
LOG_PUBLISH_CHANNEL,
|
||||||
|
} from "@homarr/core/infrastructure/logs/constants";
|
||||||
|
|
||||||
import { createListChannel, createSubPubChannel } from "./lib/channel";
|
import { createListChannel, createSubPubChannel } from "./lib/channel";
|
||||||
|
import { ChannelSubscriptionTracker } from "./lib/channel-subscription-tracker";
|
||||||
|
import { createRedisConnection } from "./lib/connection";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
handshakeAsync,
|
handshakeAsync,
|
||||||
@@ -15,9 +25,31 @@ export const pingChannel = createSubPubChannel<
|
|||||||
>("ping");
|
>("ping");
|
||||||
export const pingUrlChannel = createListChannel<string>("ping-url");
|
export const pingUrlChannel = createListChannel<string>("ping-url");
|
||||||
|
|
||||||
export interface LoggerMessage {
|
export type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
|
||||||
message: string;
|
|
||||||
level: LogLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loggingChannel = createSubPubChannel<LoggerMessage>("logging");
|
const loggingHistoryClient = createRedisConnection();
|
||||||
|
|
||||||
|
const parseLoggerMessage = (message: string): LoggerMessage | null => {
|
||||||
|
try {
|
||||||
|
const result = loggerMessageSchema.safeParse(parse<unknown>(message));
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loggingChannel = {
|
||||||
|
subscribeAsync: (callback: (message: LoggerMessage) => void) =>
|
||||||
|
ChannelSubscriptionTracker.subscribeAsync(LOG_PUBLISH_CHANNEL, (message) => {
|
||||||
|
const parsedMessage = parseLoggerMessage(message);
|
||||||
|
if (parsedMessage) callback(parsedMessage);
|
||||||
|
}),
|
||||||
|
getRecentAsync: async () => {
|
||||||
|
const messages =
|
||||||
|
(await loggingHistoryClient?.lrange(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1).catch(() => [])) ?? [];
|
||||||
|
return messages
|
||||||
|
.map(parseLoggerMessage)
|
||||||
|
.filter((message): message is LoggerMessage => message !== null)
|
||||||
|
.toReversed();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ type SubscriptionCallback = (message: string) => MaybePromise<void>;
|
|||||||
*/
|
*/
|
||||||
export class ChannelSubscriptionTracker {
|
export class ChannelSubscriptionTracker {
|
||||||
private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>();
|
private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>();
|
||||||
|
private static subscriptionReadiness = new Map<string, Promise<void>>();
|
||||||
private static redis = createRedisConnection();
|
private static redis = createRedisConnection();
|
||||||
private static listenerActive = false;
|
private static listenerActive = false;
|
||||||
|
|
||||||
@@ -28,6 +29,25 @@ export class ChannelSubscriptionTracker {
|
|||||||
* @returns a function to unsubscribe from the channel
|
* @returns a function to unsubscribe from the channel
|
||||||
*/
|
*/
|
||||||
public static subscribe(channelName: string, callback: SubscriptionCallback) {
|
public static subscribe(channelName: string, callback: SubscriptionCallback) {
|
||||||
|
return this.registerSubscription(channelName, callback).unsubscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to a channel and resolves once Redis confirms the subscription.
|
||||||
|
*/
|
||||||
|
public static async subscribeAsync(channelName: string, callback: SubscriptionCallback) {
|
||||||
|
const subscription = this.registerSubscription(channelName, callback);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await subscription.ready;
|
||||||
|
return subscription.unsubscribe;
|
||||||
|
} catch (error) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static registerSubscription(channelName: string, callback: SubscriptionCallback) {
|
||||||
logger.debug("Adding redis channel callback", { channel: channelName });
|
logger.debug("Adding redis channel callback", { channel: channelName });
|
||||||
|
|
||||||
// We only want to activate the listener once
|
// We only want to activate the listener once
|
||||||
@@ -38,11 +58,18 @@ export class ChannelSubscriptionTracker {
|
|||||||
|
|
||||||
const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>();
|
const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>();
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
let ready = this.subscriptionReadiness.get(channelName);
|
||||||
|
|
||||||
// If there are no subscriptions to the channel, subscribe to it
|
// Subscribe when this channel has no active or pending Redis subscription.
|
||||||
if (channelSubscriptions.size === 0) {
|
if (!ready) {
|
||||||
logger.debug("Subscribing to redis channel", { channel: channelName });
|
logger.debug("Subscribing to redis channel", { channel: channelName });
|
||||||
void this.redis.subscribe(channelName);
|
ready = this.redis.subscribe(channelName).then(() => undefined);
|
||||||
|
this.subscriptionReadiness.set(channelName, ready);
|
||||||
|
void ready.catch(() => {
|
||||||
|
if (this.subscriptionReadiness.get(channelName) === ready) {
|
||||||
|
this.subscriptionReadiness.delete(channelName);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Adding redis channel callback", { channel: channelName, id });
|
logger.debug("Adding redis channel callback", { channel: channelName, id });
|
||||||
@@ -50,24 +77,26 @@ export class ChannelSubscriptionTracker {
|
|||||||
|
|
||||||
this.subscriptions.set(channelName, channelSubscriptions);
|
this.subscriptions.set(channelName, channelSubscriptions);
|
||||||
|
|
||||||
// Return a function to unsubscribe
|
const unsubscribe = () => {
|
||||||
return () => {
|
|
||||||
logger.debug("Removing redis channel callback", { channel: channelName, id });
|
logger.debug("Removing redis channel callback", { channel: channelName, id });
|
||||||
|
|
||||||
const channelSubscriptions = this.subscriptions.get(channelName);
|
const currentSubscriptions = this.subscriptions.get(channelName);
|
||||||
if (!channelSubscriptions) return;
|
if (!currentSubscriptions) return;
|
||||||
|
|
||||||
channelSubscriptions.delete(id);
|
currentSubscriptions.delete(id);
|
||||||
|
|
||||||
// If there are no subscriptions to the channel, unsubscribe from it
|
// If there are no subscriptions to the channel, unsubscribe from it
|
||||||
if (channelSubscriptions.size >= 1) {
|
if (currentSubscriptions.size >= 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Unsubscribing from redis channel", { channel: channelName });
|
logger.debug("Unsubscribing from redis channel", { channel: channelName });
|
||||||
void this.redis.unsubscribe(channelName);
|
void this.redis.unsubscribe(channelName);
|
||||||
this.subscriptions.delete(channelName);
|
this.subscriptions.delete(channelName);
|
||||||
|
this.subscriptionReadiness.delete(channelName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return { ready, unsubscribe };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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": {
|
"user": {
|
||||||
"title": "Users",
|
"title": "Users",
|
||||||
"name": "User",
|
"name": "User",
|
||||||
@@ -876,7 +821,12 @@
|
|||||||
"title": "Select an app to add to this board",
|
"title": "Select an app to add to this board",
|
||||||
"toggle": "Click to select",
|
"toggle": "Click to select",
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"multiple": "Add {count}"
|
"multiple": "Add {count}",
|
||||||
|
"multiSelectTip": "Tip: Hold Ctrl / ⌘ to select multiple apps at once",
|
||||||
|
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
|
||||||
|
"appsSelected": "{count, plural, one {# app selected} other {# apps selected}}",
|
||||||
|
"customApplication": "Custom application",
|
||||||
|
"application": "Application"
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "Create new app",
|
"title": "Create new app",
|
||||||
@@ -1721,7 +1671,11 @@
|
|||||||
"grid": {
|
"grid": {
|
||||||
"widgetCount": "{count} widgets",
|
"widgetCount": "{count} widgets",
|
||||||
"noWidgets": "No widgets",
|
"noWidgets": "No widgets",
|
||||||
"detected": "Detected via Docker"
|
"detected": "Detected via Docker",
|
||||||
|
"connected": "{count, plural, one {# connected} other {# connected}}",
|
||||||
|
"tiedWidgets": "Tied widgets",
|
||||||
|
"more": "+{count} more",
|
||||||
|
"loadError": "Integrations could not be loaded. Try again before creating a new connection."
|
||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"dnsHole": "DNS",
|
"dnsHole": "DNS",
|
||||||
@@ -2135,6 +2089,7 @@
|
|||||||
"success": "Success",
|
"success": "Success",
|
||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
|
"analyticsError": "Setup analytics could not be recorded.",
|
||||||
"action": {
|
"action": {
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
@@ -2309,7 +2264,6 @@
|
|||||||
"open": "Open Homarr Assistant",
|
"open": "Open Homarr Assistant",
|
||||||
"close": "Close assistant",
|
"close": "Close assistant",
|
||||||
"minimize": "Minimize assistant",
|
"minimize": "Minimize assistant",
|
||||||
"windowControls": "Conversation window controls",
|
|
||||||
"spotlight": "Ask Homarr Assistant",
|
"spotlight": "Ask Homarr Assistant",
|
||||||
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
|
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
|
||||||
"spotlightPrompt": {
|
"spotlightPrompt": {
|
||||||
@@ -2327,6 +2281,24 @@
|
|||||||
"newConversation": "New conversation",
|
"newConversation": "New conversation",
|
||||||
"conversations": "Conversations",
|
"conversations": "Conversations",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"conversationActions": "Conversation actions",
|
||||||
|
"searchConversations": "Search conversations",
|
||||||
|
"noMatchingConversations": "No matching conversations",
|
||||||
|
"moreActions": "More response actions",
|
||||||
|
"renameConversation": {
|
||||||
|
"action": "Rename",
|
||||||
|
"label": "Conversation name",
|
||||||
|
"save": "Save name",
|
||||||
|
"failedTitle": "Conversation could not be renamed",
|
||||||
|
"failedDescription": "Homarr could not rename this conversation. Try again."
|
||||||
|
},
|
||||||
|
"deleteConversation": {
|
||||||
|
"title": "Delete conversation?",
|
||||||
|
"description": "“{title}” and its messages will be permanently deleted.",
|
||||||
|
"failedTitle": "Conversation could not be deleted",
|
||||||
|
"failedDescription": "Homarr could not delete this conversation. Try again."
|
||||||
|
},
|
||||||
"closeHistory": "Close conversation history",
|
"closeHistory": "Close conversation history",
|
||||||
"openHistory": "Open conversation history",
|
"openHistory": "Open conversation history",
|
||||||
"emptyTitle": "What can I help with?",
|
"emptyTitle": "What can I help with?",
|
||||||
@@ -2399,6 +2371,8 @@
|
|||||||
"regenerate": "Regenerate response",
|
"regenerate": "Regenerate response",
|
||||||
"helpful": "Helpful",
|
"helpful": "Helpful",
|
||||||
"notHelpful": "Not helpful",
|
"notHelpful": "Not helpful",
|
||||||
|
"readAloud": "Read response aloud",
|
||||||
|
"stopReading": "Stop reading response",
|
||||||
"feedbackError": "Your feedback could not be saved. Try again.",
|
"feedbackError": "Your feedback could not be saved. Try again.",
|
||||||
"exportMarkdown": "Export as Markdown",
|
"exportMarkdown": "Export as Markdown",
|
||||||
"exportConversation": {
|
"exportConversation": {
|
||||||
@@ -2427,7 +2401,6 @@
|
|||||||
},
|
},
|
||||||
"mentions": {
|
"mentions": {
|
||||||
"menu": "Add Homarr context",
|
"menu": "Add Homarr context",
|
||||||
"hint": "Type @ to add context",
|
|
||||||
"context": "Context",
|
"context": "Context",
|
||||||
"app": "Apps",
|
"app": "Apps",
|
||||||
"integration": "Integrations",
|
"integration": "Integrations",
|
||||||
@@ -2442,7 +2415,6 @@
|
|||||||
},
|
},
|
||||||
"commands": {
|
"commands": {
|
||||||
"menu": "Assistant commands",
|
"menu": "Assistant commands",
|
||||||
"hint": "Type / for shortcuts",
|
|
||||||
"health": "Prepare a service-health check",
|
"health": "Prepare a service-health check",
|
||||||
"explore": "Prepare an overview of this Homarr instance",
|
"explore": "Prepare an overview of this Homarr instance",
|
||||||
"media": "Prepare a media search",
|
"media": "Prepare a media search",
|
||||||
@@ -2555,7 +2527,6 @@
|
|||||||
"otherPlaceholder": "Type another answer…",
|
"otherPlaceholder": "Type another answer…",
|
||||||
"submit": "Send answer",
|
"submit": "Send answer",
|
||||||
"cancelOther": "Cancel other answer",
|
"cancelOther": "Cancel other answer",
|
||||||
"automaticAffirmative": "Choosing the affirmative answer automatically",
|
|
||||||
"optionKind": {
|
"optionKind": {
|
||||||
"affirmative": "Confirm",
|
"affirmative": "Confirm",
|
||||||
"negative": "Decline",
|
"negative": "Decline",
|
||||||
@@ -2630,7 +2601,7 @@
|
|||||||
"autoApproval": {
|
"autoApproval": {
|
||||||
"label": "Automatic approvals",
|
"label": "Automatic approvals",
|
||||||
"title": "Automatic approvals",
|
"title": "Automatic approvals",
|
||||||
"description": "Automatically approve protected changes, use presented form details, and choose answers categorized as affirmative. Neutral selections still wait for you. Your Homarr permissions still apply. This turns off when you switch conversations.",
|
"description": "Automatically prepare forms and approve protected changes for this conversation. Questions still wait for your answer, and your Homarr permissions still apply. This turns off when you switch conversations.",
|
||||||
"enable": "Enable for this conversation",
|
"enable": "Enable for this conversation",
|
||||||
"disable": "Turn off automatic approvals",
|
"disable": "Turn off automatic approvals",
|
||||||
"enabled": "Automatic approvals are on",
|
"enabled": "Automatic approvals are on",
|
||||||
@@ -2794,6 +2765,8 @@
|
|||||||
"needsSetup": "Needs setup",
|
"needsSetup": "Needs setup",
|
||||||
"noConnectionRequired": "No connection required"
|
"noConnectionRequired": "No connection required"
|
||||||
},
|
},
|
||||||
|
"customWidget": "Custom widget",
|
||||||
|
"standalone": "Standalone",
|
||||||
"missingIntegration": {
|
"missingIntegration": {
|
||||||
"title": "Connect a service first",
|
"title": "Connect a service first",
|
||||||
"noPermission": "This widget needs a compatible service connection. Ask an administrator to connect one, then try again."
|
"noPermission": "This widget needs a compatible service connection. Ask an administrator to connect one, then try again."
|
||||||
@@ -2810,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": {
|
"moveResize": {
|
||||||
"title": "Move / resize item",
|
"title": "Move / resize item",
|
||||||
"entryLabel": "{name}, column {column}, row {row}",
|
"entryLabel": "{name}, column {column}, row {row}",
|
||||||
@@ -3449,50 +3436,6 @@
|
|||||||
"notificationsDenied": "Notifications are blocked in this browser."
|
"notificationsDenied": "Notifications are blocked in this browser."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"today": {
|
|
||||||
"name": "Today",
|
|
||||||
"description": "Displays the current date and progress through the calendar.",
|
|
||||||
"option": {
|
|
||||||
"weekConvention": {
|
|
||||||
"label": "Week numbering",
|
|
||||||
"options": {
|
|
||||||
"locale": "Use locale rules",
|
|
||||||
"iso": "ISO 8601"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"showWeekNumber": {
|
|
||||||
"label": "Show week number"
|
|
||||||
},
|
|
||||||
"showDayOfYear": {
|
|
||||||
"label": "Show day of year"
|
|
||||||
},
|
|
||||||
"showQuarter": {
|
|
||||||
"label": "Show quarter"
|
|
||||||
},
|
|
||||||
"showYearProgress": {
|
|
||||||
"label": "Show year progress"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"weekNumber": "Week {week}",
|
|
||||||
"dayOfYear": "Day {day} of {total}",
|
|
||||||
"quarter": "Quarter {quarter}",
|
|
||||||
"daysAfterToday": "{days, plural, =0 {Last day of the year} one {# day after today} other {# days after today}}",
|
|
||||||
"weekProgressLabel": "Week progress",
|
|
||||||
"monthProgressLabel": "Month progress",
|
|
||||||
"yearProgressLabel": "Year progress",
|
|
||||||
"yearProgress": "Year progress: {progress}%",
|
|
||||||
"weekConvention": {
|
|
||||||
"locale": "Week numbers follow your locale",
|
|
||||||
"iso": "ISO 8601 week numbers"
|
|
||||||
},
|
|
||||||
"advanced": {
|
|
||||||
"week": "Week number",
|
|
||||||
"dayOfYear": "Day of year",
|
|
||||||
"quarter": "Quarter",
|
|
||||||
"quarterValue": "Q{quarter}",
|
|
||||||
"daysRemaining": "Days after today"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"minecraftServerStatus": {
|
"minecraftServerStatus": {
|
||||||
"name": "Minecraft Server Status",
|
"name": "Minecraft Server Status",
|
||||||
"description": "Displays the status of a Minecraft server",
|
"description": "Displays the status of a Minecraft server",
|
||||||
@@ -4893,70 +4836,70 @@
|
|||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"columnTitle": "Controls"
|
"columnTitle": "Actions"
|
||||||
},
|
},
|
||||||
"added": {
|
"added": {
|
||||||
"columnTitle": "Added",
|
"columnTitle": "Added",
|
||||||
"detailsTitle": "Date Added"
|
"detailsTitle": "Added"
|
||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"columnTitle": "Extras",
|
"columnTitle": "Category",
|
||||||
"detailsTitle": "Categories (Or extra information)"
|
"detailsTitle": "Category"
|
||||||
},
|
},
|
||||||
"downSpeed": {
|
"downSpeed": {
|
||||||
"columnTitle": "Down",
|
"columnTitle": "Down",
|
||||||
"detailsTitle": "Download Speed"
|
"detailsTitle": "Down"
|
||||||
},
|
},
|
||||||
"index": {
|
"index": {
|
||||||
"columnTitle": "#",
|
"columnTitle": "#",
|
||||||
"detailsTitle": "Current index within client"
|
"detailsTitle": "Index"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"columnTitle": "Id",
|
"columnTitle": "ID",
|
||||||
"detailsTitle": "Item ID"
|
"detailsTitle": "ID"
|
||||||
},
|
},
|
||||||
"integration": {
|
"integration": {
|
||||||
"columnTitle": "Integration"
|
"columnTitle": "Source"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"columnTitle": "Job name"
|
"columnTitle": "Name"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"columnTitle": "Progress",
|
"columnTitle": "Progress",
|
||||||
"detailsTitle": "Download Progress"
|
"detailsTitle": "Progress"
|
||||||
},
|
},
|
||||||
"ratio": {
|
"ratio": {
|
||||||
"columnTitle": "Ratio",
|
"columnTitle": "Ratio",
|
||||||
"detailsTitle": "Torrent ratio (received/sent)"
|
"detailsTitle": "Ratio"
|
||||||
},
|
},
|
||||||
"received": {
|
"received": {
|
||||||
"columnTitle": "Total down",
|
"columnTitle": "Down",
|
||||||
"detailsTitle": "Total downloaded"
|
"detailsTitle": "Downloaded"
|
||||||
},
|
},
|
||||||
"sent": {
|
"sent": {
|
||||||
"columnTitle": "Total up",
|
"columnTitle": "Up",
|
||||||
"detailsTitle": "Total Uploaded"
|
"detailsTitle": "Uploaded"
|
||||||
},
|
},
|
||||||
"size": {
|
"size": {
|
||||||
"columnTitle": "File Size",
|
"columnTitle": "Size",
|
||||||
"detailsTitle": "Total Size of selection/files"
|
"detailsTitle": "Size"
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"columnTitle": "State",
|
"columnTitle": "State",
|
||||||
"detailsTitle": "Job State"
|
"detailsTitle": "State"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"columnTitle": "Finish time",
|
"columnTitle": "ETA",
|
||||||
"detailsTitle": "Time since/to completion",
|
"detailsTitle": "ETA",
|
||||||
"eta": "ETA"
|
"eta": "ETA"
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"columnTitle": "Type",
|
"columnTitle": "Type",
|
||||||
"detailsTitle": "Download Client type"
|
"detailsTitle": "Type"
|
||||||
},
|
},
|
||||||
"upSpeed": {
|
"upSpeed": {
|
||||||
"columnTitle": "Up",
|
"columnTitle": "Up",
|
||||||
"detailsTitle": "Upload Speed"
|
"detailsTitle": "Up"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"states": {
|
"states": {
|
||||||
@@ -6083,7 +6026,13 @@
|
|||||||
"compact": {
|
"compact": {
|
||||||
"title": "Assistant",
|
"title": "Assistant",
|
||||||
"description": "Resize this widget to chat here, or open the full conversation.",
|
"description": "Resize this widget to chat here, or open the full conversation.",
|
||||||
"open": "Open assistant"
|
"open": "Open assistant",
|
||||||
|
"openTitle": "Assistant open in side panel",
|
||||||
|
"openDescription": "The conversation stays in one active surface.",
|
||||||
|
"returnToWidget": "Return the conversation to this widget",
|
||||||
|
"otherWidgetTitle": "Assistant active in another widget",
|
||||||
|
"otherWidgetDescription": "Only one conversation surface stays active at a time.",
|
||||||
|
"useHere": "Use the assistant in this widget"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -8724,6 +8673,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"log": {
|
"log": {
|
||||||
|
"context": {
|
||||||
|
"widgetError": "Widget reported an error around this time",
|
||||||
|
"expiredWidgetError": "Logs around this widget error are no longer retained"
|
||||||
|
},
|
||||||
"level": {
|
"level": {
|
||||||
"option": {
|
"option": {
|
||||||
"debug": "Debug",
|
"debug": "Debug",
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export { CustomPasswordInput } from "./password-input/password-input";
|
|||||||
export { IntegrationAvatar } from "./integration-avatar";
|
export { IntegrationAvatar } from "./integration-avatar";
|
||||||
export { BetaBadge } from "./beta-badge";
|
export { BetaBadge } from "./beta-badge";
|
||||||
export { CatalogItem } from "./catalog-item";
|
export { CatalogItem } from "./catalog-item";
|
||||||
|
export { SelectableCard } from "./selectable-card";
|
||||||
|
export type {
|
||||||
|
SelectableCardProps,
|
||||||
|
SelectableCardStylesNames,
|
||||||
|
SelectableCardCssVariables,
|
||||||
|
SelectableCardFactory,
|
||||||
|
} from "./selectable-card";
|
||||||
export { MaskedImage } from "./masked-image";
|
export { MaskedImage } from "./masked-image";
|
||||||
export { MaskedOrNormalImage } from "./masked-or-normal-image";
|
export { MaskedOrNormalImage } from "./masked-or-normal-image";
|
||||||
export { LanguageIcon } from "./language-icon";
|
export { LanguageIcon } from "./language-icon";
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
import { IconPuzzle } from "@tabler/icons-react";
|
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 { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { IntegrationAvatar } from "../components/integration-avatar";
|
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 { 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 {
|
export interface IntegrationSelectGridProps {
|
||||||
onSelect: (kind: IntegrationKind) => void;
|
onSelect: (kind: IntegrationKind) => void;
|
||||||
enableMockIntegration?: boolean;
|
enableMockIntegration?: boolean;
|
||||||
allowedKinds?: readonly IntegrationKind[];
|
allowedKinds?: readonly IntegrationKind[];
|
||||||
|
integrationData?: { kind: IntegrationKind; name?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IntegrationSelectGrid = ({
|
export const IntegrationSelectGrid = ({
|
||||||
onSelect,
|
onSelect,
|
||||||
enableMockIntegration = false,
|
enableMockIntegration = false,
|
||||||
allowedKinds,
|
allowedKinds,
|
||||||
|
integrationData,
|
||||||
}: IntegrationSelectGridProps) => {
|
}: IntegrationSelectGridProps) => {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -32,75 +73,182 @@ export const IntegrationSelectGrid = ({
|
|||||||
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectGridLayout
|
<Stack gap="md">
|
||||||
search={search}
|
{/* Top Search Input */}
|
||||||
onSearchChange={setSearch}
|
<Input
|
||||||
placeholder={`${t("integration.page.list.search")}...`}
|
value={search}
|
||||||
ariaLabel={t("integration.page.list.search")}
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
>
|
leftSection={<IconSearch size={16} />}
|
||||||
{filtered.map((integration) => (
|
placeholder={`${t("integration.page.list.search")}...`}
|
||||||
<UnstyledButton
|
aria-label={t("integration.page.list.search")}
|
||||||
className={classes.choice}
|
data-autofocus
|
||||||
key={integration.kind}
|
onKeyDown={(event) => {
|
||||||
onClick={() => onSelect(integration.kind)}
|
if (event.key === "Enter" && filtered.length === 1 && filtered[0]) {
|
||||||
aria-label={integration.name}
|
onSelect(filtered[0].kind);
|
||||||
>
|
|
||||||
<Card h={selectGridCardHeight} className={classes.card} withBorder>
|
|
||||||
<Stack justify="space-between" h="100%" gap="xs">
|
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
|
||||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
|
||||||
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
|
|
||||||
{integration.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<IntegrationGridMeta integration={integration} />
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</UnstyledButton>
|
|
||||||
))}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<Center p="xl" style={{ gridColumn: "1 / -1" }}>
|
|
||||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
</SelectGridLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const IntegrationGridMeta = ({ integration }: { integration: ReturnType<typeof buildSortedIntegrations>[number] }) => {
|
|
||||||
const t = useI18n();
|
|
||||||
return (
|
|
||||||
<Stack gap={4} mt="auto">
|
|
||||||
<Group gap={4} wrap="wrap">
|
|
||||||
{integration.categories.slice(0, 2).map((category) => (
|
|
||||||
<Badge key={category} variant="light" size="xs">
|
|
||||||
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
{integration.widgets.length > 0 ? (
|
|
||||||
<Tooltip
|
|
||||||
multiline
|
|
||||||
w={200}
|
|
||||||
label={
|
|
||||||
<Stack gap={4}>
|
|
||||||
{integration.widgets.map((widgetKind) => (
|
|
||||||
<Text key={widgetKind} size="xs">
|
|
||||||
{t(`widget.${widgetKind}.name`)}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
}
|
}
|
||||||
>
|
}}
|
||||||
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
|
/>
|
||||||
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
|
|
||||||
</Badge>
|
{/* Scrollable Container with Integration Cards */}
|
||||||
</Tooltip>
|
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
|
||||||
) : (
|
<Stack gap="md" pt="xs" pr="xs" px={4}>
|
||||||
<Badge variant="light" color="gray" size="sm">
|
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||||
{t("integration.grid.noWidgets")}
|
{filtered.map((integration) => (
|
||||||
</Badge>
|
<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>
|
</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": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./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/component": "./src/custom-api/component.tsx",
|
||||||
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
|
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
|
||||||
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
|
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import type { CommonWidgetInputProps } from "./common";
|
|||||||
import { useWidgetInputTranslation } from "./common";
|
import { useWidgetInputTranslation } from "./common";
|
||||||
import { useFormContext } from "./form";
|
import { useFormContext } from "./form";
|
||||||
|
|
||||||
|
const renderHereLink = (children: React.ReactNode) => (
|
||||||
|
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
||||||
|
{children}
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
|
||||||
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
|
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tInput = useWidgetInputTranslation(kind, property);
|
const tInput = useWidgetInputTranslation(kind, property);
|
||||||
@@ -49,13 +55,9 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
|
|||||||
}
|
}
|
||||||
inputWrapperOrder={["label", "input", "description", "error"]}
|
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||||
description={
|
description={
|
||||||
<Text size="xs">
|
<Text component="span" size="xs">
|
||||||
{t.rich("widget.common.app.description", {
|
{t.rich("widget.common.app.description", {
|
||||||
here: () => (
|
here: renderHereLink,
|
||||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
|
||||||
{t("common.here")}
|
|
||||||
</Anchor>
|
|
||||||
),
|
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
|||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
|
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
||||||
const app = getUsableWidgetQueryData(appQuery);
|
const app = getUsableWidgetQueryData(appQuery);
|
||||||
const href = getSafeAppHref(app?.href);
|
const href = getSafeAppHref(app?.href);
|
||||||
useRegisterSpotlightContextResults(
|
useRegisterSpotlightContextResults(
|
||||||
@@ -50,6 +50,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
|||||||
[app, href, options.openInNewTab],
|
[app, href, options.openInNewTab],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!options.appId) return <WidgetEmptyState />;
|
||||||
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
|
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
|
||||||
if (!app) return <WidgetEmptyState />;
|
if (!app) return <WidgetEmptyState />;
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user