Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f86eb5731 | ||
|
|
88541b013d |
No files matched your search
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: homarr-local-harness
|
||||||
|
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Homarr Local Harness
|
||||||
|
|
||||||
|
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
|
||||||
|
|
||||||
|
## Set up the harness
|
||||||
|
|
||||||
|
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness setup feature-board
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
|
||||||
|
|
||||||
|
- `DEMO_MODE=true`
|
||||||
|
- `DEMO_READ_ONLY=false`
|
||||||
|
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
|
||||||
|
|
||||||
|
Pass repeatable environment overrides when the feature needs them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness setup feature-board \
|
||||||
|
--env WORKSHOP_WEB_URL=https://example.test \
|
||||||
|
--env FEATURE_FLAG=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
|
||||||
|
|
||||||
|
Always use the URL printed by the command. If the output is no longer available, query it directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness port feature-board
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
|
||||||
|
|
||||||
|
## Browser validation
|
||||||
|
|
||||||
|
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
agent-browser skills get core
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
Stop the named container when finished:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness stop feature-board
|
||||||
|
```
|
||||||
|
|
||||||
|
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Homarr Local Harness"
|
||||||
|
short_description: "Build and browse a writable local Homarr branch harness"
|
||||||
|
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
|
||||||
@@ -30,26 +30,6 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"reportUnusedDisableDirectives": "off"
|
"reportUnusedDisableDirectives": "off"
|
||||||
},
|
},
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": ["apps/nextjs/src/**/*.ts", "apps/nextjs/src/**/*.tsx"],
|
|
||||||
"excludeFiles": ["**/*.spec.ts", "**/*.spec.tsx"],
|
|
||||||
"rules": {
|
|
||||||
"eslint/no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "@homarr/widgets",
|
|
||||||
"allowTypeImports": true,
|
|
||||||
"message": "Import a narrow @homarr/widgets export to preserve widget lazy loading."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"**/*.config.js",
|
"**/*.config.js",
|
||||||
"**/*.config.mjs",
|
"**/*.config.mjs",
|
||||||
|
|||||||
@@ -8,28 +8,13 @@ Homarr has the following keyboard shortcuts:
|
|||||||
|
|
||||||
| Shortcut | Description |
|
| Shortcut | Description |
|
||||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `MOD` + `K` | Opens search, including local apps, boards, integrations, pages, commands, and preferences |
|
| `MOD` + `K` | Opens the search bar |
|
||||||
| `MOD` + `P` | Opens app search |
|
|
||||||
| `MOD` + `/` | Opens Homarr Assistant inside search |
|
|
||||||
| `MOD` + `Shift` + `/` (`MOD` + `?`) | Opens preference search |
|
|
||||||
| `Shift` + `A` | Opens the full Homarr Assistant |
|
|
||||||
| `MOD` + `J` | Toggles Dark Mode |
|
| `MOD` + `J` | Toggles Dark Mode |
|
||||||
| `MOD` + `E` | Toggles Edit Mode |
|
| `MOD` + `E` | Toggles Edit Mode |
|
||||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||||
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
|
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
|
||||||
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
|
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
|
||||||
|
|
||||||
## Search and commands
|
|
||||||
|
|
||||||
Search is designed as the fastest route to Homarr. Start typing after `MOD` + `K` to find local apps, boards,
|
|
||||||
integrations, pages, commands, and preferences without entering a separate mode first. Local Homarr results appear
|
|
||||||
before configured search-engine actions and Assistant fallbacks. Commands include opening the board switcher without
|
|
||||||
leaving the keyboard. Type `!` to enter Web mode when you want to browse configured engine shortcuts or discover
|
|
||||||
DuckDuckGo bangs; bang discovery is excluded from ordinary Search results.
|
|
||||||
|
|
||||||
Use the dedicated shortcuts above to start in apps, Assistant, or preferences. Inside search, choose a mode to narrow
|
|
||||||
the same query when needed. Media request search is available only when a supported Seerr integration is configured.
|
|
||||||
|
|
||||||
## Widget advanced view
|
## Widget advanced view
|
||||||
|
|
||||||
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
|
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
|
||||||
|
|||||||
@@ -198,13 +198,9 @@ In the modal you can edit the app that you want to display:
|
|||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
||||||
<figcaption>
|
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
|
||||||
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
When adding a widget, set its initial grid width and height directly in the preview. These size controls are only shown before the widget is added.
|
|
||||||
|
|
||||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||||
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
|
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
|
||||||
Press and hold a handle, move the pointer, and release it when the tile has the size you want.
|
Press and hold a handle, move the pointer, and release it when the tile has the size you want.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 68 KiB |
@@ -38,7 +38,7 @@ host gateway address.
|
|||||||
## Use it
|
## Use it
|
||||||
|
|
||||||
- Select **Assistant** in the user menu or press `Shift + A`.
|
- Select **Assistant** in the user menu or press `Shift + A`.
|
||||||
- Press `Cmd/Ctrl + /` to ask from search, or use `Cmd/Ctrl + K` and choose the Assistant fallback.
|
- Ask from `Cmd/Ctrl + K`.
|
||||||
- Add the [Assistant widget](../widgets/assistant) to a board.
|
- Add the [Assistant widget](../widgets/assistant) to a board.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -27,10 +27,9 @@ opening it. Containers include their visible widgets and app icons instead of ap
|
|||||||
|
|
||||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||||
|
|
||||||
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
|
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows
|
||||||
previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
|
structural previews of the other boards you can access. Start typing to filter by name, use the arrow keys to move
|
||||||
match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the
|
through the results, and press `Enter` to open the selected board.
|
||||||
arrow keys to move through the results and press `Enter` to open the selected board.
|
|
||||||
|
|
||||||
## Create a new board
|
## Create a new board
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,9 @@ tags:
|
|||||||
|
|
||||||
# Search Engines
|
# Search Engines
|
||||||
|
|
||||||
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `MOD + K`.
|
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`.
|
||||||
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
|
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
|
||||||
|
|
||||||
The default **Search** mode lists every configured search engine as a fallback action for the current query. To discover
|
|
||||||
engines by shortcut, switch to **Web** with `!`. DuckDuckGo bang discovery is intentionally limited to Web mode so an
|
|
||||||
ordinary search term never expands into similarly named bang providers.
|
|
||||||
|
|
||||||
You can add any search engine that has a search URL that accepts a search term as a parameter.
|
You can add any search engine that has a search URL that accepts a search term as a parameter.
|
||||||
|
|
||||||
## Create search engine
|
## Create search engine
|
||||||
@@ -30,8 +26,6 @@ The name can be chosen freely. It will be displayed in the search box popover.
|
|||||||
|
|
||||||
Prefix that is used to choose the search engine in the popover using the keyboard.
|
Prefix that is used to choose the search engine in the popover using the keyboard.
|
||||||
It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia).
|
It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia).
|
||||||
Type `!` in Spotlight to browse these shortcuts and, when DuckDuckGo bangs are enabled in preferences, discover
|
|
||||||
additional bang providers.
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Bookmarks"
|
title: "Bookmarks"
|
||||||
description: "Keeps useful links together"
|
description: "Displays multiple app links"
|
||||||
hide_title: true
|
hide_title: true
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,12 +13,11 @@ import { bookmarksWidget } from ".";
|
|||||||
|
|
||||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||||
|
|
||||||
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
|
The bookmarks widget allows you to display multiple links in a single widget.
|
||||||
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
|
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
|
||||||
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one.
|
For links that do not need an App, add their URLs under **Custom links** instead.
|
||||||
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
|
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
|
||||||
|
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
|
||||||
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
|
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
|
|||||||
export const bookmarksWidget: WidgetDefinition = {
|
export const bookmarksWidget: WidgetDefinition = {
|
||||||
icon: IconBookmark,
|
icon: IconBookmark,
|
||||||
name: "Bookmarks",
|
name: "Bookmarks",
|
||||||
description: "Keeps useful links together",
|
description: "Displays multiple app links",
|
||||||
path: "../../widgets/bookmarks",
|
path: "../../widgets/bookmarks",
|
||||||
configuration: {
|
configuration: {
|
||||||
items: [
|
items: [
|
||||||
@@ -16,21 +16,9 @@ export const bookmarksWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
description: "How bookmarks use the available widget space.",
|
description: "Layout in which the bookmarks are displayed.",
|
||||||
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
|
||||||
defaultValue: "Adaptive",
|
defaultValue: "Vertical",
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Appearance",
|
|
||||||
description: "Card surface style.",
|
|
||||||
values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] },
|
|
||||||
defaultValue: "Soft",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Card spacing",
|
|
||||||
description: "Space between bookmark cards.",
|
|
||||||
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
|
|
||||||
defaultValue: "Extra small",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Hide title",
|
name: "Hide title",
|
||||||
@@ -58,10 +46,16 @@ export const bookmarksWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Bookmarks",
|
name: "Bookmarks",
|
||||||
description: "URLs and existing Apps shown by the widget.",
|
description: "List of bookmarks to display in the widget.",
|
||||||
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
|
values: "Select one or more Apps directly, then order them as you like.",
|
||||||
defaultValue: "No bookmarks selected",
|
defaultValue: "No bookmarks selected",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Custom links",
|
||||||
|
description: "URLs to display without first creating an App.",
|
||||||
|
values: "List of URLs.",
|
||||||
|
defaultValue: "No custom links",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -76,7 +76,6 @@
|
|||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-query-next-experimental": "catalog:",
|
"@tanstack/react-query-next-experimental": "catalog:",
|
||||||
"@tanstack/react-query-persist-client": "catalog:",
|
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/next": "catalog:",
|
"@trpc/next": "catalog:",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" role="img" aria-labelledby="title description">
|
|
||||||
<title id="title">Abstract homelab network</title>
|
|
||||||
<description id="description">A dark indigo field with subtle connected nodes and cyan signal paths.</description>
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="base" cx="50%" cy="40%" r="80%">
|
|
||||||
<stop offset="0" stop-color="#172047"/>
|
|
||||||
<stop offset="0.48" stop-color="#0b1028"/>
|
|
||||||
<stop offset="1" stop-color="#060814"/>
|
|
||||||
</radialGradient>
|
|
||||||
<radialGradient id="indigoGlow">
|
|
||||||
<stop offset="0" stop-color="#748ffc" stop-opacity="0.32"/>
|
|
||||||
<stop offset="1" stop-color="#748ffc" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
<radialGradient id="cyanGlow">
|
|
||||||
<stop offset="0" stop-color="#3bc9db" stop-opacity="0.22"/>
|
|
||||||
<stop offset="1" stop-color="#3bc9db" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop stop-color="#748ffc" stop-opacity="0.05"/>
|
|
||||||
<stop offset="0.5" stop-color="#748ffc" stop-opacity="0.34"/>
|
|
||||||
<stop offset="1" stop-color="#3bc9db" stop-opacity="0.05"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="blur">
|
|
||||||
<feGaussianBlur stdDeviation="55"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<rect width="1920" height="1080" fill="url(#base)"/>
|
|
||||||
<ellipse cx="330" cy="90" rx="560" ry="430" fill="url(#indigoGlow)" filter="url(#blur)"/>
|
|
||||||
<ellipse cx="1680" cy="940" rx="620" ry="400" fill="url(#cyanGlow)" filter="url(#blur)"/>
|
|
||||||
<g fill="none" stroke="url(#signal)" stroke-width="1.5">
|
|
||||||
<path d="M-80 825C240 570 475 760 720 510s470-165 650-330 330-85 630-250"/>
|
|
||||||
<path d="M-120 1010C220 740 470 920 780 660s520-105 700-270 300-125 560-180"/>
|
|
||||||
<path d="M110 40c180 190 350 220 545 130s390-55 520 80 320 155 620 60" opacity=".45"/>
|
|
||||||
</g>
|
|
||||||
<g fill="#9aa8ff" opacity=".48">
|
|
||||||
<circle cx="248" cy="660" r="3"/>
|
|
||||||
<circle cx="718" cy="511" r="4"/>
|
|
||||||
<circle cx="1118" cy="287" r="3"/>
|
|
||||||
<circle cx="1450" cy="110" r="4"/>
|
|
||||||
</g>
|
|
||||||
<g fill="#5ee0ee" opacity=".42">
|
|
||||||
<circle cx="435" cy="850" r="3"/>
|
|
||||||
<circle cx="908" cy="604" r="4"/>
|
|
||||||
<circle cx="1350" cy="510" r="3"/>
|
|
||||||
<circle cx="1715" cy="285" r="4"/>
|
|
||||||
</g>
|
|
||||||
<g fill="none" stroke="#aeb8ff" stroke-opacity=".055">
|
|
||||||
<circle cx="330" cy="90" r="260"/>
|
|
||||||
<circle cx="330" cy="90" r="345"/>
|
|
||||||
<circle cx="1680" cy="940" r="250"/>
|
|
||||||
<circle cx="1680" cy="940" r="335"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,350 +0,0 @@
|
|||||||
import { dehydrate, QueryClient } from "@tanstack/react-query";
|
|
||||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
|
||||||
import { persistQueryClientRestore } from "@tanstack/react-query-persist-client";
|
|
||||||
import { parse, stringify } from "superjson";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createSessionQueryPersister,
|
|
||||||
getQueryPersistenceStorageKey,
|
|
||||||
queryPersistenceBuster,
|
|
||||||
queryPersistenceMaxDataBytes,
|
|
||||||
shouldPersistDashboardQuery,
|
|
||||||
} from "./query-persistence";
|
|
||||||
|
|
||||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
queryClient.setQueryData(queryKey, data, { updatedAt });
|
|
||||||
const query = queryClient.getQueryCache().find({ queryKey });
|
|
||||||
if (!query) throw new Error("Expected query to be present");
|
|
||||||
return { query, queryClient };
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMemoryStorage = (): Storage => {
|
|
||||||
const values = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
get length() {
|
|
||||||
return values.size;
|
|
||||||
},
|
|
||||||
clear: () => values.clear(),
|
|
||||||
getItem: (key) => values.get(key) ?? null,
|
|
||||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
|
||||||
removeItem: (key) => values.delete(key),
|
|
||||||
setItem: (key, value) => values.set(key, value),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
window.sessionStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("widget query persistence", () => {
|
|
||||||
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
|
|
||||||
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
|
|
||||||
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
|
|
||||||
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
|
|
||||||
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
|
||||||
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
|
|
||||||
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
|
|
||||||
});
|
|
||||||
const pendingClient = new QueryClient();
|
|
||||||
const pending = pendingClient.getQueryCache().build(pendingClient, {
|
|
||||||
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
|
||||||
queryFn: () => Promise.resolve([]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
|
|
||||||
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
|
|
||||||
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
|
||||||
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
|
||||||
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
|
|
||||||
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
|
|
||||||
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
|
|
||||||
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
|
|
||||||
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
|
|
||||||
|
|
||||||
const userAKey = getQueryPersistenceStorageKey("user-a");
|
|
||||||
window.sessionStorage.setItem(userAKey, "a");
|
|
||||||
createSessionQueryPersister("user-a").removeClient();
|
|
||||||
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("evicts every other scope so signing out cannot leave data behind", () => {
|
|
||||||
const userAKey = getQueryPersistenceStorageKey("user-a");
|
|
||||||
const anonymousKey = getQueryPersistenceStorageKey(null);
|
|
||||||
window.sessionStorage.setItem(userAKey, "a");
|
|
||||||
window.sessionStorage.setItem(anonymousKey, "anonymous");
|
|
||||||
window.sessionStorage.setItem("unrelated", "kept");
|
|
||||||
|
|
||||||
createSessionQueryPersister("user-b");
|
|
||||||
|
|
||||||
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
|
||||||
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
|
|
||||||
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("never restores another session scope", async () => {
|
|
||||||
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
|
||||||
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
|
|
||||||
const userAPersister = createSessionQueryPersister("user-a");
|
|
||||||
userAPersister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(queryClient),
|
|
||||||
});
|
|
||||||
userAPersister.flush();
|
|
||||||
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
|
|
||||||
|
|
||||||
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stops persisting once the provider tears the cache down", async () => {
|
|
||||||
const storage = createMemoryStorage();
|
|
||||||
const persister = createSessionQueryPersister("user-a", storage);
|
|
||||||
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
|
||||||
temp: 21,
|
|
||||||
});
|
|
||||||
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(queryClient),
|
|
||||||
});
|
|
||||||
persister.flush();
|
|
||||||
persister.stop();
|
|
||||||
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: { mutations: [], queries: [] },
|
|
||||||
});
|
|
||||||
persister.flush();
|
|
||||||
|
|
||||||
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removes the cache instead of storing an empty one", () => {
|
|
||||||
const scope = "user-a";
|
|
||||||
const key = getQueryPersistenceStorageKey(scope);
|
|
||||||
const persister = createSessionQueryPersister(scope);
|
|
||||||
window.sessionStorage.setItem(key, "stale");
|
|
||||||
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: { mutations: [], queries: [] },
|
|
||||||
});
|
|
||||||
persister.flush();
|
|
||||||
|
|
||||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const scope = "user-a";
|
|
||||||
const key = getQueryPersistenceStorageKey(scope);
|
|
||||||
const persister = createSessionQueryPersister(scope);
|
|
||||||
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
|
||||||
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
|
||||||
});
|
|
||||||
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
|
|
||||||
expect(shouldPersistDashboardQuery(query)).toBe(true);
|
|
||||||
const persistedClient: PersistedClient = {
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
|
|
||||||
};
|
|
||||||
|
|
||||||
persister.persistClient(persistedClient);
|
|
||||||
persister.flush();
|
|
||||||
const restored = await persister.restoreClient();
|
|
||||||
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
|
|
||||||
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
|
|
||||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
|
||||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
|
||||||
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
|
||||||
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
|
||||||
|
|
||||||
window.sessionStorage.setItem(key, "not-valid-superjson");
|
|
||||||
expect(persister.restoreClient()).toBeUndefined();
|
|
||||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
|
|
||||||
const scope = "user-a";
|
|
||||||
const key = getQueryPersistenceStorageKey(scope);
|
|
||||||
const { queryClient } = createSuccessfulQuery(
|
|
||||||
[["widget", "calendar", "findAllEvents"], { type: "query" }],
|
|
||||||
[{ title: "safe" }],
|
|
||||||
);
|
|
||||||
const validQuery = dehydrate(queryClient).queries[0];
|
|
||||||
if (!validQuery) throw new Error("Expected a dehydrated query");
|
|
||||||
|
|
||||||
window.sessionStorage.setItem(
|
|
||||||
key,
|
|
||||||
stringify({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: {
|
|
||||||
mutations: [{ state: { status: "pending" } }],
|
|
||||||
queries: [
|
|
||||||
{
|
|
||||||
...validQuery,
|
|
||||||
state: {
|
|
||||||
...validQuery.state,
|
|
||||||
error: new Error("private refresh error"),
|
|
||||||
fetchStatus: "fetching",
|
|
||||||
status: "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...validQuery,
|
|
||||||
queryHash: "unrelated",
|
|
||||||
queryKey: [["board", "getBoardByName"], { type: "query" }],
|
|
||||||
},
|
|
||||||
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
|
|
||||||
{
|
|
||||||
...validQuery,
|
|
||||||
queryHash: "missing-timestamp",
|
|
||||||
state: { ...validQuery.state, dataUpdatedAt: undefined },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const restored = await createSessionQueryPersister(scope).restoreClient();
|
|
||||||
expect(restored?.clientState.mutations).toEqual([]);
|
|
||||||
expect(restored?.clientState.queries).toHaveLength(1);
|
|
||||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
|
||||||
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
|
|
||||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["expired", 1, queryPersistenceBuster],
|
|
||||||
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
|
|
||||||
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
|
|
||||||
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
|
|
||||||
const scope = "user-a";
|
|
||||||
const persister = createSessionQueryPersister(scope);
|
|
||||||
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
|
||||||
temp: 21,
|
|
||||||
});
|
|
||||||
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
|
|
||||||
persister.flush();
|
|
||||||
|
|
||||||
const restoredClient = new QueryClient();
|
|
||||||
await persistQueryClientRestore({
|
|
||||||
queryClient: restoredClient,
|
|
||||||
persister,
|
|
||||||
maxAge: 5 * 60 * 1_000,
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
|
|
||||||
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("quota retry writes the cache after evicting the oldest query", async () => {
|
|
||||||
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
|
|
||||||
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
|
|
||||||
const storage = createMemoryStorage();
|
|
||||||
const setItem = storage.setItem.bind(storage);
|
|
||||||
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
|
|
||||||
const client = parse<PersistedClient>(value);
|
|
||||||
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
|
|
||||||
setItem(key, value);
|
|
||||||
});
|
|
||||||
const persistedClient: PersistedClient = {
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: {
|
|
||||||
mutations: [],
|
|
||||||
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const persister = createSessionQueryPersister("user-a", storage);
|
|
||||||
|
|
||||||
persister.persistClient(persistedClient);
|
|
||||||
persister.flush();
|
|
||||||
|
|
||||||
expect(setItemSpy).toHaveBeenCalledTimes(2);
|
|
||||||
const restored = await persister.restoreClient();
|
|
||||||
expect(restored?.clientState.queries).toHaveLength(1);
|
|
||||||
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const storage = createMemoryStorage();
|
|
||||||
const setItem = vi.spyOn(storage, "setItem");
|
|
||||||
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
|
||||||
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
|
|
||||||
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
|
|
||||||
const persister = createSessionQueryPersister("user-a", storage);
|
|
||||||
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(first.queryClient),
|
|
||||||
});
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(latest.queryClient),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(setItem).not.toHaveBeenCalled();
|
|
||||||
persister.flush();
|
|
||||||
expect(setItem).toHaveBeenCalledOnce();
|
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
|
||||||
expect(setItem).toHaveBeenCalledOnce();
|
|
||||||
|
|
||||||
const restored = await persister.restoreClient();
|
|
||||||
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats blocked storage operations as an empty cache", async () => {
|
|
||||||
const blockedStorage = {
|
|
||||||
getItem: () => {
|
|
||||||
throw new DOMException("blocked", "SecurityError");
|
|
||||||
},
|
|
||||||
removeItem: () => {
|
|
||||||
throw new DOMException("blocked", "SecurityError");
|
|
||||||
},
|
|
||||||
setItem: () => {
|
|
||||||
throw new DOMException("blocked", "SecurityError");
|
|
||||||
},
|
|
||||||
} as unknown as Storage;
|
|
||||||
const persister = createSessionQueryPersister("blocked", blockedStorage);
|
|
||||||
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
|
|
||||||
|
|
||||||
expect(persister.restoreClient()).toBeUndefined();
|
|
||||||
expect(() => {
|
|
||||||
persister.persistClient({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
clientState: dehydrate(queryClient),
|
|
||||||
});
|
|
||||||
persister.flush();
|
|
||||||
}).not.toThrow();
|
|
||||||
expect(() => persister.removeClient()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats an unavailable session storage getter as an empty cache", async () => {
|
|
||||||
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
|
|
||||||
throw new DOMException("blocked", "SecurityError");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
|
|
||||||
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import type { QueryKey } from "@tanstack/react-query";
|
|
||||||
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
|
||||||
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
|
||||||
import { parse, stringify } from "superjson";
|
|
||||||
|
|
||||||
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
|
||||||
|
|
||||||
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
|
||||||
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
|
||||||
export const queryPersistenceBuster = "v2-session-superjson";
|
|
||||||
|
|
||||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
|
||||||
|
|
||||||
export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
|
||||||
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
|
|
||||||
|
|
||||||
interface PersistableQuery {
|
|
||||||
queryKey: QueryKey;
|
|
||||||
state: {
|
|
||||||
data: unknown;
|
|
||||||
dataUpdatedAt: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
|
|
||||||
|
|
||||||
const textEncoder = new TextEncoder();
|
|
||||||
const budgetVerdictCache = new WeakMap<object, boolean>();
|
|
||||||
|
|
||||||
const measureFitsBudget = (data: unknown) => {
|
|
||||||
try {
|
|
||||||
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fitsStorageBudget = (data: unknown) => {
|
|
||||||
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
|
|
||||||
|
|
||||||
const cached = budgetVerdictCache.get(data);
|
|
||||||
if (cached !== undefined) return cached;
|
|
||||||
|
|
||||||
const verdict = measureFitsBudget(data);
|
|
||||||
budgetVerdictCache.set(data, verdict);
|
|
||||||
return verdict;
|
|
||||||
};
|
|
||||||
|
|
||||||
const holdsDashboardData = (query: PersistableQuery) =>
|
|
||||||
query.state.data !== undefined &&
|
|
||||||
Number.isFinite(query.state.dataUpdatedAt) &&
|
|
||||||
query.state.dataUpdatedAt > 0 &&
|
|
||||||
isWidgetDataQueryKey(query.queryKey);
|
|
||||||
|
|
||||||
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
|
|
||||||
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
|
|
||||||
|
|
||||||
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
|
|
||||||
|
|
||||||
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
|
||||||
...query,
|
|
||||||
state: {
|
|
||||||
...query.state,
|
|
||||||
error: null,
|
|
||||||
errorUpdateCount: 0,
|
|
||||||
errorUpdatedAt: 0,
|
|
||||||
fetchFailureCount: 0,
|
|
||||||
fetchFailureReason: null,
|
|
||||||
fetchStatus: "idle",
|
|
||||||
isInvalidated: true,
|
|
||||||
status: "success",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
|
|
||||||
...client,
|
|
||||||
clientState: {
|
|
||||||
mutations: [],
|
|
||||||
queries: client.clientState.queries.map(sanitizePersistedQuery),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
|
|
||||||
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
|
|
||||||
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
|
|
||||||
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
|
|
||||||
|
|
||||||
return holdsDashboardData(value as unknown as PersistableQuery);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
|
|
||||||
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
|
|
||||||
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: value.timestamp as number,
|
|
||||||
buster: value.buster,
|
|
||||||
clientState: {
|
|
||||||
mutations: [],
|
|
||||||
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSessionStorage = (): Storage | undefined => {
|
|
||||||
if (typeof window === "undefined") return undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return window.sessionStorage;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeStorageItem = (storage: Storage | undefined, key: string) => {
|
|
||||||
try {
|
|
||||||
storage?.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
|
|
||||||
if (!storage) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const foreignKeys: string[] = [];
|
|
||||||
for (let index = 0; index < storage.length; index += 1) {
|
|
||||||
const storedKey = storage.key(index);
|
|
||||||
if (storedKey === null || storedKey === activeKey) continue;
|
|
||||||
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createSessionQueryPersister = (
|
|
||||||
scope: string | null,
|
|
||||||
storage: Storage | undefined = getSessionStorage(),
|
|
||||||
): SessionQueryPersister => {
|
|
||||||
const key = getQueryPersistenceStorageKey(scope);
|
|
||||||
removeForeignScopes(storage, key);
|
|
||||||
let pendingClient: PersistedClient | undefined;
|
|
||||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
let stopped = false;
|
|
||||||
|
|
||||||
const persistNow = (client: PersistedClient) => {
|
|
||||||
if (!storage) return;
|
|
||||||
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
|
|
||||||
let errorCount = 0;
|
|
||||||
|
|
||||||
if (candidate.clientState.queries.length === 0) {
|
|
||||||
removeStorageItem(storage, key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (candidate) {
|
|
||||||
try {
|
|
||||||
storage.setItem(key, stringify(candidate));
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
errorCount += 1;
|
|
||||||
candidate = removeOldestQuery({
|
|
||||||
persistedClient: candidate,
|
|
||||||
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
|
|
||||||
errorCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearPersistTimer = () => {
|
|
||||||
if (persistTimer === undefined) return;
|
|
||||||
clearTimeout(persistTimer);
|
|
||||||
persistTimer = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const flush = () => {
|
|
||||||
clearPersistTimer();
|
|
||||||
const client = pendingClient;
|
|
||||||
pendingClient = undefined;
|
|
||||||
if (client) persistNow(client);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
persistClient(client) {
|
|
||||||
if (!storage || stopped) return;
|
|
||||||
pendingClient = client;
|
|
||||||
if (persistTimer !== undefined) return;
|
|
||||||
persistTimer = setTimeout(flush, 0);
|
|
||||||
},
|
|
||||||
restoreClient() {
|
|
||||||
try {
|
|
||||||
const storedValue = storage?.getItem(key);
|
|
||||||
if (!storedValue) return undefined;
|
|
||||||
|
|
||||||
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
|
|
||||||
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
|
|
||||||
removeStorageItem(storage, key);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return client;
|
|
||||||
} catch {
|
|
||||||
removeStorageItem(storage, key);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
removeClient() {
|
|
||||||
clearPersistTimer();
|
|
||||||
pendingClient = undefined;
|
|
||||||
removeStorageItem(storage, key);
|
|
||||||
},
|
|
||||||
flush,
|
|
||||||
stop() {
|
|
||||||
stopped = true;
|
|
||||||
clearPersistTimer();
|
|
||||||
pendingClient = undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
|
||||||
import {
|
import {
|
||||||
createWSClient,
|
createWSClient,
|
||||||
httpBatchStreamLink,
|
httpBatchStreamLink,
|
||||||
@@ -36,13 +35,6 @@ import { showWarningNotification } from "@homarr/notifications";
|
|||||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||||
|
|
||||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||||
import {
|
|
||||||
createSessionQueryPersister,
|
|
||||||
queryPersistenceBuster,
|
|
||||||
queryPersistenceMaxAgeMs,
|
|
||||||
shouldPersistDashboardQuery,
|
|
||||||
} from "./query-persistence";
|
|
||||||
import type { SessionQueryPersister } from "./query-persistence";
|
|
||||||
import { createQueryRetry } from "./query-retry";
|
import { createQueryRetry } from "./query-retry";
|
||||||
|
|
||||||
const DevelopmentTools =
|
const DevelopmentTools =
|
||||||
@@ -75,29 +67,21 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const sessionQueryScope = getSessionQueryScope(session);
|
const sessionQueryScope = getSessionQueryScope(session);
|
||||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||||
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
|
||||||
const handleScopeChange = useCallback(() => {
|
|
||||||
void queryPersister.removeClient();
|
|
||||||
reloadPage();
|
|
||||||
}, [queryPersister]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionQueryScopeGuard
|
<SessionQueryScopeGuard
|
||||||
initialScope={initialSessionQueryScope}
|
initialScope={initialSessionQueryScope}
|
||||||
currentScope={sessionQueryScope}
|
currentScope={sessionQueryScope}
|
||||||
onScopeChange={handleScopeChange}
|
onScopeChange={reloadPage}
|
||||||
>
|
>
|
||||||
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
||||||
</SessionQueryScopeGuard>
|
</SessionQueryScopeGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadPage = () => window.location.reload();
|
const reloadPage = () => window.location.reload();
|
||||||
|
|
||||||
const ScopedTRPCReactProvider = ({
|
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||||
children,
|
|
||||||
queryPersister,
|
|
||||||
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
|
||||||
const wsClient = useMemo(
|
const wsClient = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createWSClient({
|
createWSClient({
|
||||||
@@ -152,15 +136,6 @@ const ScopedTRPCReactProvider = ({
|
|||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const flush = () => queryPersister.flush();
|
|
||||||
window.addEventListener("pagehide", flush);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("pagehide", flush);
|
|
||||||
flush();
|
|
||||||
queryPersister.stop();
|
|
||||||
};
|
|
||||||
}, [queryPersister]);
|
|
||||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||||
|
|
||||||
const trpcClient = useMemo(() => {
|
const trpcClient = useMemo(() => {
|
||||||
@@ -211,18 +186,10 @@ const ScopedTRPCReactProvider = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<PersistQueryClientProvider
|
<QueryClientProvider client={queryClient}>
|
||||||
client={queryClient}
|
|
||||||
persistOptions={{
|
|
||||||
persister: queryPersister,
|
|
||||||
maxAge: queryPersistenceMaxAgeMs,
|
|
||||||
buster: queryPersistenceBuster,
|
|
||||||
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||||
</PersistQueryClientProvider>
|
</QueryClientProvider>
|
||||||
</clientApi.Provider>
|
</clientApi.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -19,11 +19,35 @@
|
|||||||
align-self: start;
|
align-self: start;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: var(--board-gutter-viewport-height);
|
height: var(--board-gutter-viewport-height);
|
||||||
|
padding-top: 24px;
|
||||||
|
margin-top: -24px;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gutter[data-board-editing="true"] {
|
.gutter[data-board-editing="true"] {
|
||||||
outline: 0.0625rem solid var(--mantine-color-default-border);
|
outline: 1px dashed var(--mantine-color-default-border);
|
||||||
outline-offset: -0.0625rem;
|
outline-offset: -1px;
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gutter[data-board-editing="true"]::before {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: block;
|
||||||
|
width: fit-content;
|
||||||
|
height: 24px;
|
||||||
|
padding-inline: var(--mantine-spacing-xs);
|
||||||
|
margin-top: -24px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
line-height: 24px;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
content: attr(aria-label);
|
||||||
|
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
|
||||||
import { Box, Paper, Stack, Text } from "@mantine/core";
|
import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { IconBulb } from "@tabler/icons-react";
|
import { IconBulb } from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -11,20 +10,18 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { FloatingTip } from "@homarr/ui";
|
import { FloatingTip } from "@homarr/ui";
|
||||||
|
|
||||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||||
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
|
|
||||||
import { BoardEmptyState } from "~/components/board/board-empty-state";
|
import { BoardEmptyState } from "~/components/board/board-empty-state";
|
||||||
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||||
import {
|
import {
|
||||||
getBoardLaneColumnCount,
|
getBoardLaneColumnCount,
|
||||||
getInitialBoardLogicalHeight,
|
getInitialBoardLogicalHeight,
|
||||||
getLogicalGridSize,
|
getLogicalTrackSize,
|
||||||
LOGICAL_GRID_GAP,
|
LOGICAL_GRID_GAP,
|
||||||
getRootSectionForLane,
|
getRootSectionForLane,
|
||||||
} from "~/components/board/layout";
|
} from "~/components/board/layout";
|
||||||
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 { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
|
|
||||||
import { BoardGridPortalHost } 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";
|
||||||
@@ -34,12 +31,6 @@ import classes from "./_client.module.css";
|
|||||||
|
|
||||||
const APP_SHELL_INLINE_PADDING = 32;
|
const APP_SHELL_INLINE_PADDING = 32;
|
||||||
|
|
||||||
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
|
|
||||||
<GridEditorRegistryProvider>
|
|
||||||
<BoardSelectionProvider>{children}</BoardSelectionProvider>
|
|
||||||
</GridEditorRegistryProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ClientBoard = () => {
|
export const ClientBoard = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const t = useI18n("board.landmark");
|
const t = useI18n("board.landmark");
|
||||||
@@ -62,25 +53,24 @@ export const ClientBoard = () => {
|
|||||||
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
||||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
||||||
.filter((columnCount) => columnCount > 0)
|
.filter((columnCount) => columnCount > 0)
|
||||||
.map(getLogicalGridSize);
|
.map(getLogicalTrackSize);
|
||||||
const logicalWidth =
|
const logicalWidth =
|
||||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||||
const gridTemplateColumns = [
|
const gridTemplateColumns = [
|
||||||
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
|
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
|
||||||
`${getLogicalGridSize(mainColumnCount)}px`,
|
`${getLogicalTrackSize(mainColumnCount)}px`,
|
||||||
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
|
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
|
||||||
]
|
]
|
||||||
.filter((value) => value !== null)
|
.filter((value) => value !== null)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<BoardSelectionGridProvider>
|
<BoardSelectionProvider>
|
||||||
<BoardAdvancedFocusProvider>
|
<BoardAdvancedFocusProvider>
|
||||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||||
<BoardAppsSpotlightRegistrar />
|
|
||||||
<BoardBackgroundVideo />
|
<BoardBackgroundVideo />
|
||||||
<BoardEmptyState />
|
<BoardEmptyState />
|
||||||
<BoardSetupChecklist />
|
<BoardSetupChecklist />
|
||||||
@@ -154,7 +144,7 @@ export const ClientBoard = () => {
|
|||||||
</FloatingTip>
|
</FloatingTip>
|
||||||
</Box>
|
</Box>
|
||||||
</BoardAdvancedFocusProvider>
|
</BoardAdvancedFocusProvider>
|
||||||
</BoardSelectionGridProvider>
|
</BoardSelectionProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (representativeWidth === null) return content;
|
if (representativeWidth === null) return content;
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
|
|
||||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||||
|
const sortedLayouts = form.values.layouts
|
||||||
|
.map((layout, index) => ({ layout, index }))
|
||||||
|
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||||
@@ -126,25 +129,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
disabled={nextBreakpoint === null || !baseLayout}
|
disabled={nextBreakpoint === null || !baseLayout}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (nextBreakpoint === null || !baseLayout) return;
|
if (nextBreakpoint === null || !baseLayout) return;
|
||||||
const newLayout: FormValues["layouts"][number] = {
|
form.setFieldValue("layouts", [
|
||||||
id: createId(),
|
...form.values.layouts,
|
||||||
name: tBoard("setting.section.layout.custom.defaultName"),
|
{
|
||||||
columnCount: baseLayout.columnCount,
|
id: createId(),
|
||||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
name: tBoard("setting.section.layout.custom.defaultName"),
|
||||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
columnCount: baseLayout.columnCount,
|
||||||
breakpoint: nextBreakpoint,
|
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||||
role: "custom",
|
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||||
};
|
breakpoint: nextBreakpoint,
|
||||||
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
|
role: "custom",
|
||||||
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
|
},
|
||||||
form.insertListItem("layouts", newLayout, insertionIndex);
|
]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tBoard("setting.section.layout.responsive.action.add")}
|
{tBoard("setting.section.layout.responsive.action.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{form.values.layouts.map((layout, index) => {
|
{sortedLayouts.map(({ layout, index }) => {
|
||||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||||
const customBreakpoints = form.values.layouts
|
const customBreakpoints = form.values.layouts
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
|
|
||||||
|
|
||||||
import { use, useCallback, useState } from "react";
|
import { use, useCallback, useState } from "react";
|
||||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||||
@@ -73,7 +71,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
),
|
),
|
||||||
integrationSupport: hasIntegrationSupport,
|
integrationSupport: hasIntegrationSupport,
|
||||||
settings,
|
settings,
|
||||||
previewDimensions: dimensions,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title(translate) {
|
title(translate) {
|
||||||
@@ -89,10 +86,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
} finally {
|
} finally {
|
||||||
setIsEditorLoading(false);
|
setIsEditorLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentDefinition, dimensions, kind, openWidgetEditModal, settings, state, t, utils]);
|
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||||
|
|
||||||
const toggleEditMode = useCallback(() => {
|
const toggleEditMode = useCallback(() => {
|
||||||
setEditMode((currentEditMode) => !currentEditMode);
|
setEditMode((editMode) => !editMode);
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"),
|
message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Center, Loader } from "@mantine/core";
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
|
||||||
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
|
||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
|
||||||
import { BoardPreviewProvider } from "@homarr/boards/context";
|
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { ModalProvider } from "@homarr/modals";
|
|
||||||
import { widgetKinds } from "@homarr/widgets/manifest";
|
import { widgetKinds } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import { WidgetPreviewPageContent } from "./_content";
|
import { WidgetPreviewPageContent } from "./_content";
|
||||||
@@ -22,24 +16,12 @@ export default async function WidgetPreview(props: Props) {
|
|||||||
if (!widgetKinds.includes(kind as WidgetKind)) {
|
if (!widgetKinds.includes(kind as WidgetKind)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
const session = await auth();
|
|
||||||
if (!session) {
|
|
||||||
redirect(`/auth/login?callbackUrl=${encodeURIComponent(`/widgets/${kind}`)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [integrations, board] = await Promise.all([getIntegrationsWithPermissionsAsync(session), getHomeBoardAsync()]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntegrationProvider integrations={integrations}>
|
<Center h="100vh">
|
||||||
<BoardPreviewProvider board={board}>
|
<Suspense fallback={<Loader size="sm" />}>
|
||||||
<ModalProvider>
|
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||||
<Center h="100vh">
|
</Suspense>
|
||||||
<Suspense fallback={<Loader size="sm" />}>
|
</Center>
|
||||||
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
|
||||||
</Suspense>
|
|
||||||
</Center>
|
|
||||||
</ModalProvider>
|
|
||||||
</BoardPreviewProvider>
|
|
||||||
</IntegrationProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,45 +130,6 @@
|
|||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.widgetPanel[data-board-widget] {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgetPanel[data-board-widget] .panelHeader,
|
|
||||||
.widgetPanel[data-board-widget] .composerWrap {
|
|
||||||
border-color: rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgetPanel[data-board-widget] .composerWrap,
|
|
||||||
.widgetPanel[data-board-widget] .composer,
|
|
||||||
.widgetPanel[data-board-widget] .composerQuote,
|
|
||||||
.widgetPanel[data-board-widget] .pendingQuestionDock {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgetPanel[data-board-widget] .suggestion,
|
|
||||||
.widgetPanel[data-board-widget] .runtimeSelectorTrigger,
|
|
||||||
.widgetPanel[data-board-widget] .composerContext,
|
|
||||||
.widgetPanel[data-board-widget] .providerQuotaTrigger {
|
|
||||||
border-color: rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1));
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgetPanel[data-board-widget] .suggestion:hover:not(:disabled),
|
|
||||||
.widgetPanel[data-board-widget] .runtimeSelectorTrigger:hover:not(:disabled),
|
|
||||||
.widgetPanel[data-board-widget] .composerContext:hover:not(:disabled),
|
|
||||||
.widgetPanel[data-board-widget] .providerQuotaTrigger:hover:not(:disabled) {
|
|
||||||
background: rgb(from var(--mantine-color-default-hover) r g b / var(--opacity, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgetPanel[data-board-widget] .suggestion:focus-visible,
|
|
||||||
.widgetPanel[data-board-widget] .runtimeSelectorTrigger:focus-visible,
|
|
||||||
.widgetPanel[data-board-widget] .composerContext:focus-visible,
|
|
||||||
.widgetPanel[data-board-widget] .providerQuotaTrigger:focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgetState {
|
.widgetState {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -184,6 +145,10 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.widgetPanel[data-editing] .panelActions {
|
||||||
|
padding-inline-end: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.panelHeader {
|
.panelHeader {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 3.75rem;
|
min-height: 3.75rem;
|
||||||
@@ -192,12 +157,30 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.newConversationButton {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.panelAction {
|
.panelAction {
|
||||||
width: 2.625rem;
|
width: 2.625rem;
|
||||||
height: 2.625rem;
|
height: 2.625rem;
|
||||||
@@ -224,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;
|
||||||
}
|
}
|
||||||
@@ -279,7 +277,8 @@
|
|||||||
|
|
||||||
@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;
|
||||||
@@ -302,6 +301,10 @@
|
|||||||
.composerContextLabel {
|
.composerContextLabel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panelThreadTitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container assistant-widget (max-width: 18rem) {
|
@container assistant-widget (max-width: 18rem) {
|
||||||
|
|||||||
@@ -2368,7 +2368,7 @@ const EmptyThread = () => {
|
|||||||
<Box className={classes.empty}>
|
<Box className={classes.empty}>
|
||||||
<Stack align="center" gap="lg" maw={560} w="100%">
|
<Stack align="center" gap="lg" maw={560} w="100%">
|
||||||
<Stack align="center" gap="xs" maw={430}>
|
<Stack align="center" gap="xs" maw={430}>
|
||||||
<ThemeIcon size={52} radius="xl" variant="light">
|
<ThemeIcon size={52} radius="xl" variant="light" color="red">
|
||||||
<IconRobot size={27} />
|
<IconRobot size={27} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="xl" fw={700}>
|
<Text size="xl" fw={700}>
|
||||||
@@ -2957,6 +2957,7 @@ const Composer = (props: ComposerProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<ComposerPrimitive.Send asChild>
|
<ComposerPrimitive.Send asChild>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
color="red"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -3161,6 +3162,27 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
|
|||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AssistantPanelIdentity = () => {
|
||||||
|
const t = useI18n("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,
|
||||||
@@ -3190,20 +3212,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">
|
||||||
|
<AssistantPanelIdentity />
|
||||||
<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 && (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { showErrorNotification, showWarningNotification } from "@homarr/notifica
|
|||||||
import { closeSpotlight } from "@homarr/spotlight/store";
|
import { closeSpotlight } from "@homarr/spotlight/store";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets/assistant/context";
|
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||||
import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
||||||
|
|
||||||
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||||
@@ -856,7 +856,6 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
: t("busy.description"),
|
: t("busy.description"),
|
||||||
unavailable: !canSend,
|
unavailable: !canSend,
|
||||||
alwaysVisible: true,
|
alwaysVisible: true,
|
||||||
placement: "fallback" as const,
|
|
||||||
interaction: (query: string) =>
|
interaction: (query: string) =>
|
||||||
createAssistantPromptInteraction({
|
createAssistantPromptInteraction({
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export const useRegisterAssistantSpotlightPlaceholder = (description: string) =>
|
|||||||
description,
|
description,
|
||||||
unavailable: true,
|
unavailable: true,
|
||||||
alwaysVisible: true,
|
alwaysVisible: true,
|
||||||
placement: "fallback" as const,
|
|
||||||
interaction: (_query: string) => ({ type: "none" as const }),
|
interaction: (_query: string) => ({ type: "none" as const }),
|
||||||
}),
|
}),
|
||||||
[description, t],
|
[description, t],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const AssistantPromptDetail = () => {
|
|||||||
const t = useI18n("assistant");
|
const t = useI18n("assistant");
|
||||||
return (
|
return (
|
||||||
<Group px="md" py="sm" wrap="nowrap">
|
<Group px="md" py="sm" wrap="nowrap">
|
||||||
<ThemeIcon variant="light" radius="xl">
|
<ThemeIcon color="red" variant="light" radius="xl">
|
||||||
<IconRobot size={17} />
|
<IconRobot size={17} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack gap={1}>
|
<Stack gap={1}>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("preserves generated notebook content and fills its hidden defaults", () => {
|
test("preserves generated notebook content and fills its hidden defaults", () => {
|
||||||
const result = getAssistantWidgetConfiguration(
|
const result = getAssistantWidgetConfiguration(
|
||||||
widgetImports.notebook.definition,
|
|
||||||
{
|
{
|
||||||
boardId: "board-1",
|
boardId: "board-1",
|
||||||
boardName: "Home",
|
boardName: "Home",
|
||||||
@@ -42,7 +41,6 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
||||||
const result = getAssistantWidgetConfiguration(
|
const result = getAssistantWidgetConfiguration(
|
||||||
widgetImports.audioStats.definition,
|
|
||||||
{
|
{
|
||||||
boardId: "board-1",
|
boardId: "board-1",
|
||||||
boardName: "Home",
|
boardName: "Home",
|
||||||
@@ -90,13 +88,11 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
||||||
const mediaServer = getAssistantWidgetConfiguration(
|
const mediaServer = getAssistantWidgetConfiguration(
|
||||||
widgetImports.mediaServer.definition,
|
|
||||||
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
||||||
settings,
|
settings,
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const calendar = getAssistantWidgetConfiguration(
|
const calendar = getAssistantWidgetConfiguration(
|
||||||
widgetImports.calendar.definition,
|
|
||||||
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
||||||
settings,
|
settings,
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, use, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||||
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
|
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
|
||||||
import type { FallbackProps } from "react-error-boundary";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -15,8 +13,7 @@ import { useModalAction } from "@homarr/modals";
|
|||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
|
||||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
@@ -32,11 +29,11 @@ interface AssistantIntegration extends IntegrationSelectOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getAssistantWidgetConfiguration = (
|
export const getAssistantWidgetConfiguration = (
|
||||||
definition: WidgetDefinition,
|
|
||||||
args: ConfigureWidgetArgs,
|
args: ConfigureWidgetArgs,
|
||||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||||
integrations: AssistantIntegration[],
|
integrations: AssistantIntegration[],
|
||||||
) => {
|
) => {
|
||||||
|
const definition = widgetImports[args.kind].definition;
|
||||||
const integrationSupport = "supportedIntegrations" in definition;
|
const integrationSupport = "supportedIntegrations" in definition;
|
||||||
const supportedIntegrations = integrationSupport
|
const supportedIntegrations = integrationSupport
|
||||||
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
||||||
@@ -57,91 +54,35 @@ export const getAssistantWidgetConfiguration = (
|
|||||||
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
|
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||||
options: reduceWidgetOptionsWithDefinition(definition, settings, args.options),
|
options: reduceWidgetOptionsWithDefaultValues(args.kind, settings, args.options),
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type AssistantConfigureWidgetToolProps = ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>;
|
export const AssistantConfigureWidgetTool = ({
|
||||||
|
|
||||||
export const AssistantConfigureWidgetTool = (props: AssistantConfigureWidgetToolProps) => {
|
|
||||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(props.status);
|
|
||||||
const completeArgs =
|
|
||||||
props.result === undefined && !hasFailedAssistantToolArguments(props.status) && hasCompleteArguments
|
|
||||||
? props.args
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!completeArgs) return <AssistantConfigureWidgetToolContent {...props} definition={undefined} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary FallbackComponent={AssistantWidgetDefinitionErrorFallback} resetKeys={[completeArgs.kind]}>
|
|
||||||
<Suspense fallback={<AssistantConfigureWidgetSkeleton />}>
|
|
||||||
<LoadedAssistantConfigureWidgetTool {...props} args={completeArgs} />
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AssistantWidgetDefinitionErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {
|
|
||||||
const t = useI18n("assistant.configureWidget");
|
|
||||||
const actionT = useI18n("common.action");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert color="red" variant="light" title={t("definitionLoadErrorTitle")} icon={<IconAlertTriangle size={18} />}>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm">{t("definitionLoadErrorDescription")}</Text>
|
|
||||||
<Button variant="light" color="red" size="compact-sm" w="fit-content" onClick={resetErrorBoundary}>
|
|
||||||
{actionT("tryAgain")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LoadedAssistantConfigureWidgetTool = (
|
|
||||||
props: AssistantConfigureWidgetToolProps & { args: ConfigureWidgetArgs },
|
|
||||||
) => {
|
|
||||||
const definition = use(loadWidgetDefinition(props.args.kind));
|
|
||||||
return <AssistantConfigureWidgetToolContent {...props} definition={definition} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AssistantConfigureWidgetSkeleton = () => {
|
|
||||||
const t = useI18n("assistant.configureWidget");
|
|
||||||
return (
|
|
||||||
<Box className={classes.appTool} aria-label={t("preparing")}>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Skeleton height={18} width="45%" />
|
|
||||||
<Skeleton height={14} width="76%" />
|
|
||||||
<Skeleton height={38} />
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AssistantConfigureWidgetToolContent = ({
|
|
||||||
args,
|
args,
|
||||||
result,
|
result,
|
||||||
addResult,
|
addResult,
|
||||||
status,
|
status,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
definition,
|
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
|
||||||
}: AssistantConfigureWidgetToolProps & { definition: WidgetDefinition | undefined }) => {
|
|
||||||
const t = useI18n("assistant.configureWidget");
|
const t = useI18n("assistant.configureWidget");
|
||||||
const actionT = useI18n("common.action");
|
const actionT = useI18n("common.action");
|
||||||
const fullT = useI18n();
|
const fullT = useI18n();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
||||||
|
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
|
||||||
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
|
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
|
||||||
const integrations = clientApi.integration.all.useQuery(undefined, {
|
const integrations = clientApi.integration.all.useQuery(undefined, {
|
||||||
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
|
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const configuration = useMemo(() => {
|
const configuration = useMemo(() => {
|
||||||
if (!args || !definition || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
if (!args || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
||||||
return getAssistantWidgetConfiguration(definition, args, settings, integrations.data ?? []);
|
return getAssistantWidgetConfiguration(args, settings, integrations.data ?? []);
|
||||||
}, [args, definition, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||||
const missingRequiredIntegration =
|
const missingRequiredIntegration =
|
||||||
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
||||||
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
||||||
@@ -193,7 +134,15 @@ const AssistantConfigureWidgetToolContent = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
|
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
|
||||||
return <AssistantConfigureWidgetSkeleton />;
|
return (
|
||||||
|
<Box className={classes.appTool} aria-label={t("preparing")}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Skeleton height={18} width="45%" />
|
||||||
|
<Skeleton height={14} width="76%" />
|
||||||
|
<Skeleton height={38} />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (integrations.isError) {
|
if (integrations.isError) {
|
||||||
@@ -209,7 +158,7 @@ const AssistantConfigureWidgetToolContent = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configuration || !definition) return null;
|
if (!configuration) return null;
|
||||||
|
|
||||||
const openConfigurationModal = (
|
const openConfigurationModal = (
|
||||||
value = configuration.value,
|
value = configuration.value,
|
||||||
@@ -218,7 +167,7 @@ const AssistantConfigureWidgetToolContent = ({
|
|||||||
openWidgetModal(
|
openWidgetModal(
|
||||||
{
|
{
|
||||||
kind: args.kind,
|
kind: args.kind,
|
||||||
definition,
|
definition: widgetImports[args.kind].definition,
|
||||||
value,
|
value,
|
||||||
integrationData,
|
integrationData,
|
||||||
integrationSupport: configuration.integrationSupport,
|
integrationSupport: configuration.integrationSupport,
|
||||||
@@ -291,7 +240,7 @@ const AssistantConfigureWidgetToolContent = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WidgetIcon = definition.icon;
|
const WidgetIcon = widgetImports[args.kind].definition.icon;
|
||||||
return (
|
return (
|
||||||
<Box className={classes.appTool}>
|
<Box className={classes.appTool}>
|
||||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const CompactAssistantWidget = ({
|
|||||||
<Box className={classes.widgetPanel}>
|
<Box className={classes.widgetPanel}>
|
||||||
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
|
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
|
||||||
<Group wrap="nowrap" gap="xs" miw={0}>
|
<Group wrap="nowrap" gap="xs" miw={0}>
|
||||||
<ThemeIcon variant="light" radius="xl" size="lg" flex="0 0 auto">
|
<ThemeIcon variant="light" color="red" radius="xl" size="lg" flex="0 0 auto">
|
||||||
<IconRobot size={19} />
|
<IconRobot size={19} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack className={classes.compactCopy} gap={1}>
|
<Stack className={classes.compactCopy} gap={1}>
|
||||||
@@ -167,7 +167,13 @@ interface AssistantWidgetStateProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistantWidgetState = ({ icon: Icon, title, description, color, children }: AssistantWidgetStateProps) => (
|
const AssistantWidgetState = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
color = "red",
|
||||||
|
children,
|
||||||
|
}: AssistantWidgetStateProps) => (
|
||||||
<Center className={classes.widgetState} h="100%" p="md">
|
<Center className={classes.widgetState} h="100%" p="md">
|
||||||
<Stack align="center" gap="sm" ta="center" maw={360}>
|
<Stack align="center" gap="sm" ta="center" maw={360}>
|
||||||
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
|
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
|
||||||
import { getSafeAppHref } from "@homarr/common";
|
|
||||||
import type { ContextSpecificItem } from "@homarr/spotlight";
|
|
||||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
|
||||||
import { hasPendingSpotlightOpen, spotlightOpenEvent } from "@homarr/spotlight/open";
|
|
||||||
|
|
||||||
export const BoardAppsSpotlightRegistrar = () => {
|
|
||||||
const board = useRequiredBoard();
|
|
||||||
const [isSpotlightActivated, setIsSpotlightActivated] = useState(() => hasPendingSpotlightOpen());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSpotlightActivated) return;
|
|
||||||
|
|
||||||
const activateSpotlightQueries = () => setIsSpotlightActivated(true);
|
|
||||||
window.addEventListener(spotlightOpenEvent, activateSpotlightQueries);
|
|
||||||
|
|
||||||
if (hasPendingSpotlightOpen()) activateSpotlightQueries();
|
|
||||||
|
|
||||||
return () => window.removeEventListener(spotlightOpenEvent, activateSpotlightQueries);
|
|
||||||
}, [isSpotlightActivated]);
|
|
||||||
|
|
||||||
const openInNewTabByAppId = useMemo(() => {
|
|
||||||
const result = new Map<string, boolean>();
|
|
||||||
|
|
||||||
for (const item of board.items) {
|
|
||||||
if (item.kind !== "app" || typeof item.options.appId !== "string" || item.options.appId.length === 0) continue;
|
|
||||||
|
|
||||||
const existingOpenInNewTab = result.get(item.options.appId) ?? false;
|
|
||||||
result.set(item.options.appId, existingOpenInNewTab || item.options.openInNewTab === true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [board.items]);
|
|
||||||
const appIds = useMemo(() => Array.from(openInNewTabByAppId.keys()), [openInNewTabByAppId]);
|
|
||||||
const activeAppIds = isSpotlightActivated ? appIds : [];
|
|
||||||
const appQueries = clientApi.useQueries((trpc) => activeAppIds.map((id) => trpc.app.byId({ id })));
|
|
||||||
const apps = appQueries.flatMap((query) => query.data ?? []);
|
|
||||||
const spotlightItems = useMemo<ContextSpecificItem[]>(() => {
|
|
||||||
return apps.flatMap((app) => {
|
|
||||||
const href = getSafeAppHref(app.href);
|
|
||||||
if (!href) return [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: app.id,
|
|
||||||
dedupeKey: `app:${app.id}`,
|
|
||||||
name: app.name,
|
|
||||||
icon: app.iconUrl,
|
|
||||||
interaction() {
|
|
||||||
return {
|
|
||||||
type: "link" as const,
|
|
||||||
href,
|
|
||||||
// Opening a new tab wins if any widget instance requests it.
|
|
||||||
newTab: openInNewTabByAppId.get(app.id) ?? false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}, [apps, openInNewTabByAppId]);
|
|
||||||
|
|
||||||
useRegisterSpotlightContextResults("board-apps", spotlightItems, [spotlightItems]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
.canvas {
|
.canvas {
|
||||||
width: 100%;
|
min-height: 8rem;
|
||||||
min-width: 0;
|
|
||||||
aspect-ratio: 16 / 7;
|
aspect-ratio: 16 / 7;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--mantine-color-dimmed);
|
color: var(--mantine-color-dimmed);
|
||||||
@@ -84,3 +83,9 @@
|
|||||||
gap: rem(1px);
|
gap: rem(1px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: $mantine-breakpoint-xs) {
|
||||||
|
.canvas {
|
||||||
|
min-height: 7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,11 @@ import classes from "./board-layout-thumbnail.module.css";
|
|||||||
interface BoardLayoutThumbnailProps {
|
interface BoardLayoutThumbnailProps {
|
||||||
preview: BoardPreviewData | null;
|
preview: BoardPreviewData | null;
|
||||||
label: string;
|
label: string;
|
||||||
fitFullLayout?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxThumbnailRows = 12;
|
const maxThumbnailRows = 12;
|
||||||
|
|
||||||
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
|
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
|
||||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||||
if (!preview || !layout) {
|
if (!preview || !layout) {
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +26,6 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elements = projectBoardLayout(preview, layout, layout);
|
const elements = projectBoardLayout(preview, layout, layout);
|
||||||
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
|
||||||
const roots = preview.sections
|
const roots = preview.sections
|
||||||
.filter((section) => section.kind === "empty")
|
.filter((section) => section.kind === "empty")
|
||||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||||
@@ -67,12 +65,12 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
|||||||
element.xOffset >= 0 &&
|
element.xOffset >= 0 &&
|
||||||
element.xOffset < columnCount &&
|
element.xOffset < columnCount &&
|
||||||
element.yOffset >= 0 &&
|
element.yOffset >= 0 &&
|
||||||
element.yOffset < thumbnailRowLimit &&
|
element.yOffset < maxThumbnailRows &&
|
||||||
element.width > 0 &&
|
element.width > 0 &&
|
||||||
element.height > 0,
|
element.height > 0,
|
||||||
);
|
);
|
||||||
const rowCount = Math.min(
|
const rowCount = Math.min(
|
||||||
thumbnailRowLimit,
|
maxThumbnailRows,
|
||||||
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,7 @@
|
|||||||
.modalContent {
|
|
||||||
overflow: hidden;
|
|
||||||
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
|
|
||||||
border-radius: rem(24px);
|
|
||||||
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
|
|
||||||
box-shadow: 0 rem(18px) rem(56px) rgb(0 0 0 / 22%);
|
|
||||||
backdrop-filter: blur(rem(20px)) saturate(140%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalBody {
|
|
||||||
padding: var(--mantine-spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent);
|
|
||||||
backdrop-filter: blur(rem(8px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.option {
|
.option {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
@@ -30,12 +11,6 @@
|
|||||||
.card {
|
.card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--mantine-shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadataBadge {
|
|
||||||
width: rem(22px);
|
|
||||||
padding-inline: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.option:hover .card,
|
.option:hover .card,
|
||||||
|
|||||||
@@ -3,32 +3,29 @@
|
|||||||
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
|
Kbd,
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
useMatches,
|
useMatches,
|
||||||
VisuallyHidden,
|
VisuallyHidden,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@tabler/icons-react";
|
import { IconArrowDown, IconArrowRight, IconLayoutBoard, IconReplace } 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 { useOptionalBoard } from "@homarr/boards/context";
|
import { useOptionalBoard } from "@homarr/boards/context";
|
||||||
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||||
|
|
||||||
@@ -42,8 +39,6 @@ interface BoardSwitcherProps {
|
|||||||
|
|
||||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||||
const t = useI18n("board.action.switcher");
|
const t = useI18n("board.action.switcher");
|
||||||
const tBoard = useI18n("board");
|
|
||||||
const manageBoardsT = useI18n("management.page.board");
|
|
||||||
const currentBoard = useOptionalBoard();
|
const currentBoard = useOptionalBoard();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -55,27 +50,20 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
data: boards = [],
|
data: boards = [],
|
||||||
isPending,
|
isPending,
|
||||||
isError,
|
isError,
|
||||||
} = clientApi.board.getManageOverview.useQuery(
|
} = clientApi.board.getManageOverview.useQuery(undefined, {
|
||||||
{ fullPreview: true },
|
enabled: isOpen,
|
||||||
{
|
});
|
||||||
enabled: isOpen,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const switcherBoards = useMemo(
|
const availableBoards = useMemo(
|
||||||
() => [
|
() => boards.filter((board) => board.id !== currentBoard?.id),
|
||||||
...boards.filter((board) => board.id !== currentBoard?.id),
|
|
||||||
...boards.filter((board) => board.id === currentBoard?.id),
|
|
||||||
],
|
|
||||||
[boards, currentBoard?.id],
|
[boards, currentBoard?.id],
|
||||||
);
|
);
|
||||||
const filteredBoards = useMemo(() => {
|
const filteredBoards = useMemo(() => {
|
||||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||||
if (normalizedSearch.length === 0) return switcherBoards;
|
if (normalizedSearch.length === 0) return availableBoards;
|
||||||
return switcherBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
return availableBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
||||||
}, [search, switcherBoards]);
|
}, [availableBoards, search]);
|
||||||
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
|
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
|
||||||
const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
|
|
||||||
|
|
||||||
const openSwitcher = useCallback(() => {
|
const openSwitcher = useCallback(() => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
@@ -85,21 +73,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
}, []);
|
}, []);
|
||||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
const preloadBoards = () => void utils.board.getManageOverview.prefetch();
|
||||||
|
|
||||||
const spotlightAction = useMemo(
|
|
||||||
() => ({
|
|
||||||
id: "open-board-switcher",
|
|
||||||
name: tBoard("action.switch"),
|
|
||||||
icon: IconLayoutBoard,
|
|
||||||
interaction: () => ({
|
|
||||||
type: "javaScript" as const,
|
|
||||||
onSelect: openSwitcher,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[openSwitcher, tBoard],
|
|
||||||
);
|
|
||||||
useRegisterSpotlightContextActions("board-switcher", [spotlightAction], [spotlightAction]);
|
|
||||||
|
|
||||||
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
||||||
|
|
||||||
@@ -114,17 +88,14 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
}, [activeIndex, filteredBoards.length, isOpen]);
|
}, [activeIndex, filteredBoards.length, isOpen]);
|
||||||
|
|
||||||
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
const isSearchInput =
|
if (event.key === "Backspace") {
|
||||||
event.target instanceof HTMLInputElement && event.target.dataset.boardSwitcherSearch !== undefined;
|
|
||||||
|
|
||||||
if (!isSearchInput && event.key === "Backspace") {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSearch((current) => current.slice(0, -1));
|
setSearch((current) => current.slice(0, -1));
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSearchInput && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSearch((current) => `${current}${event.key}`);
|
setSearch((current) => `${current}${event.key}`);
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
@@ -170,7 +141,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const results = getBoardSwitcherResults({
|
const results = getBoardSwitcherResults({
|
||||||
availableBoardCount: switcherBoards.length,
|
availableBoardCount: availableBoards.length,
|
||||||
filteredBoards,
|
filteredBoards,
|
||||||
isError,
|
isError,
|
||||||
isPending,
|
isPending,
|
||||||
@@ -181,7 +152,6 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
setActiveIndex,
|
setActiveIndex,
|
||||||
optionRefs,
|
optionRefs,
|
||||||
columnCount,
|
columnCount,
|
||||||
manageBoardsT,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -191,40 +161,54 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
<Modal
|
<Modal
|
||||||
opened={isOpen}
|
opened={isOpen}
|
||||||
onClose={closeSwitcher}
|
onClose={closeSwitcher}
|
||||||
withCloseButton={false}
|
title={
|
||||||
yOffset="15vh"
|
<Group gap="sm" wrap="nowrap">
|
||||||
aria-label={t("title")}
|
<ThemeIcon variant="light" radius="md">
|
||||||
size={`${modalColumnCount * 15 + Math.max(0, modalColumnCount - 1) * 0.75}rem`}
|
<IconReplace size={18} stroke={1.5} />
|
||||||
overlayProps={{ backgroundOpacity: 0, blur: 2 }}
|
</ThemeIcon>
|
||||||
|
<Text fw={700}>{t("title")}</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
size={1280}
|
||||||
|
yOffset={32}
|
||||||
|
overlayProps={{ backgroundOpacity: 0.18, blur: 2 }}
|
||||||
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
|
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
|
||||||
classNames={{ content: classes.modalContent, body: classes.modalBody }}
|
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
|
data-autofocus
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
gap="xs"
|
gap="md"
|
||||||
onKeyDown={handleModalKeyDown}
|
onKeyDown={handleModalKeyDown}
|
||||||
className={classes.content}
|
className={classes.content}
|
||||||
aria-describedby="board-switcher-instructions"
|
aria-describedby="board-switcher-instructions"
|
||||||
>
|
>
|
||||||
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
|
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
|
||||||
<TextInput
|
<ScrollArea.Autosize mah={640} type="auto" offsetScrollbars>
|
||||||
data-autofocus
|
|
||||||
data-board-switcher-search
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSearch(event.currentTarget.value);
|
|
||||||
setActiveIndex(0);
|
|
||||||
}}
|
|
||||||
leftSection={<IconSearch size="1rem" stroke={1.6} />}
|
|
||||||
placeholder={t("title")}
|
|
||||||
aria-label={t("title")}
|
|
||||||
aria-controls="board-switcher-results"
|
|
||||||
radius="xl"
|
|
||||||
classNames={{ input: classes.searchInput }}
|
|
||||||
/>
|
|
||||||
<ScrollArea.Autosize mah="min(80vh, 42rem)" type="never">
|
|
||||||
{results}
|
{results}
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea.Autosize>
|
||||||
|
|
||||||
|
<Group justify={currentBoard ? "space-between" : "flex-end"} gap="xs" visibleFrom="sm">
|
||||||
|
{currentBoard && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("currentHidden", { name: currentBoard.name })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Group gap="xs">
|
||||||
|
<Kbd size="xs">
|
||||||
|
<IconArrowRight size={12} />
|
||||||
|
</Kbd>
|
||||||
|
<Kbd size="xs">
|
||||||
|
<IconArrowDown size={12} />
|
||||||
|
</Kbd>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("keyboard.navigate")}
|
||||||
|
</Text>
|
||||||
|
<Kbd size="xs">Enter</Kbd>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("keyboard.open")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
@@ -243,7 +227,6 @@ interface BoardSwitcherResultsProps {
|
|||||||
setActiveIndex: (index: number) => void;
|
setActiveIndex: (index: number) => void;
|
||||||
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
|
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
manageBoardsT: ReturnType<typeof useI18n<"management.page.board">>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBoardSwitcherResults = ({
|
const getBoardSwitcherResults = ({
|
||||||
@@ -258,7 +241,6 @@ const getBoardSwitcherResults = ({
|
|||||||
setActiveIndex,
|
setActiveIndex,
|
||||||
optionRefs,
|
optionRefs,
|
||||||
columnCount,
|
columnCount,
|
||||||
manageBoardsT,
|
|
||||||
}: BoardSwitcherResultsProps) => {
|
}: BoardSwitcherResultsProps) => {
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
@@ -305,7 +287,7 @@ const getBoardSwitcherResults = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="xs" p={2}>
|
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="md" p={4}>
|
||||||
{filteredBoards.map((board, index) => (
|
{filteredBoards.map((board, index) => (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
key={board.id}
|
key={board.id}
|
||||||
@@ -314,7 +296,7 @@ const getBoardSwitcherResults = ({
|
|||||||
}}
|
}}
|
||||||
className={classes.option}
|
className={classes.option}
|
||||||
w="100%"
|
w="100%"
|
||||||
maw={240}
|
maw={320}
|
||||||
mx="auto"
|
mx="auto"
|
||||||
component={Link}
|
component={Link}
|
||||||
href={`/boards/${encodeURIComponent(board.name)}`}
|
href={`/boards/${encodeURIComponent(board.name)}`}
|
||||||
@@ -324,50 +306,21 @@ const getBoardSwitcherResults = ({
|
|||||||
onFocus={() => setActiveIndex(index)}
|
onFocus={() => setActiveIndex(index)}
|
||||||
onPointerEnter={() => setActiveIndex(index)}
|
onPointerEnter={() => setActiveIndex(index)}
|
||||||
>
|
>
|
||||||
<Card padding={0} withBorder radius="lg" className={classes.card}>
|
<Card padding={0} withBorder radius="md" className={classes.card}>
|
||||||
<Card.Section>
|
<Card.Section>
|
||||||
<BoardLayoutThumbnail
|
<BoardLayoutThumbnail
|
||||||
preview={board.preview}
|
preview={board.preview}
|
||||||
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||||
fitFullLayout
|
|
||||||
/>
|
/>
|
||||||
</Card.Section>
|
</Card.Section>
|
||||||
<Card.Section withBorder px="sm" py={6}>
|
<Card.Section withBorder p="sm">
|
||||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Text fw={600} size="sm" truncate>
|
<ThemeIcon variant="light" radius="md" size="md">
|
||||||
|
<IconLayoutBoard size={16} stroke={1.5} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={650} truncate>
|
||||||
{board.name}
|
{board.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={4} wrap="nowrap">
|
|
||||||
{board.isHome && (
|
|
||||||
<Tooltip label={manageBoardsT("action.setHomeBoard.badge.tooltip")}>
|
|
||||||
<Badge
|
|
||||||
className={classes.metadataBadge}
|
|
||||||
color="yellow"
|
|
||||||
variant="light"
|
|
||||||
aria-label={manageBoardsT("action.setHomeBoard.badge.label")}
|
|
||||||
>
|
|
||||||
<IconHomeFilled size="0.7rem" />
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{board.isMobileHome && (
|
|
||||||
<Tooltip label={manageBoardsT("action.setMobileHomeBoard.badge.tooltip")}>
|
|
||||||
<Badge
|
|
||||||
className={classes.metadataBadge}
|
|
||||||
color="yellow"
|
|
||||||
variant="light"
|
|
||||||
aria-label={manageBoardsT("action.setMobileHomeBoard.badge.label")}
|
|
||||||
>
|
|
||||||
<IconDeviceMobile size="0.7rem" />
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{board.creator && (
|
|
||||||
<Tooltip label={board.creator.name ?? manageBoardsT("preview.unknownCreator")}>
|
|
||||||
<UserAvatar user={board.creator} size="xs" />
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Card.Section>
|
</Card.Section>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
.refreshIndicator {
|
.badge {
|
||||||
position: absolute;
|
@mixin dark {
|
||||||
top: 0.25rem;
|
--background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity));
|
||||||
right: 0.25rem;
|
--border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity));
|
||||||
z-index: 3;
|
}
|
||||||
display: grid;
|
@mixin light {
|
||||||
place-items: center;
|
--background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity));
|
||||||
width: 1.25rem;
|
--border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity));
|
||||||
height: 1.25rem;
|
}
|
||||||
border-radius: 999px;
|
background-color: var(--background-color) !important;
|
||||||
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
|
border-color: var(--border-color) !important;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settingsButton {
|
.settingsButton {
|
||||||
position: absolute;
|
|
||||||
top: -34px;
|
|
||||||
right: 14px;
|
|
||||||
z-index: 201;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 120ms ease;
|
transition: opacity 120ms ease;
|
||||||
@@ -63,11 +58,3 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.settingsButton {
|
|
||||||
right: 4px;
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { ComponentType, CSSProperties, MutableRefObject, PropsWithChildren } from "react";
|
import type { ComponentType, CSSProperties, MutableRefObject } from "react";
|
||||||
import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
import type { CardProps } from "@mantine/core";
|
||||||
|
import { Badge, Box, Button, Card, Center, Loader, Portal } from "@mantine/core";
|
||||||
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
||||||
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||||
import type { FallbackProps } from "react-error-boundary";
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
@@ -19,16 +20,13 @@ import { WidgetError } from "@homarr/widgets/errors";
|
|||||||
import {
|
import {
|
||||||
createWidgetRuntimeState,
|
createWidgetRuntimeState,
|
||||||
getWidgetQueryKeys,
|
getWidgetQueryKeys,
|
||||||
getWidgetRuntimeQueries,
|
|
||||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||||
} from "@homarr/widgets/definition";
|
} from "@homarr/widgets/definition";
|
||||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
import { WidgetCardShell, WidgetTitleBadge } from "@homarr/widgets/widget-card-shell";
|
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { getLogicalTrackSize } from "~/components/board/layout";
|
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||||
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
|
||||||
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";
|
||||||
@@ -36,11 +34,11 @@ import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced
|
|||||||
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 { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
|
||||||
|
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";
|
||||||
import { WidgetContextMenu } from "./widget-context-menu";
|
import { WidgetContextMenu } from "./widget-context-menu";
|
||||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
|
||||||
|
|
||||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -50,28 +48,47 @@ interface BoardItemContentProps {
|
|||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BoardItemCardProps = PropsWithChildren<{
|
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
|
||||||
|
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
|
||||||
|
if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" } as const;
|
||||||
|
if (hasCustomCssClasses) return {};
|
||||||
|
return { overflowX: "hidden", overflowY: "auto" } as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BoardItemCardProps = CardProps & {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
innerRef: (element: HTMLDivElement | null) => void;
|
innerRef: (element: HTMLDivElement | null) => void;
|
||||||
}>;
|
};
|
||||||
|
|
||||||
const BoardItemCard = ({ item, innerRef, children }: BoardItemCardProps) => {
|
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WidgetCardShell
|
<Card
|
||||||
innerRef={innerRef}
|
{...cardProps}
|
||||||
kind={item.kind}
|
ref={innerRef}
|
||||||
advancedOptions={item.advancedOptions}
|
|
||||||
opacity={board.opacity / 100}
|
|
||||||
w="100%"
|
w="100%"
|
||||||
h="100%"
|
h="100%"
|
||||||
data-grid-item-content
|
data-grid-item-content
|
||||||
|
className={combineClasses(
|
||||||
|
classes.itemCard,
|
||||||
|
`${item.kind}-wrapper`,
|
||||||
|
"board-grid-item-content",
|
||||||
|
item.advancedOptions.customCssClasses.join(" "),
|
||||||
|
)}
|
||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
"--opacity": board.opacity / 100,
|
||||||
|
containerType: "size",
|
||||||
|
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||||
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
p={0}
|
p={0}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</WidgetCardShell>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,7 +167,6 @@ const LoadedBoardItemContent = ({
|
|||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
const previewEntranceFrameRef = useRef<number | null>(null);
|
const previewEntranceFrameRef = useRef<number | null>(null);
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const boardCanvasScale = useBoardCanvasScale();
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
@@ -158,34 +174,12 @@ const LoadedBoardItemContent = ({
|
|||||||
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
||||||
[definition, item, settings],
|
[definition, item, settings],
|
||||||
);
|
);
|
||||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
|
||||||
const isWidgetRefreshing =
|
|
||||||
useIsFetching({
|
|
||||||
predicate: (query) =>
|
|
||||||
query.state.data !== undefined &&
|
|
||||||
matchesWidgetItemQuery(
|
|
||||||
query.queryKey,
|
|
||||||
widgetQueryKeys,
|
|
||||||
{
|
|
||||||
itemId: item.id,
|
|
||||||
boardId: board.id,
|
|
||||||
integrationIds: item.integrationIds,
|
|
||||||
options: menuItem.options,
|
|
||||||
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
|
||||||
},
|
|
||||||
definition.queryMatcher,
|
|
||||||
),
|
|
||||||
}) > 0;
|
|
||||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||||
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||||
const { width: viewportWidth, height: viewportHeight } = viewportSize;
|
const { width: viewportWidth, height: viewportHeight } = viewportSize;
|
||||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
||||||
const widgetName = getWidgetName(item.kind, t);
|
const widgetName = getWidgetName(item.kind, t);
|
||||||
const previewDimensions = useMemo(
|
|
||||||
() => ({ width, height, scale: boardCanvasScale }),
|
|
||||||
[boardCanvasScale, height, width],
|
|
||||||
);
|
|
||||||
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
||||||
const advancedViewId = `advanced-focus-${item.id}`;
|
const advancedViewId = `advanced-focus-${item.id}`;
|
||||||
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
||||||
@@ -297,11 +291,8 @@ const LoadedBoardItemContent = ({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const widgetCard = (
|
const widgetCard = (
|
||||||
<WidgetCardShell
|
<Card
|
||||||
innerRef={cardRef}
|
ref={cardRef}
|
||||||
kind={item.kind}
|
|
||||||
advancedOptions={item.advancedOptions}
|
|
||||||
opacity={isAdvanced ? 0.98 : board.opacity / 100}
|
|
||||||
id={isPreview ? advancedViewId : undefined}
|
id={isPreview ? advancedViewId : undefined}
|
||||||
role={isPreview ? "region" : undefined}
|
role={isPreview ? "region" : undefined}
|
||||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||||
@@ -312,9 +303,21 @@ const LoadedBoardItemContent = ({
|
|||||||
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||||
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||||
className={combineClasses(
|
className={combineClasses(
|
||||||
|
classes.itemCard,
|
||||||
|
`${item.kind}-wrapper`,
|
||||||
|
"board-grid-item-content",
|
||||||
isPreview && advancedFocusClasses.surface,
|
isPreview && advancedFocusClasses.surface,
|
||||||
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
|
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
|
||||||
|
item.advancedOptions.customCssClasses.join(" "),
|
||||||
)}
|
)}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
|
||||||
|
containerType: "size",
|
||||||
|
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||||
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
style={previewStyle}
|
style={previewStyle}
|
||||||
>
|
>
|
||||||
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
||||||
@@ -342,21 +345,15 @@ const LoadedBoardItemContent = ({
|
|||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Box>
|
</Box>
|
||||||
{isWidgetRefreshing && (
|
</Card>
|
||||||
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
|
|
||||||
<Loader size="xs" />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</WidgetCardShell>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} previewDimensions={previewDimensions} />}
|
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} />}
|
||||||
<WidgetContextMenu
|
<WidgetContextMenu
|
||||||
item={item}
|
item={item}
|
||||||
definition={definition}
|
definition={definition}
|
||||||
previewDimensions={previewDimensions}
|
|
||||||
widgetStateRef={widgetStateRef}
|
widgetStateRef={widgetStateRef}
|
||||||
widgetRuntimeRef={widgetRuntimeRef}
|
widgetRuntimeRef={widgetRuntimeRef}
|
||||||
sourceRef={sourceRef}
|
sourceRef={sourceRef}
|
||||||
@@ -421,12 +418,25 @@ const LoadedBoardItemContent = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</WidgetContextMenu>
|
</WidgetContextMenu>
|
||||||
{!isAdvanced && (
|
{!isAdvanced && item.advancedOptions.title?.trim() && (
|
||||||
<WidgetTitleBadge
|
<Badge
|
||||||
advancedOptions={item.advancedOptions}
|
pos="absolute"
|
||||||
opacity={board.opacity / 100}
|
style={{ zIndex: "var(--mantine-z-index-app)" }}
|
||||||
|
top={2}
|
||||||
|
left={16}
|
||||||
|
size="xs"
|
||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
/>
|
styles={{
|
||||||
|
root: {
|
||||||
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||||
|
"--opacity": board.opacity / 100,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className={itemContentClasses.badge}
|
||||||
|
c="var(--mantine-color-text)"
|
||||||
|
>
|
||||||
|
{item.advancedOptions.title}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
|
|||||||
interface BoardItemMenuProps {
|
interface BoardItemMenuProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
definition: WidgetDefinition;
|
definition: WidgetDefinition;
|
||||||
previewDimensions: { width: number; height: number; scale?: number };
|
|
||||||
resetErrorBoundary?: () => void;
|
resetErrorBoundary?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
|||||||
|
|
||||||
return <BoardItemMenuInner {...props} />;
|
return <BoardItemMenuInner {...props} />;
|
||||||
};
|
};
|
||||||
const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBoundary }: BoardItemMenuProps) => {
|
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||||
@@ -90,7 +89,6 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
|
|||||||
integrationSupport: "supportedIntegrations" in definition,
|
integrationSupport: "supportedIntegrations" in definition,
|
||||||
settings,
|
settings,
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
previewDimensions,
|
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -108,6 +106,10 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
|
|||||||
variant="default"
|
variant="default"
|
||||||
size={24}
|
size={24}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={4}
|
||||||
|
right={4}
|
||||||
|
style={{ zIndex: 30 }}
|
||||||
className={itemContentClasses.settingsButton}
|
className={itemContentClasses.settingsButton}
|
||||||
data-menu-open={isMenuOpen || undefined}
|
data-menu-open={isMenuOpen || undefined}
|
||||||
data-board-widget-settings
|
data-board-widget-settings
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { getLayoutRowCount } from "../layout";
|
import { getLayoutRowCount } from "../layout";
|
||||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||||
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
|
|
||||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||||
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
||||||
@@ -58,29 +57,26 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
const tContainer = useI18n("section.container");
|
const tContainer = useI18n("section.container");
|
||||||
const tMoveResize = useI18n("item.moveResize");
|
const tMoveResize = useI18n("item.moveResize");
|
||||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||||
const registeredGridEditors = useRegisteredGridEditors();
|
|
||||||
const minimumSize = useMemo(
|
const minimumSize = useMemo(
|
||||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||||
[board, currentLayoutId, entry],
|
[board, currentLayoutId, entry],
|
||||||
);
|
);
|
||||||
const targets = getMoveTargets(
|
const targets = useMemo(
|
||||||
board,
|
() =>
|
||||||
currentLayoutId,
|
getMoveTargets(board, currentLayoutId, entry, {
|
||||||
entry,
|
canvas: tLandmark("canvas"),
|
||||||
{
|
container: tContainer("action.create"),
|
||||||
canvas: tLandmark("canvas"),
|
leftRail: tLandmark("leftRail"),
|
||||||
container: tContainer("action.create"),
|
rightRail: tLandmark("rightRail"),
|
||||||
leftRail: tLandmark("leftRail"),
|
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||||
rightRail: tLandmark("rightRail"),
|
located: (name, location, index) =>
|
||||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
tMoveResize("target.located", {
|
||||||
located: (name, location, index) =>
|
name,
|
||||||
tMoveResize("target.located", {
|
location,
|
||||||
name,
|
index: String(index),
|
||||||
location,
|
}),
|
||||||
index: String(index),
|
}),
|
||||||
}),
|
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
|
||||||
},
|
|
||||||
registeredGridEditors,
|
|
||||||
);
|
);
|
||||||
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||||
@@ -256,7 +252,6 @@ export const getMoveTargets = (
|
|||||||
layoutId: string,
|
layoutId: string,
|
||||||
entry: MovableEntry,
|
entry: MovableEntry,
|
||||||
labels: MoveTargetLabels,
|
labels: MoveTargetLabels,
|
||||||
registeredGridEditors?: ReadonlyMap<string, { placementMaxRowCount: number | null }>,
|
|
||||||
): MoveTarget[] => {
|
): MoveTarget[] => {
|
||||||
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
||||||
|
|
||||||
@@ -265,15 +260,13 @@ export const getMoveTargets = (
|
|||||||
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
||||||
const lane = getRootSectionLane(section.xOffset);
|
const lane = getRootSectionLane(section.xOffset);
|
||||||
if (columnCount === 0) return [];
|
if (columnCount === 0) return [];
|
||||||
const registeredEditor = registeredGridEditors?.get(section.id);
|
|
||||||
if (lane !== "main" && registeredGridEditors && !registeredEditor) return [];
|
|
||||||
return minimum.width <= columnCount
|
return minimum.width <= columnCount
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: section.id,
|
id: section.id,
|
||||||
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
||||||
columnCount,
|
columnCount,
|
||||||
maxRowCount: registeredEditor?.placementMaxRowCount ?? getSectionMaxRowCount(board, layoutId, section.id),
|
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
getIntegrationName,
|
getIntegrationName,
|
||||||
getWidgetName,
|
getWidgetName,
|
||||||
getRootSectionLane,
|
getRootSectionLane,
|
||||||
widgetDefaultSizes,
|
|
||||||
widgetIntegrationSupport,
|
widgetIntegrationSupport,
|
||||||
widgetKinds,
|
widgetKinds,
|
||||||
widgetKindsWithOptionalIntegrations,
|
widgetKindsWithOptionalIntegrations,
|
||||||
@@ -48,7 +47,6 @@ import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr
|
|||||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||||
import { getSectionGridColumnCount } from "../sections/grid/section-grid-placements";
|
|
||||||
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 {
|
||||||
@@ -78,8 +76,6 @@ export interface WidgetItemData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
||||||
const defaultWidgetSize = { width: 1, height: 1 };
|
|
||||||
const maximumWidgetHeight = 64;
|
|
||||||
|
|
||||||
const ItemSelectModalContent = ({
|
const ItemSelectModalContent = ({
|
||||||
actions,
|
actions,
|
||||||
@@ -128,13 +124,6 @@ const ItemSelectModalContent = ({
|
|||||||
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
|
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
|
||||||
? targetSectionId
|
? targetSectionId
|
||||||
: (placementOptions[0]?.value ?? null);
|
: (placementOptions[0]?.value ?? null);
|
||||||
const maximumWidgetSize = useMemo(() => {
|
|
||||||
if (!effectiveSectionId) return defaultWidgetSize;
|
|
||||||
return {
|
|
||||||
width: Math.max(1, getSectionGridColumnCount(board, currentLayoutId, effectiveSectionId) ?? 1),
|
|
||||||
height: maximumWidgetHeight,
|
|
||||||
};
|
|
||||||
}, [board, currentLayoutId, effectiveSectionId]);
|
|
||||||
|
|
||||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||||
const selectionLock = useRef(false);
|
const selectionLock = useRef(false);
|
||||||
@@ -257,7 +246,6 @@ const ItemSelectModalContent = ({
|
|||||||
try {
|
try {
|
||||||
const definition = await loadWidgetDefinition("customApi");
|
const definition = await loadWidgetDefinition("customApi");
|
||||||
const itemId = createId();
|
const itemId = createId();
|
||||||
const initialSize = widgetDefaultSizes.customApi ?? defaultWidgetSize;
|
|
||||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||||
const options = {
|
const options = {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
@@ -275,7 +263,7 @@ const ItemSelectModalContent = ({
|
|||||||
options,
|
options,
|
||||||
integrationIds: [],
|
integrationIds: [],
|
||||||
},
|
},
|
||||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }, selectedSize) => {
|
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||||
const updatedBoard = createItem({
|
const updatedBoard = createItem({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
kind: "customApi",
|
kind: "customApi",
|
||||||
@@ -283,14 +271,12 @@ const ItemSelectModalContent = ({
|
|||||||
options: configuredOptions,
|
options: configuredOptions,
|
||||||
targetSectionId: effectiveSectionId ?? undefined,
|
targetSectionId: effectiveSectionId ?? undefined,
|
||||||
advancedOptions,
|
advancedOptions,
|
||||||
size: selectedSize ?? initialSize,
|
|
||||||
});
|
});
|
||||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||||
},
|
},
|
||||||
integrationData: [],
|
integrationData: [],
|
||||||
integrationSupport: false,
|
integrationSupport: false,
|
||||||
settings,
|
settings,
|
||||||
previewResize: { initialSize, maximumSize: maximumWidgetSize },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||||
@@ -325,7 +311,6 @@ const ItemSelectModalContent = ({
|
|||||||
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
|
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
|
||||||
const itemId = createId();
|
const itemId = createId();
|
||||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||||
const initialSize = widgetDefaultSizes[kind] ?? defaultWidgetSize;
|
|
||||||
const openEditor = (
|
const openEditor = (
|
||||||
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
|
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
|
||||||
) => {
|
) => {
|
||||||
@@ -341,7 +326,7 @@ const ItemSelectModalContent = ({
|
|||||||
options: defaultOptions,
|
options: defaultOptions,
|
||||||
integrationIds: selectedIntegrationIds,
|
integrationIds: selectedIntegrationIds,
|
||||||
},
|
},
|
||||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }, selectedSize) => {
|
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||||
const updatedBoard = createItem({
|
const updatedBoard = createItem({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
kind,
|
kind,
|
||||||
@@ -349,7 +334,6 @@ const ItemSelectModalContent = ({
|
|||||||
integrationIds: newIntegrationIds,
|
integrationIds: newIntegrationIds,
|
||||||
targetSectionId: effectiveSectionId ?? undefined,
|
targetSectionId: effectiveSectionId ?? undefined,
|
||||||
advancedOptions,
|
advancedOptions,
|
||||||
size: selectedSize ?? initialSize,
|
|
||||||
});
|
});
|
||||||
if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return;
|
if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return;
|
||||||
trackSetup("widget-completed", {
|
trackSetup("widget-completed", {
|
||||||
@@ -362,7 +346,6 @@ const ItemSelectModalContent = ({
|
|||||||
integrationData: availableIntegrations,
|
integrationData: availableIntegrations,
|
||||||
integrationSupport: hasIntegrationSupport,
|
integrationSupport: hasIntegrationSupport,
|
||||||
settings,
|
settings,
|
||||||
previewResize: { initialSize, maximumSize: maximumWidgetSize },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
|
||||||
import { Suspense, use, useCallback, useMemo, useState } 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";
|
||||||
@@ -8,49 +7,16 @@ import type { FallbackProps } from "react-error-boundary";
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { widgetDefaultSizes } from "@homarr/definitions";
|
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||||
import { createModal, 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, WidgetEditModalSize } from "@homarr/widgets/modals";
|
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||||
import { loadWidgetComponent } from "@homarr/widgets/manifest";
|
|
||||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||||
|
|
||||||
import { AssistantContext, useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
|
||||||
import { getLogicalTrackSize } from "~/components/board/layout";
|
|
||||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
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";
|
||||||
|
|
||||||
const defaultWidgetSize = { width: 1, height: 1 };
|
|
||||||
const ignoreAssistantAction = () => undefined;
|
|
||||||
const ignoreAssistantPrompt = () => false;
|
|
||||||
const ignoreAssistantRefresh = () => Promise.resolve();
|
|
||||||
const getPreviewDimensions = (size: WidgetEditModalSize) => ({
|
|
||||||
width: getLogicalTrackSize(size.width),
|
|
||||||
height: getLogicalTrackSize(size.height),
|
|
||||||
});
|
|
||||||
|
|
||||||
const PreviewRuntimeWrapper = ({ children }: PropsWithChildren) => {
|
|
||||||
const assistant = useOptionalHomarrAssistant();
|
|
||||||
const previewAssistant = useMemo(() => {
|
|
||||||
if (!assistant) return null;
|
|
||||||
return {
|
|
||||||
...assistant,
|
|
||||||
open: ignoreAssistantAction,
|
|
||||||
close: ignoreAssistantAction,
|
|
||||||
toggle: ignoreAssistantAction,
|
|
||||||
sendPrompt: ignoreAssistantPrompt,
|
|
||||||
refreshCurrentView: ignoreAssistantRefresh,
|
|
||||||
setWidgetVisible: ignoreAssistantAction,
|
|
||||||
activateWidget: ignoreAssistantAction,
|
|
||||||
};
|
|
||||||
}, [assistant]);
|
|
||||||
|
|
||||||
if (!previewAssistant) return children;
|
|
||||||
return <AssistantContext.Provider value={previewAssistant}>{children}</AssistantContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
||||||
|
|
||||||
export const loadWidgetEditModal = () => {
|
export const loadWidgetEditModal = () => {
|
||||||
@@ -117,7 +83,6 @@ interface LazyWidgetEditModalContentProps {
|
|||||||
|
|
||||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||||
const { default: PreviewComponent } = use(loadWidgetComponent(props.innerProps.kind));
|
|
||||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||||
const Component = WidgetEditModal.component;
|
const Component = WidgetEditModal.component;
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
@@ -149,18 +114,6 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
|||||||
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
||||||
return [...existing, ...additional];
|
return [...existing, ...additional];
|
||||||
}, [props.innerProps.integrationData, createdIntegrations]);
|
}, [props.innerProps.integrationData, createdIntegrations]);
|
||||||
const previewDimensions = useMemo(() => {
|
|
||||||
if (props.innerProps.previewDimensions) return props.innerProps.previewDimensions;
|
|
||||||
const size = widgetDefaultSizes[props.innerProps.kind] ?? defaultWidgetSize;
|
|
||||||
return {
|
|
||||||
width: getLogicalTrackSize(size.width),
|
|
||||||
height: getLogicalTrackSize(size.height),
|
|
||||||
};
|
|
||||||
}, [props.innerProps.kind, props.innerProps.previewDimensions]);
|
|
||||||
let previewResize = props.innerProps.previewResize;
|
|
||||||
if (previewResize) {
|
|
||||||
previewResize = { ...previewResize, getDimensions: getPreviewDimensions };
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
@@ -169,10 +122,6 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
|||||||
...props.innerProps,
|
...props.innerProps,
|
||||||
integrationData: combinedIntegrationData,
|
integrationData: combinedIntegrationData,
|
||||||
integrationEditForm: LazyIntegrationEditForm,
|
integrationEditForm: LazyIntegrationEditForm,
|
||||||
previewComponent: PreviewComponent,
|
|
||||||
previewDimensions,
|
|
||||||
previewResize,
|
|
||||||
previewWrapper: PreviewRuntimeWrapper,
|
|
||||||
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -208,10 +157,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
|||||||
defaultTitle(t) {
|
defaultTitle(t) {
|
||||||
return t("item.edit.title");
|
return t("item.edit.title");
|
||||||
},
|
},
|
||||||
size: "100rem",
|
size: modalSizeForm,
|
||||||
transitionProps: {
|
|
||||||
transition: "pop",
|
|
||||||
duration: 180,
|
|
||||||
},
|
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
});
|
});
|
||||||
@@ -35,7 +35,6 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
|
|||||||
interface WidgetContextMenuProps {
|
interface WidgetContextMenuProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
definition: WidgetDefinition;
|
definition: WidgetDefinition;
|
||||||
previewDimensions: { width: number; height: number; scale?: number };
|
|
||||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||||
widgetRuntimeRef: WidgetRuntimeRef;
|
widgetRuntimeRef: WidgetRuntimeRef;
|
||||||
sourceRef: RefObject<HTMLElement | null>;
|
sourceRef: RefObject<HTMLElement | null>;
|
||||||
@@ -45,7 +44,6 @@ interface WidgetContextMenuProps {
|
|||||||
export const WidgetContextMenu = ({
|
export const WidgetContextMenu = ({
|
||||||
item,
|
item,
|
||||||
definition,
|
definition,
|
||||||
previewDimensions,
|
|
||||||
widgetRuntimeRef,
|
widgetRuntimeRef,
|
||||||
sourceRef,
|
sourceRef,
|
||||||
children,
|
children,
|
||||||
@@ -162,7 +160,7 @@ export const WidgetContextMenu = ({
|
|||||||
definition,
|
definition,
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: item.advancedOptions,
|
advancedOptions: item.advancedOptions,
|
||||||
options,
|
options: item.options,
|
||||||
integrationIds: item.integrationIds,
|
integrationIds: item.integrationIds,
|
||||||
},
|
},
|
||||||
onSuccessfulEdit: (editResult) => {
|
onSuccessfulEdit: (editResult) => {
|
||||||
@@ -191,9 +189,6 @@ export const WidgetContextMenu = ({
|
|||||||
),
|
),
|
||||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||||
settings,
|
settings,
|
||||||
itemId: item.id,
|
|
||||||
boardId: board.id,
|
|
||||||
previewDimensions,
|
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -202,15 +197,12 @@ export const WidgetContextMenu = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
board.id,
|
|
||||||
definition,
|
definition,
|
||||||
integrationData,
|
integrationData,
|
||||||
item,
|
item,
|
||||||
handleRefetch,
|
handleRefetch,
|
||||||
openModal,
|
openModal,
|
||||||
options,
|
|
||||||
persistBoard,
|
persistBoard,
|
||||||
previewDimensions,
|
|
||||||
settings,
|
settings,
|
||||||
updateItemAdvancedOptions,
|
updateItemAdvancedOptions,
|
||||||
updateItemIntegrations,
|
updateItemIntegrations,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 4px 40px 4px 8px;
|
padding: 4px 8px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
|
|||||||
@@ -2,16 +2,17 @@
|
|||||||
|
|
||||||
The board uses fixed logical units:
|
The board uses fixed logical units:
|
||||||
|
|
||||||
- one grid track has a `212 × 212` logical-pixel footprint;
|
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||||
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter;
|
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
|
||||||
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset);
|
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
|
||||||
- the canvas is zoomed once with one uniform scale derived from its available width.
|
- the canvas is zoomed once with one uniform scale derived from its available width.
|
||||||
|
|
||||||
Grid footprints keep the same logical dimensions at every viewport. Widget cards
|
Items keep the same logical dimensions at every viewport. The viewport reserves
|
||||||
use a scale-compensated inset so their painted size and content measurements
|
the painted size while the inner canvas remains at its logical dimensions, so a
|
||||||
match the legacy responsive grid. The visual canvas always fits the available
|
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
|
||||||
width, every root lane keeps at least one viewport of height, and edit-mode
|
stable. The visual canvas always fits the available width, every root lane
|
||||||
previews extend it downward as needed.
|
keeps at least one viewport of height, and edit-mode previews extend it downward
|
||||||
|
as needed.
|
||||||
|
|
||||||
The read-only renderer should:
|
The read-only renderer should:
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { BoardLane } from "@homarr/definitions";
|
|||||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||||
|
|
||||||
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
|
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
import { getLogicalGridSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
|
import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
|
||||||
import { getCollapsedDisplayLayout } from "./reflow";
|
import { getCollapsedDisplayLayout } from "./reflow";
|
||||||
import type { GridPlacement } from "./types";
|
import type { GridPlacement } from "./types";
|
||||||
|
|
||||||
@@ -90,5 +90,5 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
|
|||||||
return [getLayoutRowCount(displayPlacements)];
|
return [getLayoutRowCount(displayPlacements)];
|
||||||
});
|
});
|
||||||
|
|
||||||
return getLogicalGridSize(Math.max(0, ...rowCounts));
|
return getLogicalTrackSize(Math.max(0, ...rowCounts));
|
||||||
};
|
};
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* individual cards never change their internal layout at different viewports.
|
* individual cards never change their internal layout at different viewports.
|
||||||
*/
|
*/
|
||||||
export const LOGICAL_GRID_CELL_SIZE = 200;
|
export const LOGICAL_GRID_CELL_SIZE = 200;
|
||||||
export const LOGICAL_GRID_GAP = 12;
|
export const LOGICAL_GRID_GAP = 24;
|
||||||
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||||
|
|
||||||
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
||||||
@@ -10,23 +10,12 @@ export const getLogicalTrackSize = (trackCount: number) => {
|
|||||||
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
|
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the complete footprint occupied by grid tracks. Cards are inset
|
|
||||||
* inside this footprint so their visual gutters stay fixed while the canvas
|
|
||||||
* is scaled.
|
|
||||||
*/
|
|
||||||
export const getLogicalGridSize = (trackCount: number) => {
|
|
||||||
const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount"));
|
|
||||||
|
|
||||||
return normalizedTrackCount * LOGICAL_GRID_PITCH;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
|
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: x * LOGICAL_GRID_PITCH,
|
left: x * LOGICAL_GRID_PITCH,
|
||||||
top: y * LOGICAL_GRID_PITCH,
|
top: y * LOGICAL_GRID_PITCH,
|
||||||
width: getLogicalGridSize(w),
|
width: getLogicalTrackSize(w),
|
||||||
height: getLogicalGridSize(h),
|
height: getLogicalTrackSize(h),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
|
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
|
||||||
@@ -36,7 +25,7 @@ export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale
|
|||||||
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
|
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
|
||||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
|
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
|
||||||
|
|
||||||
return Math.max(1, Math.ceil(visualHeight / visualScale / LOGICAL_GRID_PITCH));
|
return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
|
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
* zoom and enlarges everything together.
|
* zoom and enlarges everything together.
|
||||||
*/
|
*/
|
||||||
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
||||||
/* Keep a subtle boundary inset without widening the visual gap too much. */
|
|
||||||
--board-grid-item-inset: calc(var(--mantine-spacing-xs) / 2);
|
|
||||||
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
|
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
|
||||||
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||||
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
|||||||
@@ -5,12 +5,7 @@ import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/bo
|
|||||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-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 { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||||
import {
|
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
|
||||||
getBoardLaneColumnCount,
|
|
||||||
getInitialBoardLogicalHeight,
|
|
||||||
getLogicalGridSize,
|
|
||||||
getRootSectionForLane,
|
|
||||||
} from "../index";
|
|
||||||
|
|
||||||
describe("board lanes", () => {
|
describe("board lanes", () => {
|
||||||
test("gutters consume columns from the main canvas", () => {
|
test("gutters consume columns from the main canvas", () => {
|
||||||
@@ -131,6 +126,6 @@ describe("board lanes", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalGridSize(4));
|
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3,28 +3,24 @@ import { describe, expect, test } from "vitest";
|
|||||||
import {
|
import {
|
||||||
getGridRowCountForVisualHeight,
|
getGridRowCountForVisualHeight,
|
||||||
getLayoutRowCount,
|
getLayoutRowCount,
|
||||||
getLogicalGridSize,
|
|
||||||
getLogicalItemStyle,
|
getLogicalItemStyle,
|
||||||
getLogicalTrackSize,
|
getLogicalTrackSize,
|
||||||
normalizeGridPlacement,
|
normalizeGridPlacement,
|
||||||
} from "../index";
|
} from "../index";
|
||||||
|
|
||||||
describe("fixed dashboard geometry", () => {
|
describe("fixed dashboard geometry", () => {
|
||||||
test("separates content track sizes from complete grid footprints", () => {
|
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
|
||||||
expect(getLogicalTrackSize(1)).toBe(200);
|
expect(getLogicalTrackSize(1)).toBe(200);
|
||||||
expect(getLogicalTrackSize(0.5)).toBe(94);
|
expect(getLogicalTrackSize(0.5)).toBe(88);
|
||||||
expect(getLogicalTrackSize(2)).toBe(412);
|
expect(getLogicalTrackSize(2)).toBe(424);
|
||||||
expect(getLogicalTrackSize(3)).toBe(624);
|
expect(getLogicalTrackSize(3)).toBe(648);
|
||||||
expect(getLogicalGridSize(1)).toBe(212);
|
|
||||||
expect(getLogicalGridSize(2)).toBe(424);
|
|
||||||
expect(getLogicalGridSize(3)).toBe(636);
|
|
||||||
|
|
||||||
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: 212,
|
left: 224,
|
||||||
top: 424,
|
top: 448,
|
||||||
width: 424,
|
width: 424,
|
||||||
height: 636,
|
height: 648,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +35,7 @@ describe("fixed dashboard geometry", () => {
|
|||||||
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
|
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
|
||||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
|
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
|
||||||
|
|
||||||
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(12);
|
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
|
||||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
|
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -46,7 +46,6 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
<Card
|
<Card
|
||||||
className={combineClasses(
|
className={combineClasses(
|
||||||
classes.itemCard,
|
classes.itemCard,
|
||||||
classes.containerCard,
|
|
||||||
options.customCssClasses.join(" "),
|
options.customCssClasses.join(" "),
|
||||||
isVisuallyCollapsed && classes.collapsedContainerCard,
|
isVisuallyCollapsed && classes.collapsedContainerCard,
|
||||||
)}
|
)}
|
||||||
@@ -57,7 +56,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
root: {
|
root: {
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
"--opacity": board.opacity / 100,
|
"--opacity": board.opacity / 100,
|
||||||
"--container-border-color": options.borderColor || undefined,
|
"--border-color": options.borderColor || undefined,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
@@ -67,7 +66,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
className={classes.containerToggle}
|
className={classes.containerToggle}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
|
top={isVisuallyCollapsed ? 0 : -24}
|
||||||
left={0}
|
left={0}
|
||||||
w="100%"
|
w="100%"
|
||||||
h={isVisuallyCollapsed ? "100%" : 24}
|
h={isVisuallyCollapsed ? "100%" : 24}
|
||||||
@@ -104,9 +103,8 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
)}
|
)}
|
||||||
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
||||||
<Badge
|
<Badge
|
||||||
className={classes.containerLabel}
|
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
top="calc(var(--mantine-spacing-xs) * -1)"
|
top={-24}
|
||||||
left={labelLeft}
|
left={labelLeft}
|
||||||
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
||||||
size="md"
|
size="md"
|
||||||
@@ -115,7 +113,12 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
c="var(--mantine-color-text)"
|
c="var(--mantine-color-text)"
|
||||||
style={{
|
style={{
|
||||||
zIndex: 9,
|
zIndex: 9,
|
||||||
|
overflow: "hidden",
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
backgroundColor: "var(--background-color)",
|
||||||
|
borderColor: "var(--border-color)",
|
||||||
}}
|
}}
|
||||||
title={options.title}
|
title={options.title}
|
||||||
data-board-container-label
|
data-board-container-label
|
||||||
@@ -125,9 +128,8 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
)}
|
)}
|
||||||
{options.showOpenAll && !isEditMode && (
|
{options.showOpenAll && !isEditMode && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
className={classes.containerAction}
|
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
top={isVisuallyCollapsed ? "50%" : "calc(var(--mantine-spacing-xs) * -1)"}
|
top={isVisuallyCollapsed ? "50%" : -24}
|
||||||
right={8}
|
right={8}
|
||||||
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
|
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
|
||||||
variant={options.collapsible ? "subtle" : "default"}
|
variant={options.collapsible ? "subtle" : "default"}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
|
|
||||||
import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
|
import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
|
||||||
import { useRegisteredGridEditors } from "./grid-editor-registry";
|
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>;
|
||||||
@@ -54,14 +54,14 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
|||||||
const GridEditor = resolvedEditorModule?.default ?? null;
|
const GridEditor = resolvedEditorModule?.default ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<GridEditorRegistryProvider>
|
||||||
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
|
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
|
||||||
{isEditMode && Provider && GridEditor && (
|
{isEditMode && Provider && GridEditor && (
|
||||||
<Provider>
|
<Provider>
|
||||||
<RegisteredGridEditors GridEditor={GridEditor} />
|
<RegisteredGridEditors GridEditor={GridEditor} />
|
||||||
</Provider>
|
</Provider>
|
||||||
)}
|
)}
|
||||||
</>
|
</GridEditorRegistryProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,7 @@ describe("dnd grid coordinates", () => {
|
|||||||
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
|
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
|
||||||
const scale = 0.75;
|
const scale = 0.75;
|
||||||
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
|
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
|
||||||
const initialShape = {
|
const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale };
|
||||||
left: target.left,
|
|
||||||
top: target.top,
|
|
||||||
width: getLogicalTrackSize(2) * scale,
|
|
||||||
height: 200 * scale,
|
|
||||||
};
|
|
||||||
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
|
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
import type { GridPlacement } from "~/components/board/layout";
|
import type { GridPlacement } from "~/components/board/layout";
|
||||||
import type { GridResizeDirection } from "./types";
|
import type { GridResizeDirection } from "./types";
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export const getSnappedGridCoordinates = ({
|
|||||||
target: RectangleLike;
|
target: RectangleLike;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
}) => {
|
}) => {
|
||||||
const logicalWidth = getLogicalGridSize(columnCount);
|
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||||
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
|
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
|
||||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
|
if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export interface GridEditorRenderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisteredGridEditor extends GridEditorRenderProps {
|
export interface RegisteredGridEditor extends GridEditorRenderProps {
|
||||||
placementMaxRowCount: number | null;
|
|
||||||
host: HTMLElement;
|
host: HTMLElement;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
.board-grid-entry[data-board-item-selected="true"]::after {
|
.board-grid-entry[data-board-item-selected="true"]::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: var(--board-grid-item-inset, 0px);
|
inset: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
content: "";
|
content: "";
|
||||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
||||||
@@ -121,7 +121,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
|
|
||||||
.board-grid-placeholder {
|
.board-grid-placeholder {
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
clip-path: inset(var(--board-grid-item-inset, 0px));
|
|
||||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||||
border: 2px dashed var(--mantine-primary-color-filled);
|
border: 2px dashed var(--mantine-primary-color-filled);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
@@ -140,7 +139,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
|
|
||||||
.board-grid-resize-outline {
|
.board-grid-resize-outline {
|
||||||
z-index: 1002;
|
z-index: 1002;
|
||||||
clip-path: inset(var(--board-grid-item-inset, 0px));
|
|
||||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
|
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
|
||||||
border: 2px solid var(--mantine-primary-color-filled);
|
border: 2px solid var(--mantine-primary-color-filled);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
@@ -155,6 +153,17 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
|
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-section-kind="empty"][data-rail-placement="main"] > .board-grid-editor[data-dnd-drop-target="true"] {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-section-kind="empty"][data-rail-placement="main"]
|
||||||
|
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
|
||||||
|
outline-color: var(--mantine-color-red-filled);
|
||||||
|
}
|
||||||
|
|
||||||
[data-board-container-collapsed]
|
[data-board-container-collapsed]
|
||||||
> [data-board-container-body]
|
> [data-board-container-body]
|
||||||
> [data-section-id]
|
> [data-section-id]
|
||||||
@@ -231,11 +240,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||||
top: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
top: -0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||||
bottom: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
bottom: -0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||||
@@ -249,11 +258,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||||
right: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
right: -0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||||
left: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
left: -0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||||
@@ -266,30 +275,42 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||||
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
top: -0.625rem;
|
||||||
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
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: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
right: -0.625rem;
|
||||||
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
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: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
bottom: -0.625rem;
|
||||||
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
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: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
top: -0.625rem;
|
||||||
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
left: -0.625rem;
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
/* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area,
|
/* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area,
|
||||||
so it doesn't sit on top of the container menu button anchored in this same corner. */
|
so it doesn't sit on top of the container menu button anchored in this same corner. */
|
||||||
clip-path: polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, 4px 4px, 4px 40px, 40px 40px, 40px 4px, 4px 4px);
|
clip-path: polygon(
|
||||||
|
evenodd,
|
||||||
|
0 0,
|
||||||
|
100% 0,
|
||||||
|
100% 100%,
|
||||||
|
0 100%,
|
||||||
|
0 0,
|
||||||
|
4px 4px,
|
||||||
|
4px 40px,
|
||||||
|
40px 40px,
|
||||||
|
40px 4px,
|
||||||
|
4px 4px
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
|||||||
import { getWidgetName } from "@homarr/definitions";
|
import { getWidgetName } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
import {
|
import {
|
||||||
beginGridTransaction,
|
beginGridTransaction,
|
||||||
cancelGridTransaction,
|
cancelGridTransaction,
|
||||||
@@ -792,8 +792,8 @@ export default function GridEditor({
|
|||||||
data-grid-depth={depth}
|
data-grid-depth={depth}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
width: getLogicalGridSize(columnCount),
|
width: getLogicalTrackSize(columnCount),
|
||||||
height: getLogicalGridSize(rowCount),
|
height: getLogicalTrackSize(rowCount),
|
||||||
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
|
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
@@ -1234,8 +1234,8 @@ const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGrid
|
|||||||
if (!element) return null;
|
if (!element) return null;
|
||||||
const rectangle = element.getBoundingClientRect();
|
const rectangle = element.getBoundingClientRect();
|
||||||
const scale = {
|
const scale = {
|
||||||
x: rectangle.width / getLogicalGridSize(placement.w),
|
x: rectangle.width / getLogicalTrackSize(placement.w),
|
||||||
y: rectangle.height / getLogicalGridSize(placement.h),
|
y: rectangle.height / getLogicalTrackSize(placement.h),
|
||||||
};
|
};
|
||||||
return Number.isFinite(scale.x) && scale.x > 0 && Number.isFinite(scale.y) && scale.y > 0 ? scale : null;
|
return Number.isFinite(scale.x) && scale.x > 0 && Number.isFinite(scale.y) && scale.y > 0 ? scale : null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
import type { GridPlacement } from "~/components/board/layout";
|
import type { GridPlacement } from "~/components/board/layout";
|
||||||
|
|
||||||
export interface GridPreviewDomState {
|
export interface GridPreviewDomState {
|
||||||
@@ -75,8 +75,8 @@ export const clearGridPreviewGeometry = (
|
|||||||
const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => {
|
const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => {
|
||||||
element.style.setProperty("--board-grid-preview-x", `${(preview.x - controlled.x) * LOGICAL_GRID_PITCH}px`);
|
element.style.setProperty("--board-grid-preview-x", `${(preview.x - controlled.x) * LOGICAL_GRID_PITCH}px`);
|
||||||
element.style.setProperty("--board-grid-preview-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`);
|
element.style.setProperty("--board-grid-preview-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`);
|
||||||
element.style.setProperty("--board-grid-preview-width", `${getLogicalGridSize(preview.w)}px`);
|
element.style.setProperty("--board-grid-preview-width", `${getLogicalTrackSize(preview.w)}px`);
|
||||||
element.style.setProperty("--board-grid-preview-height", `${getLogicalGridSize(preview.h)}px`);
|
element.style.setProperty("--board-grid-preview-height", `${getLogicalTrackSize(preview.h)}px`);
|
||||||
element.setAttribute("data-grid-preview", "true");
|
element.setAttribute("data-grid-preview", "true");
|
||||||
setPlacementAttributes(element, preview);
|
setPlacementAttributes(element, preview);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
import { getLayoutRowCount, getLogicalItemStyle, getLogicalGridSize } from "~/components/board/layout";
|
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
|
||||||
import type { TransactionalGridState } from "./dnd";
|
import type { TransactionalGridState } from "./dnd";
|
||||||
import type { GridInteractionStore } from "./grid-interaction-store";
|
import type { GridInteractionStore } from "./grid-interaction-store";
|
||||||
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
|
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
|
||||||
@@ -87,7 +87,7 @@ export const GridPreviewLayer = ({
|
|||||||
} else {
|
} else {
|
||||||
delete grid.dataset.dndPreviewRevision;
|
delete grid.dataset.dndPreviewRevision;
|
||||||
}
|
}
|
||||||
grid.style.height = `${getLogicalGridSize(renderedRowCount)}px`;
|
grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
|
||||||
|
|
||||||
const viewport = grid.parentElement;
|
const viewport = grid.parentElement;
|
||||||
if (viewport?.hasAttribute("data-section-id")) {
|
if (viewport?.hasAttribute("data-section-id")) {
|
||||||
@@ -98,7 +98,7 @@ export const GridPreviewLayer = ({
|
|||||||
// stray value would silently blow the cap open for the rest of the session, well after any
|
// stray value would silently blow the cap open for the rest of the session, well after any
|
||||||
// drag ended, since nothing else would ever clear it back out.
|
// drag ended, since nothing else would ever clear it back out.
|
||||||
if (maxRowCount === null) {
|
if (maxRowCount === null) {
|
||||||
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalGridSize(renderedRowCount)}px`);
|
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
|
||||||
} else {
|
} else {
|
||||||
viewport.style.removeProperty("--board-grid-drag-height");
|
viewport.style.removeProperty("--board-grid-drag-height");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
import type { GridPlacement } from "~/components/board/layout";
|
import type { GridPlacement } from "~/components/board/layout";
|
||||||
|
|
||||||
interface ResizeOutlinePreview {
|
interface ResizeOutlinePreview {
|
||||||
@@ -31,8 +31,8 @@ export const createGridResizeOutlineController = (
|
|||||||
outline.style.position = "absolute";
|
outline.style.position = "absolute";
|
||||||
outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`;
|
outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`;
|
||||||
outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`;
|
outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`;
|
||||||
outline.style.width = `${getLogicalGridSize(preview.placement.w)}px`;
|
outline.style.width = `${getLogicalTrackSize(preview.placement.w)}px`;
|
||||||
outline.style.height = `${getLogicalGridSize(preview.placement.h)}px`;
|
outline.style.height = `${getLogicalTrackSize(preview.placement.h)}px`;
|
||||||
outline.dataset.gridResizeValid = String(preview.valid);
|
outline.dataset.gridResizeValid = String(preview.valid);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,18 +46,12 @@
|
|||||||
|
|
||||||
.contentMount {
|
.contentMount {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: var(--board-grid-item-inset, 0px);
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.staticItem[data-type="item"] > .contentMount {
|
|
||||||
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.staticItem:has([data-board-container-label]) {
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editorEntry {
|
.editorEntry {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
getEditableCanvasAttributes,
|
getEditableCanvasAttributes,
|
||||||
getGridRowCountForVisualHeight,
|
getGridRowCountForVisualHeight,
|
||||||
getLayoutRowCount,
|
getLayoutRowCount,
|
||||||
getLogicalGridSize,
|
getLogicalTrackSize,
|
||||||
getReadonlyCanvasAttributes,
|
getReadonlyCanvasAttributes,
|
||||||
normalizeGridPlacement,
|
normalizeGridPlacement,
|
||||||
} from "~/components/board/layout";
|
} from "~/components/board/layout";
|
||||||
@@ -130,17 +130,13 @@ export const SectionGrid = ({
|
|||||||
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;
|
||||||
let placementMaxRowCount = maxRowCount;
|
|
||||||
if (railPlacement !== "main") {
|
|
||||||
placementMaxRowCount = minimumViewportRowCount;
|
|
||||||
}
|
|
||||||
// A scrollable container isn't forced to grow with its content - it scrolls internally instead
|
// A scrollable container isn't forced to grow with its content - it scrolls internally instead
|
||||||
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
|
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
|
||||||
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
||||||
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
|
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
|
||||||
const logicalWidth = getLogicalGridSize(columnCount);
|
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||||
const logicalHeight = getLogicalGridSize(rowCount);
|
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||||
const viewportHeight = getLogicalGridSize(viewportRowCount);
|
const viewportHeight = getLogicalTrackSize(viewportRowCount);
|
||||||
// A collapsed container's compact coordinates are display-only. Its own
|
// A collapsed container's compact coordinates are display-only. Its own
|
||||||
// nested grid stays inactive until an explicit edit interaction expands it.
|
// nested grid stays inactive until an explicit edit interaction expands it.
|
||||||
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
|
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
|
||||||
@@ -179,7 +175,6 @@ export const SectionGrid = ({
|
|||||||
columnCount,
|
columnCount,
|
||||||
rowCount,
|
rowCount,
|
||||||
maxRowCount,
|
maxRowCount,
|
||||||
placementMaxRowCount,
|
|
||||||
placements: displayPlacements,
|
placements: displayPlacements,
|
||||||
transactionPlacements: placements,
|
transactionPlacements: placements,
|
||||||
className: editorClassName,
|
className: editorClassName,
|
||||||
@@ -195,7 +190,6 @@ export const SectionGrid = ({
|
|||||||
entryElementStore,
|
entryElementStore,
|
||||||
isInteractionDisabled,
|
isInteractionDisabled,
|
||||||
maxRowCount,
|
maxRowCount,
|
||||||
placementMaxRowCount,
|
|
||||||
placements,
|
placements,
|
||||||
rowCount,
|
rowCount,
|
||||||
section,
|
section,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import type { Root } from "react-dom/client";
|
import type { Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { getCollapsedDisplayLayout, getLogicalGridSize } from "../../../layout";
|
import { getCollapsedDisplayLayout, LOGICAL_GRID_CELL_SIZE } from "../../../layout";
|
||||||
import { FixedGridItem } from "../fixed-grid-item";
|
import { FixedGridItem } from "../fixed-grid-item";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@@ -250,14 +250,14 @@ describe("fixed grid item behavior", () => {
|
|||||||
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull();
|
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders a view-only 1x1 card at the complete grid footprint", () => {
|
test("renders a view-only 1x1 card at the fixed 200px logical size", () => {
|
||||||
mocks.editMode = false;
|
mocks.editMode = false;
|
||||||
renderWeather(root);
|
renderWeather(root);
|
||||||
|
|
||||||
const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]');
|
const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]');
|
||||||
expect(item).not.toBeNull();
|
expect(item).not.toBeNull();
|
||||||
expect(item?.style.width).toBe(`${getLogicalGridSize(1)}px`);
|
expect(item?.style.width).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
|
||||||
expect(item?.style.height).toBe(`${getLogicalGridSize(1)}px`);
|
expect(item?.style.height).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
|
||||||
expect(item?.getAttribute("role")).toBeNull();
|
expect(item?.getAttribute("role")).toBeNull();
|
||||||
|
|
||||||
act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, test } from "vitest";
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
|
||||||
|
|
||||||
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
||||||
|
|
||||||
describe("grid preview geometry", () => {
|
describe("grid preview geometry", () => {
|
||||||
@@ -27,12 +25,8 @@ describe("grid preview geometry", () => {
|
|||||||
|
|
||||||
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
|
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
|
||||||
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
|
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
|
||||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe(
|
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe("-448px");
|
||||||
`${-2 * LOGICAL_GRID_PITCH}px`,
|
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px");
|
||||||
);
|
|
||||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe(
|
|
||||||
`${2 * LOGICAL_GRID_PITCH}px`,
|
|
||||||
);
|
|
||||||
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
|
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
|
||||||
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
|
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
|
||||||
|
|
||||||
@@ -63,10 +57,10 @@ describe("grid preview geometry", () => {
|
|||||||
|
|
||||||
const active = elements.get("active");
|
const active = elements.get("active");
|
||||||
expect(active?.getAttribute("data-grid-preview")).toBe("true");
|
expect(active?.getAttribute("data-grid-preview")).toBe("true");
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe(`${LOGICAL_GRID_PITCH}px`);
|
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe("224px");
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe(`${LOGICAL_GRID_PITCH}px`);
|
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe("224px");
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalGridSize(3)}px`);
|
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe("648px");
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalGridSize(4)}px`);
|
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe("872px");
|
||||||
expect(active?.dataset.gridW).toBe("3");
|
expect(active?.dataset.gridW).toBe("3");
|
||||||
expect(active?.dataset.gridH).toBe("4");
|
expect(active?.dataset.gridH).toBe("4");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { getLogicalGridSize } from "~/components/board/layout";
|
|
||||||
|
|
||||||
import { createGridResizeOutlineController } from "../grid-resize-outline";
|
import { createGridResizeOutlineController } from "../grid-resize-outline";
|
||||||
|
|
||||||
describe("grid resize outline", () => {
|
describe("grid resize outline", () => {
|
||||||
@@ -30,8 +28,8 @@ describe("grid resize outline", () => {
|
|||||||
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
|
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
|
||||||
expect(outline?.style.left).toBe("0px");
|
expect(outline?.style.left).toBe("0px");
|
||||||
expect(outline?.style.top).toBe("0px");
|
expect(outline?.style.top).toBe("0px");
|
||||||
expect(outline?.style.width).toBe(`${getLogicalGridSize(1)}px`);
|
expect(outline?.style.width).toBe("200px");
|
||||||
expect(outline?.style.height).toBe(`${getLogicalGridSize(2)}px`);
|
expect(outline?.style.height).toBe("424px");
|
||||||
expect(outline?.dataset.gridResizeValid).toBe("false");
|
expect(outline?.dataset.gridResizeValid).toBe("false");
|
||||||
|
|
||||||
controller.schedule(null);
|
controller.schedule(null);
|
||||||
|
|||||||
@@ -11,18 +11,6 @@
|
|||||||
border-color: var(--border-color) !important;
|
border-color: var(--border-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.containerCard {
|
|
||||||
@mixin dark {
|
|
||||||
--container-default-border-color: var(--mantine-color-dark-4);
|
|
||||||
}
|
|
||||||
@mixin light {
|
|
||||||
--container-default-border-color: var(--mantine-color-gray-3);
|
|
||||||
}
|
|
||||||
--border-color: rgb(
|
|
||||||
from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.itemCard:global(.iframe-wrapper) {
|
.itemCard:global(.iframe-wrapper) {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
border-color: transparent !important;
|
border-color: transparent !important;
|
||||||
@@ -38,8 +26,8 @@
|
|||||||
z-index: 9;
|
z-index: 9;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-text);
|
||||||
background-color: var(--background-color) !important;
|
background-color: var(--background-color);
|
||||||
border-color: var(--border-color) !important;
|
border-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.containerToggle :global(.mantine-Button-inner) {
|
.containerToggle :global(.mantine-Button-inner) {
|
||||||
@@ -54,24 +42,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.containerLabel,
|
|
||||||
.containerAction {
|
|
||||||
background-color: var(--background-color) !important;
|
|
||||||
border-color: var(--border-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.containerLabel {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.containerLabel :global(.mantine-Badge-label) {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.containerBody {
|
.containerBody {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface BoardSelectionContextValue {
|
|||||||
copySelectedItems: () => Promise<void>;
|
copySelectedItems: () => Promise<void>;
|
||||||
pasteItems: () => Promise<void>;
|
pasteItems: () => Promise<void>;
|
||||||
removeSelectedItems: () => void;
|
removeSelectedItems: () => void;
|
||||||
moveSelectedItemsToSection: (sectionId: string, maxRowCount?: number | null) => void;
|
moveSelectedItemsToSection: (sectionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
|
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
|
||||||
@@ -229,7 +229,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
|||||||
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
|
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
|
||||||
|
|
||||||
const moveSelectedItemsToSection = useCallback(
|
const moveSelectedItemsToSection = useCallback(
|
||||||
(targetSectionId: string, maxRowCount?: number | null) => {
|
(targetSectionId: string) => {
|
||||||
if (selectedItemIds.size === 0) return;
|
if (selectedItemIds.size === 0) return;
|
||||||
|
|
||||||
const idsToMove = new Set(selectedItemIds);
|
const idsToMove = new Set(selectedItemIds);
|
||||||
@@ -248,7 +248,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
|||||||
targetSection.kind === "container"
|
targetSection.kind === "container"
|
||||||
? (containerLayout?.width ?? 0)
|
? (containerLayout?.width ?? 0)
|
||||||
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
|
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
|
||||||
const rowCount = containerLayout?.height ?? maxRowCount ?? 9999;
|
const rowCount = containerLayout?.height ?? 9999;
|
||||||
if (columnCount === 0 || rowCount === 0) return previous;
|
if (columnCount === 0 || rowCount === 0) return previous;
|
||||||
|
|
||||||
const existingContainers = previous.sections
|
const existingContainers = previous.sections
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||||
|
|
||||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
|
|
||||||
import { useBoardSelection } from "./board-selection-context";
|
import { useBoardSelection } from "./board-selection-context";
|
||||||
|
|
||||||
const MAX_AVATARS_DISPLAYED = 5;
|
const MAX_AVATARS_DISPLAYED = 5;
|
||||||
@@ -41,7 +40,6 @@ export const BoardSelectionToolbar = () => {
|
|||||||
moveSelectedItemsToSection,
|
moveSelectedItemsToSection,
|
||||||
} = useBoardSelection();
|
} = useBoardSelection();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const registeredGridEditors = useRegisteredGridEditors();
|
|
||||||
const currentLayoutId = useCurrentLayout();
|
const currentLayoutId = useCurrentLayout();
|
||||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -98,15 +96,7 @@ export const BoardSelectionToolbar = () => {
|
|||||||
.at(0);
|
.at(0);
|
||||||
|
|
||||||
const placementOptions = [
|
const placementOptions = [
|
||||||
...(mainCanvasSection
|
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
|
||||||
? [
|
|
||||||
{
|
|
||||||
value: mainCanvasSection.id,
|
|
||||||
label: t("item.create.destination.mainCanvas"),
|
|
||||||
maxRowCount: null,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...board.sections.flatMap((section) => {
|
...board.sections.flatMap((section) => {
|
||||||
if (section.kind !== "empty") return [];
|
if (section.kind !== "empty") return [];
|
||||||
const lane = getRootSectionLane(section.xOffset);
|
const lane = getRootSectionLane(section.xOffset);
|
||||||
@@ -115,7 +105,6 @@ export const BoardSelectionToolbar = () => {
|
|||||||
{
|
{
|
||||||
value: section.id,
|
value: section.id,
|
||||||
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
||||||
maxRowCount: registeredGridEditors.get(section.id)?.placementMaxRowCount,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
@@ -124,7 +113,6 @@ export const BoardSelectionToolbar = () => {
|
|||||||
.map((section) => ({
|
.map((section) => ({
|
||||||
value: section.id,
|
value: section.id,
|
||||||
label: section.options.title.trim() || t("section.container.untitled"),
|
label: section.options.title.trim() || t("section.container.untitled"),
|
||||||
maxRowCount: section.layouts.find((layout) => layout.layoutId === currentLayoutId)?.height,
|
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -256,11 +244,7 @@ export const BoardSelectionToolbar = () => {
|
|||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
|
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
|
||||||
{placementOptions.map((opt) => (
|
{placementOptions.map((opt) => (
|
||||||
<Menu.Item
|
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
|
||||||
key={opt.value}
|
|
||||||
disabled={opt.maxRowCount === undefined}
|
|
||||||
onClick={() => moveSelectedItemsToSection(opt.value, opt.maxRowCount)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type { HotkeyItem } from "@mantine/hooks";
|
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
|
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
@@ -14,18 +13,23 @@ const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component })
|
|||||||
loading: () => null,
|
loading: () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const spotlightHotkeys: HotkeyItem[] = [
|
|
||||||
[hotkeys.openSpotlight, () => openSpotlight(), { preventDefault: true }],
|
|
||||||
[hotkeys.openSpotlightApps, () => openSpotlight({ mode: "apps" }), { preventDefault: true }],
|
|
||||||
[hotkeys.openSpotlightAssistant, () => openSpotlight({ mode: "assistant" }), { preventDefault: true }],
|
|
||||||
[hotkeys.openSpotlightPreferences, () => openSpotlight({ mode: "preferences" }), { preventDefault: true }],
|
|
||||||
];
|
|
||||||
|
|
||||||
export const LazySpotlight = () => {
|
export const LazySpotlight = () => {
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [isPreloaded, setIsPreloaded] = useState(false);
|
const [isPreloaded, setIsPreloaded] = useState(false);
|
||||||
|
|
||||||
useHotkeys(spotlightHotkeys, [], true);
|
useHotkeys(
|
||||||
|
isMounted
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
[
|
||||||
|
hotkeys.openSpotlight,
|
||||||
|
() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
openSpotlight();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
@@ -57,8 +61,12 @@ export const LazySpotlight = () => {
|
|||||||
return isMounted || isPreloaded ? (
|
return isMounted || isPreloaded ? (
|
||||||
<>
|
<>
|
||||||
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||||
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
|
{isMounted && (
|
||||||
<Spotlight />
|
<>
|
||||||
|
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
|
||||||
|
<Spotlight />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
@@ -20,7 +20,7 @@ export const DesktopSearchInput = () => {
|
|||||||
w={400}
|
w={400}
|
||||||
size="sm"
|
size="sm"
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
onClick={() => openSpotlight()}
|
onClick={openSpotlight}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
>
|
>
|
||||||
{`${t("search.placeholder")}...`}
|
{`${t("search.placeholder")}...`}
|
||||||
@@ -32,7 +32,7 @@ export const MobileSearchButton = () => {
|
|||||||
return (
|
return (
|
||||||
<HeaderButton
|
<HeaderButton
|
||||||
data-homarr-dev-benchmark-interaction="search"
|
data-homarr-dev-benchmark-interaction="search"
|
||||||
onClick={() => openSpotlight()}
|
onClick={openSpotlight}
|
||||||
className={classes.mobileSearch}
|
className={classes.mobileSearch}
|
||||||
>
|
>
|
||||||
<IconSearch size={20} stroke={1.5} />
|
<IconSearch size={20} stroke={1.5} />
|
||||||
|
|||||||
+28
-114
@@ -1,6 +1,5 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { chromium, expect } from "@playwright/test";
|
import { chromium, expect } from "@playwright/test";
|
||||||
import type { Route } from "@playwright/test";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { stringify } from "superjson";
|
import { stringify } from "superjson";
|
||||||
import { describe, test } from "vitest";
|
import { describe, test } from "vitest";
|
||||||
@@ -22,9 +21,7 @@ describe("lazy widget application graph", () => {
|
|||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
const layoutId = createId();
|
const layoutId = createId();
|
||||||
const sectionId = createId();
|
const sectionId = createId();
|
||||||
const clockItemId = createId();
|
const itemId = createId();
|
||||||
const downloadsItemId = createId();
|
|
||||||
const mockIntegrationId = createId();
|
|
||||||
|
|
||||||
await db.insert(sqliteSchema.boards).values({
|
await db.insert(sqliteSchema.boards).values({
|
||||||
id: boardId,
|
id: boardId,
|
||||||
@@ -44,56 +41,26 @@ describe("lazy widget application graph", () => {
|
|||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
});
|
});
|
||||||
await db.insert(sqliteSchema.integrations).values({
|
await db.insert(sqliteSchema.items).values({
|
||||||
id: mockIntegrationId,
|
id: itemId,
|
||||||
name: "Mock downloads",
|
boardId,
|
||||||
url: "http://mock.local",
|
kind: "clock",
|
||||||
kind: "mock",
|
options: stringify({ showSeconds: true }),
|
||||||
});
|
});
|
||||||
await db.insert(sqliteSchema.items).values([
|
await db.insert(sqliteSchema.itemLayouts).values({
|
||||||
{
|
itemId,
|
||||||
id: clockItemId,
|
sectionId,
|
||||||
boardId,
|
layoutId,
|
||||||
kind: "clock",
|
xOffset: 0,
|
||||||
options: stringify({ showSeconds: true }),
|
yOffset: 0,
|
||||||
},
|
width: 4,
|
||||||
{
|
height: 2,
|
||||||
id: downloadsItemId,
|
|
||||||
boardId,
|
|
||||||
kind: "downloads",
|
|
||||||
options: stringify({}),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await db.insert(sqliteSchema.itemLayouts).values([
|
|
||||||
{
|
|
||||||
itemId: clockItemId,
|
|
||||||
sectionId,
|
|
||||||
layoutId,
|
|
||||||
xOffset: 0,
|
|
||||||
yOffset: 0,
|
|
||||||
width: 4,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemId: downloadsItemId,
|
|
||||||
sectionId,
|
|
||||||
layoutId,
|
|
||||||
xOffset: 0,
|
|
||||||
yOffset: 2,
|
|
||||||
width: 8,
|
|
||||||
height: 5,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await db.insert(sqliteSchema.integrationItems).values({
|
|
||||||
itemId: downloadsItemId,
|
|
||||||
integrationId: mockIntegrationId,
|
|
||||||
});
|
});
|
||||||
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
||||||
|
|
||||||
const homarrContainer = await createHomarrContainer({
|
const homarrContainer = await createHomarrContainer({
|
||||||
environment: {
|
environment: {
|
||||||
AUTH_PROVIDERS: "credentials",
|
AUTH_PROVIDERS: "credentials",
|
||||||
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
|
|
||||||
},
|
},
|
||||||
mounts: {
|
mounts: {
|
||||||
"/appdata": localMountPath,
|
"/appdata": localMountPath,
|
||||||
@@ -102,18 +69,10 @@ describe("lazy widget application graph", () => {
|
|||||||
|
|
||||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const context = await browser.newContext({ serviceWorkers: "block" });
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const pageErrors: Error[] = [];
|
const pageErrors: Error[] = [];
|
||||||
const hydrationErrors: string[] = [];
|
|
||||||
page.on("pageerror", (error) => pageErrors.push(error));
|
page.on("pageerror", (error) => pageErrors.push(error));
|
||||||
const hydrationErrorPattern = /hydrat|did not match|react\.dev\/errors\/4(18|2[1235])|react error #4(18|2[1235])/i;
|
|
||||||
page.on("console", (message) => {
|
|
||||||
if (message.type() === "error" && hydrationErrorPattern.test(message.text())) {
|
|
||||||
hydrationErrors.push(message.text());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let releaseRefresh: (() => void) | undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`${baseUrl}/auth/login`);
|
await page.goto(`${baseUrl}/auth/login`);
|
||||||
@@ -129,18 +88,9 @@ describe("lazy widget application graph", () => {
|
|||||||
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
||||||
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
||||||
const clockWidget = page.locator(
|
const clockWidget = page.locator(
|
||||||
`[data-id="${clockItemId}"] .clock-widget-container, [data-grid-item-id="${clockItemId}"] .clock-widget-container`,
|
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
||||||
);
|
);
|
||||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
const downloadsWidget = page
|
|
||||||
.locator(`[data-id="${downloadsItemId}"], [data-grid-item-id="${downloadsItemId}"]`)
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first();
|
|
||||||
const cachedDownload = downloadsWidget.getByText("Big.Buck.Bunny.2008.1080p.BluRay.x264");
|
|
||||||
await expect(cachedDownload).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
@@ -151,59 +101,23 @@ describe("lazy widget application graph", () => {
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
||||||
|
|
||||||
let refreshRequestStarted = false;
|
await expect
|
||||||
const refreshGate = new Promise<void>((resolve) => {
|
.poll(
|
||||||
releaseRefresh = resolve;
|
async () =>
|
||||||
});
|
await page.evaluate(async () => {
|
||||||
const downloadsRequestPattern = "**/*widget.downloads.getJobsAndStatuses*";
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
const delayDownloadsRefresh = async (route: Route) => {
|
return registrations[0]?.scope ?? null;
|
||||||
refreshRequestStarted = true;
|
}),
|
||||||
await refreshGate;
|
{ timeout: 15_000 },
|
||||||
await route.continue();
|
)
|
||||||
};
|
.toBe(`${baseUrl}/`);
|
||||||
await page.route(downloadsRequestPattern, delayDownloadsRefresh);
|
|
||||||
|
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload();
|
||||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
|
||||||
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
|
|
||||||
|
|
||||||
const refreshedDownloadsResponse = page.waitForResponse(
|
|
||||||
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
|
||||||
);
|
|
||||||
releaseRefresh();
|
|
||||||
releaseRefresh = undefined;
|
|
||||||
await refreshedDownloadsResponse;
|
|
||||||
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
|
|
||||||
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
|
||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
expect(hydrationErrors).toEqual([]);
|
|
||||||
|
|
||||||
const serviceWorkerContext = await browser.newContext();
|
|
||||||
const serviceWorkerPage = await serviceWorkerContext.newPage();
|
|
||||||
try {
|
|
||||||
await serviceWorkerPage.goto(`${baseUrl}/auth/login`);
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () =>
|
|
||||||
await serviceWorkerPage.evaluate(async () => {
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
||||||
return registrations[0]?.scope ?? null;
|
|
||||||
}),
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toBe(`${baseUrl}/`);
|
|
||||||
} finally {
|
|
||||||
await serviceWorkerContext.close();
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
releaseRefresh?.();
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
await homarrContainer.stop();
|
await homarrContainer.stop();
|
||||||
}
|
}
|
||||||
}, 180_000);
|
}, 120_000);
|
||||||
});
|
});
|
||||||
@@ -8,8 +8,6 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
|||||||
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||||
|
|
||||||
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||||
// Beszel system stats are large time-series payloads and were deliberately kept out of
|
|
||||||
// the persisted cache in #6289; live stats come from an SSE subscription instead.
|
|
||||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||||
|
|
||||||
export const isWidgetDataTrpcPath = (path: string) => {
|
export const isWidgetDataTrpcPath = (path: string) => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
|
||||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||||
import { createId, generateResponsiveGridFor } from "@homarr/common";
|
import { createId, generateResponsiveGridFor } from "@homarr/common";
|
||||||
import type { GridAlgorithmItem } from "@homarr/common";
|
import type { GridAlgorithmItem } from "@homarr/common";
|
||||||
@@ -87,7 +86,6 @@ interface BoardItemPlacementRectangle {
|
|||||||
|
|
||||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||||
const maxManageOverviewPreviewRows = 12;
|
const maxManageOverviewPreviewRows = 12;
|
||||||
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
|
|
||||||
|
|
||||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||||
@@ -124,78 +122,6 @@ const findFirstAvailableBoardItemPosition = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchAccessibleBoardsAsync = async (
|
|
||||||
ctx: { db: Database; session: Session | null },
|
|
||||||
query: string,
|
|
||||||
limit?: number,
|
|
||||||
) => {
|
|
||||||
const userId = ctx.session?.user.id;
|
|
||||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
|
||||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
|
||||||
});
|
|
||||||
|
|
||||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
|
||||||
where: eq(groupMembers.userId, userId ?? ""),
|
|
||||||
with: {
|
|
||||||
group: {
|
|
||||||
with: {
|
|
||||||
boardPermissions: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const boardIds = permissionsOfCurrentUserWhenPresent
|
|
||||||
.map((permission) => permission.boardId)
|
|
||||||
.concat(
|
|
||||||
permissionsOfCurrentUserGroupsWhenPresent
|
|
||||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
|
||||||
.flat(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
|
||||||
where: eq(users.id, userId ?? ""),
|
|
||||||
});
|
|
||||||
|
|
||||||
const foundBoards = await ctx.db.query.boards.findMany({
|
|
||||||
where: and(
|
|
||||||
like(boards.name, `%${query}%`),
|
|
||||||
ctx.session?.user.permissions.includes("board-view-all")
|
|
||||||
? undefined
|
|
||||||
: or(
|
|
||||||
eq(boards.isPublic, true),
|
|
||||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
|
||||||
inArray(boards.id, boardIds),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
limit,
|
|
||||||
orderBy: asc(boards.name),
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
creatorId: true,
|
|
||||||
isPublic: true,
|
|
||||||
logoImageUrl: true,
|
|
||||||
},
|
|
||||||
with: {
|
|
||||||
userPermissions: {
|
|
||||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
|
||||||
},
|
|
||||||
groupPermissions: {
|
|
||||||
where: getBoardGroupPermissionWhere(permissionsOfCurrentUserGroupsWhenPresent),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return foundBoards.map((board) => ({
|
|
||||||
id: board.id,
|
|
||||||
name: board.name,
|
|
||||||
logoImageUrl: board.logoImageUrl,
|
|
||||||
permissions: constructBoardPermissions(board, ctx.session),
|
|
||||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
|
||||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const boardRouter = createTRPCRouter({
|
export const boardRouter = createTRPCRouter({
|
||||||
exists: permissionRequiredProcedure
|
exists: permissionRequiredProcedure
|
||||||
.requiresPermission("board-create")
|
.requiresPermission("board-create")
|
||||||
@@ -299,7 +225,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
|
getManageOverview: publicProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session?.user.id;
|
const userId = ctx.session?.user.id;
|
||||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||||
@@ -380,7 +306,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||||
gte(itemLayouts.xOffset, 0),
|
gte(itemLayouts.xOffset, 0),
|
||||||
gte(itemLayouts.yOffset, 0),
|
gte(itemLayouts.yOffset, 0),
|
||||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(itemLayouts.width, 0),
|
gt(itemLayouts.width, 0),
|
||||||
gt(itemLayouts.height, 0),
|
gt(itemLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -405,7 +331,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||||
gte(sectionLayouts.xOffset, 0),
|
gte(sectionLayouts.xOffset, 0),
|
||||||
gte(sectionLayouts.yOffset, 0),
|
gte(sectionLayouts.yOffset, 0),
|
||||||
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(sectionLayouts.width, 0),
|
gt(sectionLayouts.width, 0),
|
||||||
gt(sectionLayouts.height, 0),
|
gt(sectionLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -440,7 +366,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
||||||
gte(itemLayouts.xOffset, 0),
|
gte(itemLayouts.xOffset, 0),
|
||||||
gte(itemLayouts.yOffset, 0),
|
gte(itemLayouts.yOffset, 0),
|
||||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(itemLayouts.width, 0),
|
gt(itemLayouts.width, 0),
|
||||||
gt(itemLayouts.height, 0),
|
gt(itemLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -555,18 +481,80 @@ export const boardRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
catalog: publicProcedure
|
|
||||||
.meta({ mcp: { enabled: true, description: "List every board the current user can access." } })
|
|
||||||
.query(async ({ ctx }) => searchAccessibleBoardsAsync(ctx, "")),
|
|
||||||
search: publicProcedure
|
search: publicProcedure
|
||||||
.meta({
|
|
||||||
mcp: {
|
|
||||||
enabled: true,
|
|
||||||
description: "Search accessible boards by name. REQUIRED: query (string). OPTIONAL: limit (number).",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||||
.query(async ({ ctx, input }) => searchAccessibleBoardsAsync(ctx, input.query, input.limit)),
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.session?.user.id;
|
||||||
|
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||||
|
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||||
|
where: eq(groupMembers.userId, userId ?? ""),
|
||||||
|
with: {
|
||||||
|
group: {
|
||||||
|
with: {
|
||||||
|
boardPermissions: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||||
|
.map((permission) => permission.boardId)
|
||||||
|
.concat(
|
||||||
|
permissionsOfCurrentUserGroupsWhenPresent
|
||||||
|
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||||
|
.flat(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundBoards = await ctx.db.query.boards.findMany({
|
||||||
|
where: and(
|
||||||
|
like(boards.name, `%${input.query}%`),
|
||||||
|
ctx.session?.user.permissions.includes("board-view-all")
|
||||||
|
? undefined
|
||||||
|
: or(
|
||||||
|
eq(boards.isPublic, true),
|
||||||
|
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||||
|
inArray(boards.id, boardIds),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
limit: input.limit,
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
creatorId: true,
|
||||||
|
isPublic: true,
|
||||||
|
logoImageUrl: true,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
userPermissions: {
|
||||||
|
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||||
|
},
|
||||||
|
groupPermissions: {
|
||||||
|
where:
|
||||||
|
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
|
||||||
|
? inArray(
|
||||||
|
boardGroupPermissions.groupId,
|
||||||
|
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return foundBoards.map((board) => ({
|
||||||
|
id: board.id,
|
||||||
|
name: board.name,
|
||||||
|
logoImageUrl: board.logoImageUrl,
|
||||||
|
permissions: constructBoardPermissions(board, ctx.session),
|
||||||
|
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||||
|
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
createBoard: permissionRequiredProcedure
|
createBoard: permissionRequiredProcedure
|
||||||
.requiresPermission("board-create")
|
.requiresPermission("board-create")
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -130,28 +130,6 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
catalog: publicProcedure
|
|
||||||
.meta({
|
|
||||||
mcp: {
|
|
||||||
enabled: true,
|
|
||||||
description: "List search engines available to the current user.",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.query(async ({ ctx }) => {
|
|
||||||
return await ctx.db.query.searchEngines.findMany({
|
|
||||||
where: ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
|
|
||||||
orderBy: asc(searchEngines.name),
|
|
||||||
with: {
|
|
||||||
integration: {
|
|
||||||
columns: {
|
|
||||||
kind: true,
|
|
||||||
url: true,
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||||
return await ctx.db.query.searchEngines.findMany({
|
return await ctx.db.query.searchEngines.findMany({
|
||||||
// Public dashboards have no session: restrict anonymous users to generic
|
// Public dashboards have no session: restrict anonymous users to generic
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ describe("getManageOverview", () => {
|
|||||||
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
|
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns compact previews by default and full previews on request", async () => {
|
test("returns one compact base-layout preview for each accessible board", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
@@ -487,10 +487,6 @@ describe("getManageOverview", () => {
|
|||||||
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
||||||
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
|
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
|
||||||
expect(result[0]?.preview?.sections).toHaveLength(2);
|
expect(result[0]?.preview?.sections).toHaveLength(2);
|
||||||
|
|
||||||
const fullResult = await caller.getManageOverview({ fullPreview: true });
|
|
||||||
|
|
||||||
expect(fullResult[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, offscreenItemId, nestedItemId]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("includes app and widget icons in compact previews", async () => {
|
test("includes app and widget icons in compact previews", async () => {
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ const MCP_TOOL_ALLOWLIST = {
|
|||||||
"beszel_getAlerts",
|
"beszel_getAlerts",
|
||||||
"beszel_getSystems",
|
"beszel_getSystems",
|
||||||
"beszel_getSystemStats",
|
"beszel_getSystemStats",
|
||||||
"board_catalog",
|
|
||||||
"board_getAllBoards",
|
"board_getAllBoards",
|
||||||
"board_getBoardSettings",
|
"board_getBoardSettings",
|
||||||
"board_search",
|
|
||||||
"calendar_findAllEvents",
|
"calendar_findAllEvents",
|
||||||
"customWidget_get",
|
"customWidget_get",
|
||||||
"customWidget_getAuthoringPrompt",
|
"customWidget_getAuthoringPrompt",
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection";
|
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection";
|
||||||
import { updateBoardName } from "./updater";
|
import { updateBoardName } from "./updater";
|
||||||
|
|
||||||
type Board = RouterOutputs["board"]["getBoardByName"];
|
const BoardContext = createContext<{
|
||||||
|
board: RouterOutputs["board"]["getBoardByName"];
|
||||||
interface BoardContextValue {
|
|
||||||
board: Board;
|
|
||||||
layoutOverrideId: string | null;
|
layoutOverrideId: string | null;
|
||||||
currentLayout: string;
|
currentLayout: string;
|
||||||
initialViewportWidth: number;
|
initialViewportWidth: number;
|
||||||
}
|
} | null>(null);
|
||||||
|
|
||||||
const BoardContext = createContext<BoardContextValue | null>(null);
|
|
||||||
|
|
||||||
const subscribeToViewport = (onStoreChange: () => void) => {
|
const subscribeToViewport = (onStoreChange: () => void) => {
|
||||||
if (typeof window === "undefined") return () => undefined;
|
if (typeof window === "undefined") return () => undefined;
|
||||||
@@ -41,23 +37,6 @@ const subscribeToViewport = (onStoreChange: () => void) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BoardPreviewProvider = ({ children, board }: PropsWithChildren<{ board: Board }>) => {
|
|
||||||
const currentLayout = board.layouts[0]?.id ?? "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BoardContext.Provider
|
|
||||||
value={{
|
|
||||||
board,
|
|
||||||
layoutOverrideId: null,
|
|
||||||
currentLayout,
|
|
||||||
initialViewportWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</BoardContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BoardProvider = ({
|
export const BoardProvider = ({
|
||||||
children,
|
children,
|
||||||
initialBoard,
|
initialBoard,
|
||||||
|
|||||||
+61
-213
@@ -514,9 +514,6 @@ const seedDefaultBoardAsync = async (db: Database) => {
|
|||||||
|
|
||||||
interface DemoWidget {
|
interface DemoWidget {
|
||||||
kind: WidgetKind;
|
kind: WidgetKind;
|
||||||
section?: "right";
|
|
||||||
xOffset: number;
|
|
||||||
yOffset: number;
|
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
needsIntegration: boolean;
|
needsIntegration: boolean;
|
||||||
@@ -565,9 +562,9 @@ const demoApps = [
|
|||||||
href: "https://portainer.io",
|
href: "https://portainer.io",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Uptime Kuma",
|
name: "Home Assistant",
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/uptime-kuma.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
|
||||||
href: "https://uptime.kuma.pet",
|
href: "https://home-assistant.io",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Nextcloud",
|
name: "Nextcloud",
|
||||||
@@ -586,163 +583,69 @@ const demoApps = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): DemoWidget[] => [
|
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
|
||||||
// Daily focus
|
// Row 1: calendar + downloads + clock = 12
|
||||||
{ kind: "calendar", xOffset: 0, yOffset: 0, width: 2, height: 2, needsIntegration: true },
|
{ kind: "calendar", width: 5, height: 3, needsIntegration: true },
|
||||||
|
{ kind: "downloads", width: 5, height: 3, needsIntegration: true },
|
||||||
|
{ kind: "clock", width: 2, height: 1, needsIntegration: false },
|
||||||
|
// Row 2: healthMonitoring + bookmarks = 12
|
||||||
|
{ kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
|
||||||
{
|
{
|
||||||
kind: "weather",
|
kind: "bookmarks",
|
||||||
xOffset: 2,
|
width: 6,
|
||||||
yOffset: 0,
|
|
||||||
width: 2,
|
|
||||||
height: 2,
|
|
||||||
needsIntegration: false,
|
|
||||||
options: {
|
|
||||||
location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 },
|
|
||||||
hasForecast: false,
|
|
||||||
showHumidity: false,
|
|
||||||
showCurrentWindSpeed: false,
|
|
||||||
showCity: true,
|
|
||||||
animateIcons: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "clock",
|
|
||||||
xOffset: 4,
|
|
||||||
yOffset: 0,
|
|
||||||
width: 1,
|
|
||||||
height: 2,
|
|
||||||
needsIntegration: false,
|
|
||||||
options: { customTitleToggle: false, showDate: false, customTimeFormat: "HH:mm" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "timer",
|
|
||||||
xOffset: 5,
|
|
||||||
yOffset: 0,
|
|
||||||
width: 2,
|
|
||||||
height: 1,
|
|
||||||
needsIntegration: false,
|
|
||||||
options: {
|
|
||||||
mode: "pomodoro",
|
|
||||||
focusMinutes: 25,
|
|
||||||
shortBreakMinutes: 5,
|
|
||||||
longBreakMinutes: 15,
|
|
||||||
sessionsBeforeLongBreak: 4,
|
|
||||||
autoStartBreaks: false,
|
|
||||||
autoStartFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "airQuality",
|
|
||||||
xOffset: 5,
|
|
||||||
yOffset: 1,
|
|
||||||
width: 2,
|
|
||||||
height: 1,
|
|
||||||
needsIntegration: false,
|
|
||||||
options: {
|
|
||||||
location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 },
|
|
||||||
aqiStandard: "european",
|
|
||||||
showUv: false,
|
|
||||||
showPollutants: false,
|
|
||||||
showPollen: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ kind: "downloads", xOffset: 7, yOffset: 0, width: 5, height: 2, needsIntegration: true },
|
|
||||||
|
|
||||||
// Homarr workspace
|
|
||||||
{
|
|
||||||
kind: "notebook",
|
|
||||||
xOffset: 0,
|
|
||||||
yOffset: 2,
|
|
||||||
width: 5,
|
|
||||||
height: 3,
|
height: 3,
|
||||||
needsIntegration: false,
|
needsIntegration: false,
|
||||||
options: {
|
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
|
||||||
showToolbar: false,
|
|
||||||
allowReadOnlyCheck: true,
|
|
||||||
content: `<p style="text-align: center"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg" width="28%"></p><h2 style="text-align: center">Quick runbook</h2><p style="text-align: center">Keep today close: signals first, controls beside them, distractions out.</p><ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Backups verified</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Review active alerts</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Connect your everyday services</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Tune this board to your routine</p></div></li></ul><p style="text-align: center"><em>Make it yours. Keep it useful.</em></p>`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ kind: "beszelSystemGrid", xOffset: 5, yOffset: 2, width: 4, height: 3, needsIntegration: true },
|
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
|
||||||
{ kind: "assistant", xOffset: 9, yOffset: 2, width: 3, height: 3, needsIntegration: false },
|
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
|
||||||
|
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
|
||||||
// Operations center
|
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
|
||||||
{ kind: "mediaRequests-requestList", xOffset: 0, yOffset: 5, width: 3, height: 2, needsIntegration: true },
|
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
|
||||||
{ kind: "mediaMissing", xOffset: 9, yOffset: 5, width: 3, height: 2, needsIntegration: true },
|
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
|
||||||
{ kind: "mediaServer", xOffset: 0, yOffset: 7, width: 3, height: 2, needsIntegration: true },
|
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
|
||||||
{ kind: "mediaRequests-requestStats", xOffset: 9, yOffset: 7, width: 3, height: 2, needsIntegration: true },
|
{ kind: "notifications", width: 6, height: 2, needsIntegration: true },
|
||||||
|
// Row 5: requestList + requestStats + indexerManager = 12
|
||||||
|
{ kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
|
||||||
|
{ kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
|
||||||
|
{ kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
|
||||||
|
// Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
|
||||||
|
{ kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
|
||||||
|
{ kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
|
||||||
{
|
{
|
||||||
kind: "rssFeed",
|
kind: "rssFeed",
|
||||||
xOffset: 0,
|
width: 6,
|
||||||
yOffset: 9,
|
|
||||||
width: 3,
|
|
||||||
height: 2,
|
height: 2,
|
||||||
needsIntegration: false,
|
needsIntegration: false,
|
||||||
options: {
|
options: {
|
||||||
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
|
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
|
||||||
maximumAmountPosts: 12,
|
maximumAmountPosts: 20,
|
||||||
textLinesClamp: 2,
|
textLinesClamp: 2,
|
||||||
hideDescription: true,
|
hideDescription: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: "indexerManager", xOffset: 9, yOffset: 9, width: 3, height: 2, needsIntegration: true },
|
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
|
||||||
|
|
||||||
// Infrastructure and activity
|
|
||||||
{ kind: "dockerContainers", xOffset: 0, yOffset: 11, width: 4, height: 3, needsIntegration: false },
|
|
||||||
{ kind: "mediaReleases", xOffset: 4, yOffset: 11, width: 4, height: 3, needsIntegration: true },
|
|
||||||
|
|
||||||
// Community Workshop
|
|
||||||
{
|
|
||||||
kind: "customApi",
|
|
||||||
xOffset: 8,
|
|
||||||
yOffset: 11,
|
|
||||||
width: 4,
|
|
||||||
height: 3,
|
|
||||||
needsIntegration: false,
|
|
||||||
options: { definitionId: customWidgetDefinitionId, refreshInterval: 300 },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Network and health
|
|
||||||
{
|
|
||||||
kind: "healthMonitoring",
|
|
||||||
xOffset: 3,
|
|
||||||
yOffset: 5,
|
|
||||||
width: 6,
|
|
||||||
height: 2,
|
|
||||||
needsIntegration: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "dnsHoleSummary",
|
|
||||||
xOffset: 3,
|
|
||||||
yOffset: 7,
|
|
||||||
width: 3,
|
|
||||||
height: 2,
|
|
||||||
needsIntegration: true,
|
|
||||||
options: { layout: "grid", usePiHoleColors: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "beszelSystemStats",
|
|
||||||
xOffset: 6,
|
|
||||||
yOffset: 7,
|
|
||||||
width: 3,
|
|
||||||
height: 2,
|
|
||||||
needsIntegration: true,
|
|
||||||
},
|
|
||||||
{ kind: "notifications", xOffset: 3, yOffset: 9, width: 2, height: 2, needsIntegration: true },
|
|
||||||
{ kind: "beszelAlerts", xOffset: 5, yOffset: 9, width: 4, height: 2, needsIntegration: true },
|
|
||||||
|
|
||||||
// Right app rail
|
|
||||||
...appIds.map(
|
...appIds.map(
|
||||||
(appId, index): DemoWidget => ({
|
(appId): DemoWidget => ({
|
||||||
kind: "app",
|
kind: "app",
|
||||||
section: "right",
|
width: 2,
|
||||||
xOffset: 0,
|
|
||||||
yOffset: index,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
height: 1,
|
||||||
needsIntegration: false,
|
needsIntegration: false,
|
||||||
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
|
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
// Row 9: beszelSystemGrid + beszelAlerts = 12
|
||||||
|
{ kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true },
|
||||||
|
{ kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true },
|
||||||
|
// Row 10: beszelSystemTable + beszelSystemStats = 12
|
||||||
|
{ kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true },
|
||||||
|
{ kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true },
|
||||||
|
// Row 11: notebook + dockerContainers + weather = 12
|
||||||
|
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
|
||||||
|
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
|
||||||
|
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
|
||||||
|
// Row 12: assistant
|
||||||
|
{ kind: "assistant", width: 3, height: 3, needsIntegration: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const seedDemoUserAsync = async (db: Database) => {
|
const seedDemoUserAsync = async (db: Database) => {
|
||||||
@@ -807,100 +710,44 @@ const seedDemoUserAsync = async (db: Database) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const customWidgetDefinitionId = createId();
|
|
||||||
const customWidgetDefinition = customWidgetDefinitionSchema.parse({
|
|
||||||
$schema: "homarr-custom-widget-v2",
|
|
||||||
name: "Community Workshop",
|
|
||||||
description: "Live Custom Widget and Custom CSS submissions shared by the Homarr community.",
|
|
||||||
sources: {
|
|
||||||
default: {
|
|
||||||
name: "Homarr Workshop",
|
|
||||||
baseUrl: "https://v2.preview.homarr.dev",
|
|
||||||
networkScope: "public",
|
|
||||||
auth: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
requests: {
|
|
||||||
workshop: {
|
|
||||||
path: "/api/collections/workshop_listings/records",
|
|
||||||
query: { perPage: 50 },
|
|
||||||
cacheSeconds: 300,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {},
|
|
||||||
template: `<Stack p="md" gap="xs" h="100%">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Stack gap={0}><Text size="xs" c="indigo" fw={700}>COMMUNITY WORKSHOP</Text><Title order={3}>Made by Homarr users</Title></Stack>
|
|
||||||
<Badge color="indigo" variant="light">{data.workshop?.totalItems ?? "—"} shared</Badge>
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed">Build Custom Widgets or dashboard CSS, then share it with the community.</Text>
|
|
||||||
{status.workshop?.loading ? <Stack gap="xs"><Skeleton height={26} radius="sm" /><Skeleton height={26} radius="sm" /><Skeleton height={26} radius="sm" /></Stack> : status.workshop?.error ? <Stack gap="xs"><Alert color="red" title="The Workshop could not be loaded">{status.workshop.error}</Alert><RefreshButton /></Stack> : (data.workshop?.items ?? []).length === 0 ? <Stack gap="xs"><Alert color="gray" title="Nothing shared yet">The first community submission could be yours.</Alert><RefreshButton /></Stack> : <Stack gap={4} style={{ flex: 1 }}>{(data.workshop?.items ?? []).slice(0, 4).map(submission => <Group key={submission.id} justify="space-between" wrap="nowrap"><Text size="sm" fw={600} lineClamp={1}>{submission.title}</Text><Group gap="xs" wrap="nowrap"><Text size="xs" c="dimmed">@{submission.authorName ?? "community"}</Text><Badge size="xs" color="gray" variant="light">{submission.upvotes ?? 0} ↑</Badge></Group></Group>)}{data.workshop?.totalItems > 4 ? <Text size="xs" c="dimmed">and {data.workshop.totalItems - 4} more in the Workshop</Text> : null}</Stack>}
|
|
||||||
<Anchor href="https://homarr.dev/docs/getting-started" target="_blank" rel="noreferrer" bg="indigo" c="white" fw={700} px="md" py="xs" radius="md" underline="never">Install Homarr now →</Anchor>
|
|
||||||
</Stack>`,
|
|
||||||
});
|
|
||||||
await db.insert(customWidgetDefinitions).values({
|
|
||||||
id: customWidgetDefinitionId,
|
|
||||||
name: customWidgetDefinition.name,
|
|
||||||
description: customWidgetDefinition.description ?? null,
|
|
||||||
iconUrl: null,
|
|
||||||
sources: SuperJSON.stringify(customWidgetDefinition.sources),
|
|
||||||
requests: SuperJSON.stringify(customWidgetDefinition.requests),
|
|
||||||
options: SuperJSON.stringify(customWidgetDefinition.options),
|
|
||||||
template: customWidgetDefinition.template,
|
|
||||||
enabled: true,
|
|
||||||
creatorId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
await db.insert(boards).values({
|
await db.insert(boards).values({
|
||||||
id: boardId,
|
id: boardId,
|
||||||
name: "default",
|
name: "default",
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
pageTitle: "Homarr demo",
|
|
||||||
backgroundImageUrl: "/images/demo-dashboard-background.svg",
|
|
||||||
primaryColor: "#748FFC",
|
|
||||||
secondaryColor: "#3BC9DB",
|
|
||||||
opacity: 90,
|
|
||||||
itemRadius: "xl",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mainSectionId = createId();
|
const sectionId = createId();
|
||||||
await db.insert(sections).values({
|
await db.insert(sections).values({
|
||||||
id: mainSectionId,
|
id: sectionId,
|
||||||
kind: "empty",
|
kind: "empty",
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
boardId,
|
boardId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rightSectionId = createId();
|
|
||||||
await db.insert(sections).values({
|
|
||||||
id: rightSectionId,
|
|
||||||
kind: "empty",
|
|
||||||
xOffset: 1,
|
|
||||||
yOffset: 0,
|
|
||||||
boardId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const layoutId = createId();
|
const layoutId = createId();
|
||||||
await db.insert(layouts).values({
|
await db.insert(layouts).values({
|
||||||
id: layoutId,
|
id: layoutId,
|
||||||
name: "Base",
|
name: "Base",
|
||||||
columnCount: 13,
|
columnCount: 12,
|
||||||
rightGutterColumnCount: 1,
|
|
||||||
breakpoint: 768,
|
breakpoint: 768,
|
||||||
role: "base",
|
role: "base",
|
||||||
boardId,
|
boardId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const demoWidgets = buildDemoWidgets(appIds, customWidgetDefinitionId);
|
const demoWidgets = buildDemoWidgets(appIds);
|
||||||
|
let xOffset = 0;
|
||||||
|
let yOffset = 0;
|
||||||
|
let rowMaxHeight = 0;
|
||||||
for (const widget of demoWidgets) {
|
for (const widget of demoWidgets) {
|
||||||
let sectionId = mainSectionId;
|
if (xOffset + widget.width > 12) {
|
||||||
if (widget.section === "right") {
|
xOffset = 0;
|
||||||
sectionId = rightSectionId;
|
yOffset += rowMaxHeight;
|
||||||
|
rowMaxHeight = 0;
|
||||||
}
|
}
|
||||||
|
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
|
||||||
const itemId = createId();
|
const itemId = createId();
|
||||||
await db.insert(items).values({
|
await db.insert(items).values({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
@@ -912,8 +759,8 @@ const seedDemoUserAsync = async (db: Database) => {
|
|||||||
itemId,
|
itemId,
|
||||||
sectionId,
|
sectionId,
|
||||||
layoutId,
|
layoutId,
|
||||||
xOffset: widget.xOffset,
|
xOffset,
|
||||||
yOffset: widget.yOffset,
|
yOffset,
|
||||||
width: widget.width,
|
width: widget.width,
|
||||||
height: widget.height,
|
height: widget.height,
|
||||||
});
|
});
|
||||||
@@ -923,6 +770,7 @@ const seedDemoUserAsync = async (db: Database) => {
|
|||||||
integrationId,
|
integrationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
xOffset += widget.width;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
|
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
|
||||||
|
|||||||
@@ -45,169 +45,6 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
|
|||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("SQLite migrations seed the redesigned demo dashboard", async () => {
|
|
||||||
const previousDemoMode = process.env.DEMO_MODE;
|
|
||||||
process.env.DEMO_MODE = "true";
|
|
||||||
const connection = new BetterSqlite3(":memory:");
|
|
||||||
const database = drizzle(connection, { schema: sqliteSchema, casing: DB_CASING });
|
|
||||||
|
|
||||||
try {
|
|
||||||
migrate(database, { migrationsFolder: path.join(__dirname, "..", "migrations", "sqlite") });
|
|
||||||
await seedDataAsync(database as unknown as Database);
|
|
||||||
|
|
||||||
const demoUser = await database.query.users.findFirst({
|
|
||||||
where: (table, { eq }) => eq(table.name, "demo"),
|
|
||||||
});
|
|
||||||
if (!demoUser?.homeBoardId) throw new Error("Demo user or home board was not seeded");
|
|
||||||
|
|
||||||
const board = await database.query.boards.findFirst({
|
|
||||||
where: (table, { eq }) => eq(table.id, demoUser.homeBoardId ?? ""),
|
|
||||||
with: {
|
|
||||||
layouts: true,
|
|
||||||
sections: { with: { collapseStates: true, layouts: true } },
|
|
||||||
items: { with: { layouts: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!board) throw new Error("Demo board was not seeded");
|
|
||||||
|
|
||||||
expect(board).toMatchObject({
|
|
||||||
pageTitle: "Homarr demo",
|
|
||||||
backgroundImageUrl: "/images/demo-dashboard-background.svg",
|
|
||||||
});
|
|
||||||
expect(board.layouts).toHaveLength(2);
|
|
||||||
expect(board.layouts).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({ columnCount: 13, rightGutterColumnCount: 1, role: "base" }),
|
|
||||||
expect.objectContaining({ columnCount: 3, rightGutterColumnCount: 0, role: "mobile" }),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
|
||||||
if (!baseLayout) throw new Error("Demo base layout was not seeded");
|
|
||||||
|
|
||||||
const mainSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 0);
|
|
||||||
const rightSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 1);
|
|
||||||
if (!mainSection || !rightSection) throw new Error("Demo board sections were not seeded");
|
|
||||||
expect(board.sections.some((section) => section.kind === "container")).toBe(false);
|
|
||||||
|
|
||||||
const itemByKind = (kind: string) => board.items.find((item) => item.kind === kind);
|
|
||||||
const expectItemLayout = (
|
|
||||||
kind: string,
|
|
||||||
xOffset: number,
|
|
||||||
yOffset: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
sectionId = mainSection.id,
|
|
||||||
) => {
|
|
||||||
const layout = itemByKind(kind)?.layouts.find((candidate) => candidate.layoutId === baseLayout.id);
|
|
||||||
expect(layout).toMatchObject({ sectionId, xOffset, yOffset, width, height });
|
|
||||||
};
|
|
||||||
expectItemLayout("calendar", 0, 0, 2, 2);
|
|
||||||
expectItemLayout("weather", 2, 0, 2, 2);
|
|
||||||
expectItemLayout("clock", 4, 0, 1, 2);
|
|
||||||
expectItemLayout("timer", 5, 0, 2, 1);
|
|
||||||
expectItemLayout("airQuality", 5, 1, 2, 1);
|
|
||||||
expectItemLayout("downloads", 7, 0, 5, 2);
|
|
||||||
expectItemLayout("notebook", 0, 2, 5, 3);
|
|
||||||
expectItemLayout("beszelSystemGrid", 5, 2, 4, 3);
|
|
||||||
expectItemLayout("assistant", 9, 2, 3, 3);
|
|
||||||
expectItemLayout("mediaRequests-requestList", 0, 5, 3, 2);
|
|
||||||
expectItemLayout("mediaMissing", 9, 5, 3, 2);
|
|
||||||
expectItemLayout("mediaServer", 0, 7, 3, 2);
|
|
||||||
expectItemLayout("mediaRequests-requestStats", 9, 7, 3, 2);
|
|
||||||
expectItemLayout("rssFeed", 0, 9, 3, 2);
|
|
||||||
expectItemLayout("indexerManager", 9, 9, 3, 2);
|
|
||||||
expectItemLayout("dockerContainers", 0, 11, 4, 3);
|
|
||||||
expectItemLayout("mediaReleases", 4, 11, 4, 3);
|
|
||||||
expectItemLayout("customApi", 8, 11, 4, 3);
|
|
||||||
|
|
||||||
expectItemLayout("healthMonitoring", 3, 5, 6, 2);
|
|
||||||
expectItemLayout("dnsHoleSummary", 3, 7, 3, 2);
|
|
||||||
expectItemLayout("beszelSystemStats", 6, 7, 3, 2);
|
|
||||||
expectItemLayout("notifications", 3, 9, 2, 2);
|
|
||||||
expectItemLayout("beszelAlerts", 5, 9, 4, 2);
|
|
||||||
|
|
||||||
for (const kind of ["weather", "airQuality"]) {
|
|
||||||
const item = itemByKind(kind);
|
|
||||||
if (!item?.options) throw new Error(`Demo ${kind} options were not seeded`);
|
|
||||||
expect(SuperJSON.parse<{ location: { name: string } }>(item.options).location.name).toBe("Paris");
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectFilledGrid = (
|
|
||||||
placements: { xOffset: number; yOffset: number; width: number; height: number }[],
|
|
||||||
columnCount: number,
|
|
||||||
) => {
|
|
||||||
const rowCount = Math.max(...placements.map((placement) => placement.yOffset + placement.height));
|
|
||||||
const cells = Array.from({ length: rowCount }, () => Array<number>(columnCount).fill(0));
|
|
||||||
for (const placement of placements) {
|
|
||||||
for (let y = placement.yOffset; y < placement.yOffset + placement.height; y += 1) {
|
|
||||||
for (let x = placement.xOffset; x < placement.xOffset + placement.width; x += 1) {
|
|
||||||
const row = cells[y];
|
|
||||||
if (!row) throw new Error("Demo placement exceeds the grid height");
|
|
||||||
row[x] = (row[x] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expect(cells.every((row) => row.length === columnCount && row.every((cell) => cell === 1))).toBe(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mainItemLayouts = board.items.flatMap((item) =>
|
|
||||||
item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === mainSection.id),
|
|
||||||
);
|
|
||||||
expectFilledGrid(mainItemLayouts, 12);
|
|
||||||
|
|
||||||
const rightRailItems = board.items.filter((item) =>
|
|
||||||
item.layouts.some((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id),
|
|
||||||
);
|
|
||||||
expect(rightRailItems).toHaveLength(12);
|
|
||||||
const rightRailLayouts = rightRailItems.flatMap((item) =>
|
|
||||||
item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
rightRailItems.every((item) => {
|
|
||||||
const layout = item.layouts.find((candidate) => candidate.layoutId === baseLayout.id);
|
|
||||||
return item.kind === "app" && layout?.width === 1 && layout.height === 1;
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
rightRailLayouts
|
|
||||||
.toSorted((first, second) => first.yOffset - second.yOffset)
|
|
||||||
.map(({ sectionId, xOffset, yOffset, width, height }) => ({
|
|
||||||
sectionId,
|
|
||||||
xOffset,
|
|
||||||
yOffset,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})),
|
|
||||||
).toEqual(
|
|
||||||
Array.from({ length: 12 }, (_, yOffset) => ({
|
|
||||||
sectionId: rightSection.id,
|
|
||||||
xOffset: 0,
|
|
||||||
yOffset,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
expectFilledGrid(rightRailLayouts, 1);
|
|
||||||
|
|
||||||
const workshopDefinition = await database.query.customWidgetDefinitions.findFirst({
|
|
||||||
where: (table, { eq }) => eq(table.creatorId, demoUser.id),
|
|
||||||
});
|
|
||||||
if (!workshopDefinition) throw new Error("Demo Workshop definition was not seeded");
|
|
||||||
expect(workshopDefinition.name).toBe("Community Workshop");
|
|
||||||
expect(SuperJSON.parse<Record<string, { baseUrl: string }>>(workshopDefinition.sources).default?.baseUrl).toBe(
|
|
||||||
"https://v2.preview.homarr.dev",
|
|
||||||
);
|
|
||||||
|
|
||||||
const workshopItem = itemByKind("customApi");
|
|
||||||
if (!workshopItem) throw new Error("Demo Workshop item was not seeded");
|
|
||||||
expect(SuperJSON.parse<{ definitionId: string }>(workshopItem.options).definitionId).toBe(workshopDefinition.id);
|
|
||||||
} finally {
|
|
||||||
connection.close();
|
|
||||||
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
|
|
||||||
else process.env.DEMO_MODE = previousDemoMode;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom Widget v2 migration preserves v1 data and board references", () => {
|
test("Custom Widget v2 migration preserves v1 data and board references", () => {
|
||||||
const connection = new BetterSqlite3(":memory:");
|
const connection = new BetterSqlite3(":memory:");
|
||||||
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
|
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
|
||||||
|
|||||||
@@ -3,8 +3,5 @@ export const hotkeys = {
|
|||||||
toggleColorScheme: "mod+j",
|
toggleColorScheme: "mod+j",
|
||||||
saveNotebook: "mod+s",
|
saveNotebook: "mod+s",
|
||||||
openSpotlight: "mod+k",
|
openSpotlight: "mod+k",
|
||||||
openSpotlightApps: "mod+p",
|
|
||||||
openSpotlightAssistant: "mod+/",
|
|
||||||
openSpotlightPreferences: "mod+shift+?",
|
|
||||||
openAssistant: "shift+a",
|
openAssistant: "shift+a",
|
||||||
};
|
};
|
||||||
@@ -161,7 +161,7 @@ export interface DefaultWidgetConfig {
|
|||||||
|
|
||||||
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
|
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
|
||||||
{ kind: "clock", width: 2, height: 1 },
|
{ kind: "clock", width: 2, height: 1 },
|
||||||
{ kind: "weather", width: 2, height: 1, options: { showHumidity: false, showCity: false, hasForecast: false } },
|
{ kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } },
|
||||||
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
|
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,22 @@
|
|||||||
import { Center } from "@mantine/core";
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
import { useWindowEvent } from "@mantine/hooks";
|
||||||
|
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { SearchGroup } from "../../lib/group";
|
import type { SearchGroup } from "../../lib/group";
|
||||||
import type { inferSearchInteractionOptions } from "../../lib/interaction";
|
import type { inferSearchInteractionOptions } from "../../lib/interaction";
|
||||||
import type { SpotlightMode } from "../../open";
|
import { SpotlightNoResults } from "../no-results";
|
||||||
import { SpotlightGroupActionItem } from "./items/group-action-item";
|
import { SpotlightGroupActionItem } from "./items/group-action-item";
|
||||||
|
|
||||||
interface GroupActionsProps<TOption extends Record<string, unknown>> {
|
interface GroupActionsProps<TOption extends Record<string, unknown>> {
|
||||||
group: SearchGroup<TOption>;
|
group: SearchGroup<TOption>;
|
||||||
query: string;
|
query: string;
|
||||||
setQuery: (query: string) => void;
|
setQuery: (query: string) => void;
|
||||||
setMode: (mode: SpotlightMode) => void;
|
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUniqueOptions = <TOption extends Record<string, unknown>>(options: TOption[], keyPath: keyof TOption) => {
|
|
||||||
const optionKeys = new Set<unknown>();
|
|
||||||
|
|
||||||
return options.filter((option) => {
|
|
||||||
const optionKey = option[keyPath];
|
|
||||||
if (optionKeys.has(optionKey)) return false;
|
|
||||||
|
|
||||||
optionKeys.add(optionKey);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
||||||
group,
|
group,
|
||||||
query,
|
query,
|
||||||
@@ -40,26 +30,28 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
|||||||
const options = useOptions(query);
|
const options = useOptions(query);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
|
useWindowEvent("keydown", (event) => {
|
||||||
|
const optionsArray = Array.isArray(options) ? options : (options.data ?? []);
|
||||||
|
group.onKeyDown?.(event, optionsArray, query, { setChildrenOptions });
|
||||||
|
});
|
||||||
|
|
||||||
if (Array.isArray(options)) {
|
if (Array.isArray(options)) {
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredOptions = getUniqueOptions(
|
const filteredOptions = options
|
||||||
options
|
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
|
||||||
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
|
.sort((optionA, optionB) => {
|
||||||
.sort((optionA, optionB) => {
|
if ("sort" in group) {
|
||||||
if ("sort" in group) {
|
return group.sort?.(query, [optionA, optionB]) ?? 0;
|
||||||
return group.sort?.(query, [optionA, optionB]) ?? 0;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}),
|
});
|
||||||
group.keyPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (filteredOptions.length === 0) {
|
if (filteredOptions.length === 0) {
|
||||||
return null;
|
return <SpotlightNoResults />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredOptions.map((option) => (
|
return filteredOptions.map((option) => (
|
||||||
@@ -76,15 +68,15 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.isLoading) {
|
if (options.isLoading) {
|
||||||
return <span hidden data-search-pending />;
|
return (
|
||||||
|
<Center w="100%" py="sm">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.isError) {
|
if (options.isError) {
|
||||||
return (
|
return <Center py="sm">{t("search.error.fetch")}</Center>;
|
||||||
<Center py="sm" data-search-status>
|
|
||||||
{t("search.error.fetch")}
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.data) {
|
if (!options.data) {
|
||||||
@@ -92,10 +84,10 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.data.length === 0) {
|
if (options.data.length === 0) {
|
||||||
return null;
|
return <SpotlightNoResults />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getUniqueOptions(options.data, group.keyPath).map((option) => (
|
return options.data.map((option) => (
|
||||||
<SpotlightGroupActionItem
|
<SpotlightGroupActionItem
|
||||||
key={option[group.keyPath] as never}
|
key={option[group.keyPath] as never}
|
||||||
option={option}
|
option={option}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { translateIfNecessary } from "@homarr/translation";
|
import { translateIfNecessary } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { SearchGroup } from "../../../lib/group";
|
import type { SearchGroup } from "../../../lib/group";
|
||||||
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||||
import type { SpotlightMode } from "../../../open";
|
|
||||||
import { SpotlightGroupActions } from "../group-actions";
|
import { SpotlightGroupActions } from "../group-actions";
|
||||||
|
|
||||||
interface SpotlightActionGroupsProps {
|
interface SpotlightActionGroupsProps {
|
||||||
groups: SearchGroup[];
|
groups: SearchGroup[];
|
||||||
query: string;
|
query: string;
|
||||||
setQuery: (query: string) => void;
|
setQuery: (query: string) => void;
|
||||||
setMode: (mode: SpotlightMode) => void;
|
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +23,6 @@ export const SpotlightActionGroups = ({ groups, ...others }: SpotlightActionGrou
|
|||||||
<Spotlight.ActionsGroup
|
<Spotlight.ActionsGroup
|
||||||
key={`${translateIfNecessary(t, group.title)}::${String(group.keyPath)}`}
|
key={`${translateIfNecessary(t, group.title)}::${String(group.keyPath)}`}
|
||||||
label={translateIfNecessary(t, group.title)}
|
label={translateIfNecessary(t, group.title)}
|
||||||
data-search-source={group.source?.kind ?? "local"}
|
|
||||||
data-remote-source={group.source?.kind === "remote" ? group.source.source : undefined}
|
|
||||||
>
|
>
|
||||||
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<SpotlightGroupActions<any> group={group} {...others} />
|
<SpotlightGroupActions<any> group={group} {...others} />
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
|
||||||
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common";
|
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common";
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import type { SearchGroup } from "../../../lib/group";
|
import type { SearchGroup } from "../../../lib/group";
|
||||||
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||||
import type { SpotlightMode } from "../../../open";
|
|
||||||
import { selectAction, spotlightStore } from "../../../spotlight-store";
|
import { selectAction, spotlightStore } from "../../../spotlight-store";
|
||||||
import classes from "./action-item.module.css";
|
import classes from "./action-item.module.css";
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ interface SpotlightGroupActionItemProps<TOption extends Record<string, unknown>>
|
|||||||
option: TOption;
|
option: TOption;
|
||||||
query: string;
|
query: string;
|
||||||
setQuery: (query: string) => void;
|
setQuery: (query: string) => void;
|
||||||
setMode: (mode: SpotlightMode) => void;
|
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||||
group: SearchGroup<TOption>;
|
group: SearchGroup<TOption>;
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
|||||||
|
|
||||||
const safeHref = interaction.type === "link" ? getSafeAppHref(interaction.href) : undefined;
|
const safeHref = interaction.type === "link" ? getSafeAppHref(interaction.href) : undefined;
|
||||||
const renderRoot =
|
const renderRoot =
|
||||||
interaction.type === "link" && safeHref && !unavailable
|
interaction.type === "link" && safeHref
|
||||||
? (props: Record<string, unknown>) => {
|
? (props: Record<string, unknown>) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -48,8 +48,6 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleClickAsync = async () => {
|
const handleClickAsync = async () => {
|
||||||
if (unavailable) return;
|
|
||||||
|
|
||||||
if (interaction.type === "javaScript") {
|
if (interaction.type === "javaScript") {
|
||||||
await interaction.onSelect();
|
await interaction.onSelect();
|
||||||
} else if (interaction.type === "setQuery") {
|
} else if (interaction.type === "setQuery") {
|
||||||
@@ -78,7 +76,6 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
|||||||
}
|
}
|
||||||
className={classes.spotlightAction}
|
className={classes.spotlightAction}
|
||||||
aria-disabled={unavailable || undefined}
|
aria-disabled={unavailable || undefined}
|
||||||
disabled={unavailable}
|
|
||||||
>
|
>
|
||||||
<group.Component {...(optionProps as TOption)} />
|
<group.Component {...(optionProps as TOption)} />
|
||||||
</Spotlight.Action>
|
</Spotlight.Action>
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import { Group, Kbd, ScrollArea, Text, UnstyledButton } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconApps,
|
|
||||||
IconCommand,
|
|
||||||
IconMovie,
|
|
||||||
IconRobot,
|
|
||||||
IconSearch,
|
|
||||||
IconSettings,
|
|
||||||
IconUsers,
|
|
||||||
IconWorldSearch,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useSession } from "@homarr/auth/client";
|
|
||||||
|
|
||||||
import type { SpotlightMode } from "../open";
|
|
||||||
import classes from "./spotlight.module.css";
|
|
||||||
|
|
||||||
interface ModeRailEntry {
|
|
||||||
ariaLabel: string;
|
|
||||||
character?: string;
|
|
||||||
label: string;
|
|
||||||
mode: SpotlightMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpotlightModeRailProps {
|
|
||||||
activeMode: SpotlightMode;
|
|
||||||
entries: ModeRailEntry[];
|
|
||||||
navigationLabel: string;
|
|
||||||
onModeChange: (mode: SpotlightMode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modeIcons: Record<SpotlightMode, typeof IconSearch> = {
|
|
||||||
search: IconSearch,
|
|
||||||
apps: IconApps,
|
|
||||||
command: IconCommand,
|
|
||||||
preferences: IconSettings,
|
|
||||||
assistant: IconRobot,
|
|
||||||
external: IconWorldSearch,
|
|
||||||
media: IconMovie,
|
|
||||||
userGroup: IconUsers,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SpotlightModeRail = ({ activeMode, entries, navigationLabel, onModeChange }: SpotlightModeRailProps) => {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const canManageUsers = session?.user.permissions.includes("admin") ?? false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea type="never" scrollbarSize={0} className={classes.modeRailViewport}>
|
|
||||||
<Group component="nav" aria-label={navigationLabel} wrap="nowrap" gap={4} className={classes.modeRail}>
|
|
||||||
{entries.map((entry) => {
|
|
||||||
if (entry.mode === "userGroup" && !canManageUsers) return null;
|
|
||||||
const Icon = modeIcons[entry.mode];
|
|
||||||
const active = entry.mode === activeMode;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnstyledButton
|
|
||||||
type="button"
|
|
||||||
key={entry.mode}
|
|
||||||
className={classes.modeButton}
|
|
||||||
data-active={active || undefined}
|
|
||||||
aria-pressed={active}
|
|
||||||
aria-label={entry.ariaLabel}
|
|
||||||
onClick={() => onModeChange(entry.mode)}
|
|
||||||
>
|
|
||||||
<Icon size={15} stroke={1.7} />
|
|
||||||
<Text component="span" size="xs" fw={600} className={classes.modeLabel}>
|
|
||||||
{entry.label}
|
|
||||||
</Text>
|
|
||||||
{entry.character ? (
|
|
||||||
<Kbd size="xs" className={classes.modeKey}>
|
|
||||||
{entry.character}
|
|
||||||
</Kbd>
|
|
||||||
) : null}
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Group>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,8 +2,8 @@ import { Spotlight } from "@mantine/spotlight";
|
|||||||
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
export const SpotlightNoResults = ({ className }: { className?: string }) => {
|
export const SpotlightNoResults = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return <Spotlight.Empty className={className}>{t("search.nothingFound")}</Spotlight.Empty>;
|
return <Spotlight.Empty>{t("search.nothingFound")}</Spotlight.Empty>;
|
||||||
};
|
};
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
.content {
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
box-shadow: var(--mantine-shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.modeRailViewport {
|
|
||||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.modeRail {
|
|
||||||
min-width: max-content;
|
|
||||||
padding: var(--mantine-spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modeButton {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
min-height: 30px;
|
|
||||||
padding-inline: 6px;
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
color: var(--mantine-color-dimmed);
|
|
||||||
transition:
|
|
||||||
color 100ms ease,
|
|
||||||
background-color 100ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modeKey {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modeButton:hover {
|
|
||||||
color: var(--mantine-color-text);
|
|
||||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
|
||||||
}
|
|
||||||
|
|
||||||
.modeButton[data-active] {
|
|
||||||
color: var(--mantine-primary-color-filled);
|
|
||||||
background: var(--mantine-primary-color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modeButton:focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionsList {
|
|
||||||
padding-block: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionsList [data-search-source="fallback"] {
|
|
||||||
margin-top: var(--mantine-spacing-xs);
|
|
||||||
padding-top: var(--mantine-spacing-xs);
|
|
||||||
border-top: 1px dashed light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionsList > [data-search-source="fallback"]:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
border-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionsList:has([data-action]) .emptyState,
|
|
||||||
.actionsList:has([data-search-pending]) .emptyState,
|
|
||||||
.actionsList:has([data-search-status]) .emptyState {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionsList [data-search-source]:has([data-search-pending]) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
min-height: 42px;
|
|
||||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.footerHint {
|
|
||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 48em) {
|
|
||||||
.modeLabel {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footerSecondary {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.modeButton {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ActionIcon, Center, Group, Kbd, Text } from "@mantine/core";
|
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||||
import { IconArrowDown, IconArrowLeft, IconArrowUp, IconCornerDownLeft, IconSearch, IconX } from "@tabler/icons-react";
|
import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
|
|
||||||
import { translateIfNecessary } from "@homarr/translation";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -16,142 +17,74 @@ import {
|
|||||||
mediaRequestSearchEvent,
|
mediaRequestSearchEvent,
|
||||||
spotlightOpenEvent,
|
spotlightOpenEvent,
|
||||||
} from "../open";
|
} from "../open";
|
||||||
import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } from "../open";
|
import type { OpenMediaRequestSearchOptions } from "../open";
|
||||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||||
import type { SearchMode } from "../lib/mode";
|
import type { SearchMode } from "../lib/mode";
|
||||||
import { useSpotlightCatalogs } from "../lib/catalog";
|
|
||||||
import { searchModes } from "../modes";
|
import { searchModes } from "../modes";
|
||||||
|
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
|
||||||
|
import { contextSpecificSearchGroups } from "../modes/home/context-specific-group";
|
||||||
import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
|
import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
|
||||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||||
import { SpotlightModeRail } from "./mode-rail";
|
|
||||||
import { SpotlightNoResults } from "./no-results";
|
|
||||||
import classes from "./spotlight.module.css";
|
|
||||||
|
|
||||||
|
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
||||||
type ChildrenOptions = inferSearchInteractionOptions<"children">;
|
type ChildrenOptions = inferSearchInteractionOptions<"children">;
|
||||||
|
|
||||||
const defaultMode: SpotlightMode = "search";
|
const defaultMode = "home";
|
||||||
|
|
||||||
const resetSelection = () => {
|
|
||||||
requestAnimationFrame(() => selectAction(0, spotlightStore));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Spotlight = () => {
|
export const Spotlight = () => {
|
||||||
useSpotlightCatalogs();
|
const searchModeState = useState<SearchModeKey>(defaultMode);
|
||||||
|
const queryState = useState("");
|
||||||
|
const mode = searchModeState[0];
|
||||||
|
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
|
||||||
|
|
||||||
const [mode, setMode] = useState<SpotlightMode>(defaultMode);
|
if (!activeMode) {
|
||||||
const [query, setQuery] = useState("");
|
return null;
|
||||||
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
}
|
||||||
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.mode === mode), [mode]);
|
|
||||||
|
|
||||||
const applyOpenOptions = useCallback(
|
|
||||||
(options: SpotlightOpenIntent) => {
|
|
||||||
setMode(options.mode);
|
|
||||||
setQuery(options.query ?? "");
|
|
||||||
if (options.mode !== "media") setMediaRequestSearchScope({});
|
|
||||||
spotlightActions.open();
|
|
||||||
resetSelection();
|
|
||||||
},
|
|
||||||
[setMediaRequestSearchScope],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleOpen = (event: Event) => {
|
|
||||||
const pendingOptions = consumePendingSpotlightOpen();
|
|
||||||
const eventOptions = (event as CustomEvent<SpotlightOpenIntent>).detail;
|
|
||||||
applyOpenOptions(eventOptions ?? pendingOptions ?? { mode: defaultMode });
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener(spotlightOpenEvent, handleOpen);
|
|
||||||
const pendingOptions = consumePendingSpotlightOpen();
|
|
||||||
if (pendingOptions) applyOpenOptions(pendingOptions);
|
|
||||||
return () => window.removeEventListener(spotlightOpenEvent, handleOpen);
|
|
||||||
}, [applyOpenOptions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const applyMediaRequestSearch = ({ integrationIds, query: mediaQuery }: OpenMediaRequestSearchOptions) => {
|
|
||||||
setMediaRequestSearchScope({ integrationIds });
|
|
||||||
applyOpenOptions({ mode: "media", query: mediaQuery });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMediaRequestSearch = (event: Event) => {
|
|
||||||
const pendingOptions = consumePendingMediaRequestSearch();
|
|
||||||
const eventOptions = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail;
|
|
||||||
applyMediaRequestSearch(eventOptions ?? pendingOptions ?? {});
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
|
||||||
const pendingOptions = consumePendingMediaRequestSearch();
|
|
||||||
if (pendingOptions) applyMediaRequestSearch(pendingOptions);
|
|
||||||
return () => window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
|
||||||
}, [applyOpenOptions, setMediaRequestSearchScope]);
|
|
||||||
|
|
||||||
if (!activeMode) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SpotlightWithActiveMode
|
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
|
||||||
key={mode}
|
|
||||||
mode={mode}
|
|
||||||
setMode={setMode}
|
|
||||||
query={query}
|
|
||||||
setQuery={setQuery}
|
|
||||||
activeMode={activeMode}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SpotlightWithActiveModeProps {
|
interface SpotlightWithActiveModeProps {
|
||||||
mode: SpotlightMode;
|
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
|
||||||
setMode: Dispatch<SetStateAction<SpotlightMode>>;
|
queryState: [string, Dispatch<SetStateAction<string>>];
|
||||||
query: string;
|
|
||||||
setQuery: Dispatch<SetStateAction<string>>;
|
|
||||||
activeMode: SearchMode;
|
activeMode: SearchMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }: SpotlightWithActiveModeProps) => {
|
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
|
||||||
|
const [query, setQuery] = queryState;
|
||||||
|
const [mode, setMode] = modeState;
|
||||||
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
|
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
|
||||||
const childrenOptions = childrenStack.at(-1) ?? null;
|
const childrenOptions = childrenStack.at(-1) ?? null;
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
||||||
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
||||||
const groups = useGroups();
|
const groups = useGroups();
|
||||||
const hasModeCharacter = activeMode.character !== undefined;
|
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
|
||||||
const activeModeLabel = translateIfNecessary(t, activeMode.label) ?? activeMode.mode;
|
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
|
||||||
const placeholder = translateIfNecessary(t, activeMode.placeholder) ?? t("search.placeholder");
|
|
||||||
const modeRailEntries = searchModes.map((searchMode) => {
|
|
||||||
const label = translateIfNecessary(t, searchMode.label) ?? searchMode.mode;
|
|
||||||
return {
|
|
||||||
mode: searchMode.mode,
|
|
||||||
character: searchMode.character,
|
|
||||||
label,
|
|
||||||
ariaLabel: t("search.modePicker.modeAriaLabel", { mode: label }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
const handleOpen = () => {
|
||||||
|
consumePendingSpotlightOpen();
|
||||||
|
spotlightActions.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(spotlightOpenEvent, handleOpen);
|
||||||
|
if (consumePendingSpotlightOpen()) spotlightActions.open();
|
||||||
|
return () => window.removeEventListener(spotlightOpenEvent, handleOpen);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearChildrenStack = useCallback(() => {
|
const clearChildrenStack = useCallback(() => {
|
||||||
setChildrenStack([]);
|
setChildrenStack([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const changeMode = useCallback(
|
|
||||||
(nextMode: SpotlightMode, preserveQuery = true) => {
|
|
||||||
setMode(nextMode);
|
|
||||||
clearChildrenStack();
|
|
||||||
if (!preserveQuery) setQuery("");
|
|
||||||
resetSelection();
|
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
|
||||||
},
|
|
||||||
[clearChildrenStack, setMode, setQuery],
|
|
||||||
);
|
|
||||||
|
|
||||||
const pushChildrenOptions = useCallback(
|
const pushChildrenOptions = useCallback(
|
||||||
(options: ChildrenOptions) => {
|
(options: ChildrenOptions) => {
|
||||||
setChildrenStack((currentStack) => [...currentStack, options]);
|
setChildrenStack((currentStack) => [...currentStack, options]);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
resetSelection();
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
},
|
},
|
||||||
[setQuery],
|
[setQuery],
|
||||||
);
|
);
|
||||||
@@ -159,19 +92,44 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
const popChildrenOptions = useCallback(() => {
|
const popChildrenOptions = useCallback(() => {
|
||||||
setChildrenStack((currentStack) => currentStack.slice(0, -1));
|
setChildrenStack((currentStack) => currentStack.slice(0, -1));
|
||||||
setQuery("");
|
setQuery("");
|
||||||
resetSelection();
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
}, [setQuery]);
|
}, [setQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const applyMediaRequestSearch = ({ integrationIds, query: searchQuery }: OpenMediaRequestSearchOptions) => {
|
||||||
|
setMediaRequestSearchScope({ integrationIds });
|
||||||
|
setMode("media");
|
||||||
|
clearChildrenStack();
|
||||||
|
setQuery(searchQuery ?? "");
|
||||||
|
spotlightActions.open();
|
||||||
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMediaRequestSearch = (event: Event) => {
|
||||||
|
consumePendingMediaRequestSearch();
|
||||||
|
const { integrationIds, query: searchQuery } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
|
||||||
|
applyMediaRequestSearch({ integrationIds, query: searchQuery });
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||||
|
const pendingSearch = consumePendingMediaRequestSearch();
|
||||||
|
if (pendingSearch) applyMediaRequestSearch(pendingSearch);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||||
|
};
|
||||||
|
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "media") {
|
||||||
|
setMediaRequestSearchScope({});
|
||||||
|
}
|
||||||
|
}, [mode, setMediaRequestSearchScope]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineSpotlight.Root
|
<MantineSpotlight.Root
|
||||||
shortcut={null}
|
shortcut={hotkeys.openSpotlight}
|
||||||
size={760}
|
yOffset={8}
|
||||||
yOffset={64}
|
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
|
||||||
scrollable
|
|
||||||
maxHeight="min(62vh, 560px)"
|
|
||||||
overlayProps={{ blur: 3, backgroundOpacity: 0.24 }}
|
|
||||||
attributes={{ content: { "aria-label": t("search.modePicker.dialogAriaLabel", { mode: activeModeLabel }) } }}
|
|
||||||
classNames={{ content: classes.content }}
|
|
||||||
onSpotlightOpen={() => {
|
onSpotlightOpen={() => {
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
}}
|
}}
|
||||||
@@ -185,24 +143,22 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
|
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
|
||||||
setQuery(sanitizedQuery);
|
setQuery(sanitizedQuery);
|
||||||
|
|
||||||
if (sanitizedQuery.length === 1) {
|
if (sanitizedQuery.length !== 1) return;
|
||||||
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
|
|
||||||
if (modeToActivate) {
|
|
||||||
changeMode(modeToActivate.mode, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSelection();
|
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
|
||||||
|
if (!modeToActivate) return;
|
||||||
|
|
||||||
|
setMode(modeToActivate.modeKey);
|
||||||
|
clearChildrenStack();
|
||||||
|
setQuery("");
|
||||||
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
}}
|
}}
|
||||||
store={spotlightStore}
|
store={spotlightStore}
|
||||||
>
|
>
|
||||||
<MantineSpotlight.Search
|
<MantineSpotlight.Search
|
||||||
data-homarr-dev-benchmark-spotlight
|
data-homarr-dev-benchmark-spotlight
|
||||||
aria-label={placeholder}
|
placeholder={`${t("search.placeholder")}...`}
|
||||||
placeholder={placeholder}
|
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={classes.search}
|
|
||||||
leftSectionWidth={hasModeCharacter ? 80 : 48}
|
leftSectionWidth={hasModeCharacter ? 80 : 48}
|
||||||
leftSection={
|
leftSection={
|
||||||
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
|
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
|
||||||
@@ -212,7 +168,11 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
styles={{ section: { pointerEvents: "all" } }}
|
styles={{
|
||||||
|
section: {
|
||||||
|
pointerEvents: "all",
|
||||||
|
},
|
||||||
|
}}
|
||||||
rightSection={
|
rightSection={
|
||||||
mode === defaultMode && childrenStack.length === 0 ? null : (
|
mode === defaultMode && childrenStack.length === 0 ? null : (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -228,7 +188,9 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
changeMode(defaultMode, true);
|
setMode(defaultMode);
|
||||||
|
clearChildrenStack();
|
||||||
|
inputRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -250,28 +212,20 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode !== defaultMode) changeMode(defaultMode, true);
|
if (mode !== defaultMode) {
|
||||||
|
setMode(defaultMode);
|
||||||
|
clearChildrenStack();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SpotlightModeRail
|
|
||||||
activeMode={mode}
|
|
||||||
entries={modeRailEntries}
|
|
||||||
navigationLabel={t("search.modePicker.navigationLabel")}
|
|
||||||
onModeChange={changeMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{childrenOptions ? (
|
{childrenOptions ? (
|
||||||
<Group key={childrenStack.length}>
|
<Group key={childrenStack.length}>
|
||||||
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MantineSpotlight.ActionsList
|
<MantineSpotlight.ActionsList>
|
||||||
component="section"
|
|
||||||
className={classes.actionsList}
|
|
||||||
aria-label={t("search.modePicker.resultsAriaLabel", { mode: activeModeLabel })}
|
|
||||||
>
|
|
||||||
{childrenOptions ? (
|
{childrenOptions ? (
|
||||||
<SpotlightChildrenActions
|
<SpotlightChildrenActions
|
||||||
key={childrenStack.length}
|
key={childrenStack.length}
|
||||||
@@ -282,46 +236,21 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
|
|||||||
) : (
|
) : (
|
||||||
<SpotlightActionGroups
|
<SpotlightActionGroups
|
||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
setMode={(nextMode) => changeMode(nextMode, true)}
|
setMode={(nextMode) => {
|
||||||
setChildrenOptions={pushChildrenOptions}
|
setMode(nextMode);
|
||||||
|
clearChildrenStack();
|
||||||
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
|
}}
|
||||||
|
setChildrenOptions={(options) => {
|
||||||
|
pushChildrenOptions(options);
|
||||||
|
}}
|
||||||
query={query}
|
query={query}
|
||||||
groups={groups}
|
groups={
|
||||||
|
mode === defaultMode && query.length === 0 ? [...homeEmptyGroups, contextSpecificSearchGroups] : groups
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SpotlightNoResults className={classes.emptyState} />
|
|
||||||
</MantineSpotlight.ActionsList>
|
</MantineSpotlight.ActionsList>
|
||||||
|
|
||||||
<MantineSpotlight.Footer className={classes.footer} px="md" py="xs">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Group gap={4} wrap="nowrap">
|
|
||||||
<Kbd size="xs">
|
|
||||||
<IconArrowUp size={11} />
|
|
||||||
</Kbd>
|
|
||||||
<Kbd size="xs">
|
|
||||||
<IconArrowDown size={11} />
|
|
||||||
</Kbd>
|
|
||||||
<Text size="xs" className={classes.footerHint}>
|
|
||||||
{t("search.modePicker.footer.navigate")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap={4} wrap="nowrap">
|
|
||||||
<Kbd size="xs">
|
|
||||||
<IconCornerDownLeft size={11} />
|
|
||||||
</Kbd>
|
|
||||||
<Text size="xs" className={classes.footerHint}>
|
|
||||||
{t("search.modePicker.footer.open")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Group gap={4} wrap="nowrap" className={classes.footerSecondary}>
|
|
||||||
<Kbd size="xs">esc</Kbd>
|
|
||||||
<Text size="xs" className={classes.footerHint}>
|
|
||||||
{t("search.modePicker.footer.close")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</MantineSpotlight.Footer>
|
|
||||||
</MantineSpotlight.Root>
|
</MantineSpotlight.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -6,4 +6,3 @@ export {
|
|||||||
useRegisterSpotlightContextResults,
|
useRegisterSpotlightContextResults,
|
||||||
useRegisterSpotlightContextActions,
|
useRegisterSpotlightContextActions,
|
||||||
} from "./modes/home/context";
|
} from "./modes/home/context";
|
||||||
export type { ContextSpecificItem } from "./modes/home/context";
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
|
|
||||||
import { useSession } from "@homarr/auth/client";
|
|
||||||
|
|
||||||
import { spotlightOpenEvent } from "../open";
|
|
||||||
|
|
||||||
const catalogQueryOptions = (enabled = true) => ({
|
|
||||||
enabled,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: queryCacheDefaultStaleTimeMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useAppsCatalogQuery = (enabled = true) => {
|
|
||||||
const { status } = useSession();
|
|
||||||
return clientApi.app.selectable.useQuery(undefined, catalogQueryOptions(enabled && status === "authenticated"));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useBoardsCatalogQuery = () => {
|
|
||||||
const { status } = useSession();
|
|
||||||
return clientApi.board.catalog.useQuery(undefined, catalogQueryOptions(status !== "loading"));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useIntegrationsCatalogQuery = (enabled = true) => {
|
|
||||||
const { status } = useSession();
|
|
||||||
return clientApi.integration.all.useQuery(undefined, catalogQueryOptions(enabled && status === "authenticated"));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSearchEnginesCatalogQuery = () => {
|
|
||||||
const { status } = useSession();
|
|
||||||
return clientApi.searchEngine.catalog.useQuery(undefined, catalogQueryOptions(status !== "loading"));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSpotlightCatalogs = () => {
|
|
||||||
const { data: session, status } = useSession();
|
|
||||||
const isAuthenticated = status === "authenticated";
|
|
||||||
const isSessionResolved = status !== "loading";
|
|
||||||
const canBrowseApps =
|
|
||||||
session?.user.permissions.includes("board-modify-all") === true ||
|
|
||||||
session?.user.permissions.includes("app-modify-all") === true;
|
|
||||||
|
|
||||||
const appsQuery = useAppsCatalogQuery(isAuthenticated && canBrowseApps);
|
|
||||||
const boardsQuery = useBoardsCatalogQuery();
|
|
||||||
const integrationsQuery = useIntegrationsCatalogQuery(isAuthenticated);
|
|
||||||
const searchEnginesQuery = useSearchEnginesCatalogQuery();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const refreshCatalogs = () => {
|
|
||||||
if (canBrowseApps) void appsQuery.refetch();
|
|
||||||
if (isSessionResolved) void boardsQuery.refetch();
|
|
||||||
if (isAuthenticated) void integrationsQuery.refetch();
|
|
||||||
if (isSessionResolved) void searchEnginesQuery.refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener(spotlightOpenEvent, refreshCatalogs);
|
|
||||||
return () => window.removeEventListener(spotlightOpenEvent, refreshCatalogs);
|
|
||||||
}, [
|
|
||||||
appsQuery,
|
|
||||||
boardsQuery,
|
|
||||||
canBrowseApps,
|
|
||||||
integrationsQuery,
|
|
||||||
isAuthenticated,
|
|
||||||
isSessionResolved,
|
|
||||||
searchEnginesQuery,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const filterCatalog = <TOption>(
|
|
||||||
options: TOption[],
|
|
||||||
query: string,
|
|
||||||
getSearchableValues: (option: TOption) => string[],
|
|
||||||
limit = 8,
|
|
||||||
) => {
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
|
||||||
if (normalizedQuery.length === 0) return options.slice(0, limit);
|
|
||||||
|
|
||||||
return options
|
|
||||||
.map((option, index) => {
|
|
||||||
const values = getSearchableValues(option).map((value) => value.toLowerCase());
|
|
||||||
const score = values.reduce((currentScore, value) => {
|
|
||||||
if (value === normalizedQuery) return Math.min(currentScore, 0);
|
|
||||||
if (value.startsWith(normalizedQuery)) return Math.min(currentScore, 1);
|
|
||||||
if (value.includes(normalizedQuery)) return Math.min(currentScore, 2);
|
|
||||||
return currentScore;
|
|
||||||
}, Number.POSITIVE_INFINITY);
|
|
||||||
|
|
||||||
return { option, index, score };
|
|
||||||
})
|
|
||||||
.filter(({ score }) => Number.isFinite(score))
|
|
||||||
.toSorted((first, second) => first.score - second.score || first.index - second.index)
|
|
||||||
.slice(0, limit)
|
|
||||||
.map(({ option }) => option);
|
|
||||||
};
|
|
||||||
@@ -2,34 +2,26 @@ import type { JSX } from "react";
|
|||||||
|
|
||||||
import type { stringOrTranslation } from "@homarr/translation";
|
import type { stringOrTranslation } from "@homarr/translation";
|
||||||
|
|
||||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "./interaction";
|
import type { inferSearchInteractionDefinition, inferSearchInteractionOptions, SearchInteraction } from "./interaction";
|
||||||
|
|
||||||
export type RemoteSearchSource =
|
type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps extends Record<string, unknown>> = {
|
||||||
| "apps"
|
|
||||||
| "boards"
|
|
||||||
| "integrations"
|
|
||||||
| "users"
|
|
||||||
| "groups"
|
|
||||||
| "search-engines"
|
|
||||||
| "integration-search"
|
|
||||||
| "media";
|
|
||||||
|
|
||||||
export type SearchGroupSource =
|
|
||||||
| { kind: "local" }
|
|
||||||
| { kind: "remote"; source: RemoteSearchSource }
|
|
||||||
| { kind: "fallback" };
|
|
||||||
|
|
||||||
type CommonSearchGroup<TOption extends object, TOptionProps extends object> = {
|
|
||||||
// key path is used to define the path to a unique key in the option object
|
// key path is used to define the path to a unique key in the option object
|
||||||
keyPath: keyof TOption;
|
keyPath: keyof TOption;
|
||||||
title: stringOrTranslation;
|
title: stringOrTranslation;
|
||||||
source?: SearchGroupSource;
|
|
||||||
Component: (option: TOption) => JSX.Element;
|
Component: (option: TOption) => JSX.Element;
|
||||||
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||||
|
onKeyDown?: (
|
||||||
|
event: KeyboardEvent,
|
||||||
|
options: TOption[],
|
||||||
|
query: string,
|
||||||
|
actions: {
|
||||||
|
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
} & TOptionProps;
|
} & TOptionProps;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type SearchGroup<TOption extends object = any> =
|
export type SearchGroup<TOption extends Record<string, unknown> = any> =
|
||||||
| CommonSearchGroup<TOption, { filter: (query: string, option: TOption) => boolean; options: TOption[] }>
|
| CommonSearchGroup<TOption, { filter: (query: string, option: TOption) => boolean; options: TOption[] }>
|
||||||
| CommonSearchGroup<
|
| CommonSearchGroup<
|
||||||
TOption,
|
TOption,
|
||||||
@@ -44,4 +36,4 @@ export type SearchGroup<TOption extends object = any> =
|
|||||||
{ useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } }
|
{ useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const createGroup = <TOption extends object>(group: SearchGroup<TOption>) => group;
|
export const createGroup = <TOption extends Record<string, unknown>>(group: SearchGroup<TOption>) => group;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { MaybePromise } from "@homarr/common/types";
|
import type { MaybePromise } from "@homarr/common/types";
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
|
|
||||||
import type { CreateChildrenOptionsProps } from "./children";
|
import type { CreateChildrenOptionsProps } from "./children";
|
||||||
import type { SpotlightMode } from "../open";
|
|
||||||
|
|
||||||
const createSearchInteraction = <TType extends string>(type: TType) => ({
|
const createSearchInteraction = <TType extends string>(type: TType) => ({
|
||||||
optionsType: <TOption extends Record<string, unknown> | undefined>() => ({ type, _inferOptions: {} as TOption }),
|
optionsType: <TOption extends Record<string, unknown> | undefined>() => ({ type, _inferOptions: {} as TOption }),
|
||||||
@@ -15,7 +15,7 @@ const searchInteractions = [
|
|||||||
closeSpotlightOnTrigger?: boolean;
|
closeSpotlightOnTrigger?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
|
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
|
||||||
createSearchInteraction("mode").optionsType<{ mode: SpotlightMode; query?: string }>(),
|
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
|
||||||
createSearchInteraction("children").optionsType<{
|
createSearchInteraction("children").optionsType<{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
useActions: CreateChildrenOptionsProps<any>["useActions"];
|
useActions: CreateChildrenOptionsProps<any>["useActions"];
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import type { stringOrTranslation } from "@homarr/translation";
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
|
|
||||||
import type { SearchGroup } from "./group";
|
import type { SearchGroup } from "./group";
|
||||||
import type { SpotlightMode } from "../open";
|
|
||||||
|
|
||||||
export type SearchMode = {
|
export type SearchMode = {
|
||||||
mode: SpotlightMode;
|
modeKey: keyof TranslationObject["search"]["mode"];
|
||||||
character: string | undefined;
|
character: string | undefined;
|
||||||
label: stringOrTranslation;
|
|
||||||
placeholder: stringOrTranslation;
|
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
groups: SearchGroup[];
|
groups: SearchGroup[];
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
|
|
||||||
import type { RemoteSearchSource } from "./group";
|
|
||||||
|
|
||||||
const remoteSearchPolicy: Record<RemoteSearchSource, { delay: number; minimumLength: number }> = {
|
|
||||||
apps: { delay: 140, minimumLength: 1 },
|
|
||||||
boards: { delay: 140, minimumLength: 1 },
|
|
||||||
integrations: { delay: 160, minimumLength: 1 },
|
|
||||||
users: { delay: 180, minimumLength: 1 },
|
|
||||||
groups: { delay: 180, minimumLength: 1 },
|
|
||||||
"search-engines": { delay: 160, minimumLength: 1 },
|
|
||||||
"integration-search": { delay: 220, minimumLength: 1 },
|
|
||||||
media: { delay: 220, minimumLength: 1 },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RemoteQueryOptions {
|
|
||||||
minimumLength?: number;
|
|
||||||
trim?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemoteQuery {
|
|
||||||
enabled: boolean;
|
|
||||||
query: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps every network-backed spotlight provider on the same query cadence. Local providers still
|
|
||||||
* receive the immediate query and therefore render before remote results can replace the selection.
|
|
||||||
*/
|
|
||||||
export const useRemoteQuery = (
|
|
||||||
query: string,
|
|
||||||
source: RemoteSearchSource,
|
|
||||||
options: RemoteQueryOptions = {},
|
|
||||||
): RemoteQuery => {
|
|
||||||
const policy = remoteSearchPolicy[source];
|
|
||||||
const minimumLength = options.minimumLength ?? policy.minimumLength;
|
|
||||||
const normalizedQuery = options.trim === false ? query : query.trim();
|
|
||||||
const [debouncedQuery] = useDebouncedValue(normalizedQuery, policy.delay);
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: debouncedQuery.length >= minimumLength,
|
|
||||||
query: debouncedQuery,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Avatar, Group, Stack, Text } from "@mantine/core";
|
import { Avatar, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconExternalLink, IconEye } from "@tabler/icons-react";
|
import { IconExternalLink, IconEye } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { createChildrenOptions } from "../../lib/children";
|
import { createChildrenOptions } from "../../lib/children";
|
||||||
import { filterCatalog, useAppsCatalogQuery } from "../../lib/catalog";
|
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import { interaction } from "../../lib/interaction";
|
import { interaction } from "../../lib/interaction";
|
||||||
import { useSpotlightContextResults } from "../home/context";
|
|
||||||
|
|
||||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
@@ -59,7 +58,6 @@ const appChildrenOptions = createChildrenOptions<App>({
|
|||||||
<Avatar
|
<Avatar
|
||||||
size="sm"
|
size="sm"
|
||||||
src={options.iconUrl}
|
src={options.iconUrl}
|
||||||
alt=""
|
|
||||||
radius={0}
|
radius={0}
|
||||||
styles={{
|
styles={{
|
||||||
image: {
|
image: {
|
||||||
@@ -77,13 +75,11 @@ const appChildrenOptions = createChildrenOptions<App>({
|
|||||||
export const appsSearchGroup = createGroup<App>({
|
export const appsSearchGroup = createGroup<App>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("common.entity.apps"),
|
title: (t) => t("common.entity.apps"),
|
||||||
source: { kind: "remote", source: "apps" },
|
|
||||||
Component: (app) => (
|
Component: (app) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<Avatar
|
<Avatar
|
||||||
size="sm"
|
size="sm"
|
||||||
src={app.iconUrl}
|
src={app.iconUrl}
|
||||||
alt=""
|
|
||||||
radius={0}
|
radius={0}
|
||||||
styles={{
|
styles={{
|
||||||
image: {
|
image: {
|
||||||
@@ -96,16 +92,6 @@ export const appsSearchGroup = createGroup<App>({
|
|||||||
),
|
),
|
||||||
useInteraction: interaction.children(appChildrenOptions),
|
useInteraction: interaction.children(appChildrenOptions),
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const catalogQuery = useAppsCatalogQuery();
|
return clientApi.app.search.useQuery({ query, limit: 5 });
|
||||||
const localAppKeys = new Set(
|
|
||||||
useSpotlightContextResults().flatMap((item) => (item.dedupeKey ? [item.dedupeKey] : [])),
|
|
||||||
);
|
|
||||||
const apps = (catalogQuery.data ?? []).filter((app) => !localAppKeys.has(`app:${app.id}`));
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: filterCatalog(apps, query, (app) => [app.name]),
|
|
||||||
isLoading: catalogQuery.isLoading,
|
|
||||||
isError: catalogQuery.isError,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Group, Stack, Text } from "@mantine/core";
|
import { Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
|
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { ChildrenAction } from "../../lib/children";
|
import type { ChildrenAction } from "../../lib/children";
|
||||||
import { filterCatalog, useBoardsCatalogQuery } from "../../lib/catalog";
|
|
||||||
import { createChildrenOptions } from "../../lib/children";
|
import { createChildrenOptions } from "../../lib/children";
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import { interaction } from "../../lib/interaction";
|
import { interaction } from "../../lib/interaction";
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
|
|
||||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
@@ -127,7 +126,6 @@ const boardChildrenOptions = createChildrenOptions<Board>({
|
|||||||
export const boardsSearchGroup = createGroup<Board>({
|
export const boardsSearchGroup = createGroup<Board>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("common.entity.boards"),
|
title: (t) => t("common.entity.boards"),
|
||||||
source: { kind: "remote", source: "boards" },
|
|
||||||
Component: (board) => (
|
Component: (board) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
{board.logoImageUrl ? (
|
{board.logoImageUrl ? (
|
||||||
@@ -141,11 +139,6 @@ export const boardsSearchGroup = createGroup<Board>({
|
|||||||
),
|
),
|
||||||
useInteraction: interaction.children(boardChildrenOptions),
|
useInteraction: interaction.children(boardChildrenOptions),
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const catalogQuery = useBoardsCatalogQuery();
|
return clientApi.board.search.useQuery({ query, limit: 5 });
|
||||||
return {
|
|
||||||
data: filterCatalog(catalogQuery.data ?? [], query, (board) => [board.name]),
|
|
||||||
isLoading: catalogQuery.isLoading,
|
|
||||||
isError: catalogQuery.isError,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2,22 +2,19 @@ import { useSession } from "@homarr/auth/client";
|
|||||||
|
|
||||||
import type { SearchGroup } from "../../lib/group";
|
import type { SearchGroup } from "../../lib/group";
|
||||||
import type { SearchMode } from "../../lib/mode";
|
import type { SearchMode } from "../../lib/mode";
|
||||||
import { contextSpecificAppsSearchGroup } from "../home/context-specific-group";
|
|
||||||
import { appsSearchGroup } from "./apps-search-group";
|
import { appsSearchGroup } from "./apps-search-group";
|
||||||
import { boardsSearchGroup } from "./boards-search-group";
|
import { boardsSearchGroup } from "./boards-search-group";
|
||||||
import { integrationsSearchGroup } from "./integrations-search-group";
|
import { integrationsSearchGroup } from "./integrations-search-group";
|
||||||
|
|
||||||
export const appIntegrationBoardMode = {
|
export const appIntegrationBoardMode = {
|
||||||
mode: "apps",
|
modeKey: "appIntegrationBoard",
|
||||||
character: "#",
|
character: "#",
|
||||||
label: (t) => t("search.modePicker.apps.label"),
|
|
||||||
placeholder: (t) => t("search.modePicker.apps.placeholder"),
|
|
||||||
useGroups() {
|
useGroups() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const groups: SearchGroup[] = [contextSpecificAppsSearchGroup];
|
const groups: SearchGroup[] = [boardsSearchGroup];
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return groups.concat(boardsSearchGroup);
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the gate on app.search: either permission may browse the app catalogue, so an app
|
// Mirrors the gate on app.search: either permission may browse the app catalogue, so an app
|
||||||
@@ -25,10 +22,6 @@ export const appIntegrationBoardMode = {
|
|||||||
const canSearchApps =
|
const canSearchApps =
|
||||||
session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all");
|
session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all");
|
||||||
|
|
||||||
if (canSearchApps) {
|
return groups.concat(canSearchApps ? [appsSearchGroup, integrationsSearchGroup] : [integrationsSearchGroup]);
|
||||||
return groups.concat(appsSearchGroup, boardsSearchGroup, integrationsSearchGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups.concat(boardsSearchGroup, integrationsSearchGroup);
|
|
||||||
},
|
},
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import { Group, Text } from "@mantine/core";
|
import { Group, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { filterCatalog, useIntegrationsCatalogQuery } from "../../lib/catalog";
|
|
||||||
import { createGroup } from "../../lib/group";
|
import { createGroup } from "../../lib/group";
|
||||||
import { interaction } from "../../lib/interaction";
|
import { interaction } from "../../lib/interaction";
|
||||||
|
|
||||||
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
|
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("common.entity.integrations"),
|
title: (t) => t("common.entity.integrations"),
|
||||||
source: { kind: "remote", source: "integrations" },
|
|
||||||
Component: (integration) => (
|
Component: (integration) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<IntegrationAvatar size="sm" kind={integration.kind} />
|
<IntegrationAvatar size="sm" kind={integration.kind} />
|
||||||
@@ -20,11 +19,6 @@ export const integrationsSearchGroup = createGroup<{ id: string; kind: Integrati
|
|||||||
),
|
),
|
||||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
|
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
const catalogQuery = useIntegrationsCatalogQuery();
|
return clientApi.integration.search.useQuery({ query, limit: 5 });
|
||||||
return {
|
|
||||||
data: filterCatalog(catalogQuery.data ?? [], query, (integration) => [integration.name, integration.kind]),
|
|
||||||
isLoading: catalogQuery.isLoading,
|
|
||||||
isError: catalogQuery.isError,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { SearchMode } from "../../lib/mode";
|
|
||||||
import { contextSpecificFallbackSearchGroup } from "../home/context-specific-group";
|
|
||||||
|
|
||||||
export const assistantMode = {
|
|
||||||
mode: "assistant",
|
|
||||||
character: undefined,
|
|
||||||
label: (t) => t("search.modePicker.assistant.label"),
|
|
||||||
placeholder: (t) => t("search.modePicker.assistant.placeholder"),
|
|
||||||
groups: [contextSpecificFallbackSearchGroup],
|
|
||||||
} satisfies SearchMode;
|
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { Group, Stack, Switch, Text } from "@mantine/core";
|
import { Group, Stack, Switch, Text } from "@mantine/core";
|
||||||
import type { DayOfWeek } from "@mantine/dates";
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import { IconChevronRight } from "@tabler/icons-react";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import localeData from "dayjs/plugin/localeData";
|
import localeData from "dayjs/plugin/localeData";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
import { visiblePreferenceDefinitions } from "@homarr/settings";
|
import { visiblePreferenceDefinitions } from "@homarr/settings";
|
||||||
@@ -47,19 +47,15 @@ const BooleanRow = ({
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel?: string }) => (
|
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => (
|
||||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||||
<Group wrap="nowrap" gap="sm">
|
<Group wrap="nowrap" gap="sm">
|
||||||
<Icon stroke={1.5} size={20} />
|
<Icon stroke={1.5} size={20} />
|
||||||
<Text>{label}</Text>
|
<Text>{label}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{valueLabel ? (
|
<Text size="sm" c="dimmed">
|
||||||
<Text size="sm" c="dimmed">
|
{valueLabel}
|
||||||
{valueLabel}
|
</Text>
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<IconChevronRight aria-hidden size={16} />
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,7 +68,7 @@ const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
|
|||||||
|
|
||||||
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
|
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
|
||||||
|
|
||||||
export const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
|
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
|
||||||
const t = useI18n("search.mode.command.group.preferences.option");
|
const t = useI18n("search.mode.command.group.preferences.option");
|
||||||
const tUserField = useI18n("user.field");
|
const tUserField = useI18n("user.field");
|
||||||
const tColorScheme = useI18n("common.colorScheme.options");
|
const tColorScheme = useI18n("common.colorScheme.options");
|
||||||
@@ -81,13 +77,25 @@ export const useSettingsActions = (_: Record<string, unknown>, query: string): S
|
|||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const preferences = useUserPreferences();
|
const preferences = useUserPreferences();
|
||||||
|
|
||||||
|
const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated });
|
||||||
|
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated });
|
||||||
|
|
||||||
const valueLabelResolvers: Record<string, () => string> = {
|
const valueLabelResolvers: Record<string, () => string> = {
|
||||||
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
|
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
|
||||||
locale: () => {
|
locale: () => {
|
||||||
const val = preferences.getPreference("locale").value;
|
const val = preferences.getPreference("locale").value;
|
||||||
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
|
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
|
||||||
},
|
},
|
||||||
|
defaultSearchEngineId: () =>
|
||||||
|
searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)
|
||||||
|
?.label ?? t("searchEngine.none" as never),
|
||||||
firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "",
|
firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "",
|
||||||
|
homeBoardId: () =>
|
||||||
|
boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ??
|
||||||
|
t("board.none" as never),
|
||||||
|
mobileHomeBoardId: () =>
|
||||||
|
boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ??
|
||||||
|
t("board.none" as never),
|
||||||
};
|
};
|
||||||
|
|
||||||
return visiblePreferenceDefinitions(isAuthenticated)
|
return visiblePreferenceDefinitions(isAuthenticated)
|
||||||
@@ -126,7 +134,9 @@ export const useSettingsActions = (_: Record<string, unknown>, query: string): S
|
|||||||
if (!children) return null;
|
if (!children) return null;
|
||||||
return {
|
return {
|
||||||
key: definition.key,
|
key: definition.key,
|
||||||
Component: () => <SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.()} />,
|
Component: () => (
|
||||||
|
<SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.() ?? ""} />
|
||||||
|
),
|
||||||
useInteraction: interaction.children(children),
|
useInteraction: interaction.children(children),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useSpotlightContextActions } from "../home/context";
|
|||||||
export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({
|
export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({
|
||||||
title: (t) => t("search.mode.command.group.localCommand.title"),
|
title: (t) => t("search.mode.command.group.localCommand.title"),
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
source: { kind: "local" },
|
|
||||||
Component(option) {
|
Component(option) {
|
||||||
const icon =
|
const icon =
|
||||||
typeof option.icon !== "string" ? (
|
typeof option.icon !== "string" ? (
|
||||||
|
|||||||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user