Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777b59359c | ||
|
|
9284949b9a |
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": {
|
||||
"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": [
|
||||
"**/*.config.js",
|
||||
"**/*.config.mjs",
|
||||
|
||||
@@ -132,8 +132,8 @@ Here you have the following options in the header:
|
||||
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
|
||||
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
|
||||
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
|
||||
- **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open
|
||||
it directly. The current board is left out of the results.
|
||||
- **Board switcher**: Use this button to open another board that you can access.
|
||||
- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions.
|
||||
|
||||
When entering the edit mode, you'll get some additional buttons to work with:
|
||||
|
||||
@@ -198,13 +198,9 @@ In the modal you can edit the app that you want to display:
|
||||
|
||||
<figure>
|
||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
||||
<figcaption>
|
||||
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
|
||||
</figcaption>
|
||||
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
|
||||
</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.
|
||||
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.
|
||||
@@ -373,7 +369,7 @@ Each page highlights the list view and the create action so you know where to fi
|
||||
|
||||
### Board tour
|
||||
|
||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu.
|
||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
|
||||
|
||||
### Tour controls
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 68 KiB |
@@ -27,11 +27,6 @@ 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.
|
||||
|
||||
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
|
||||
previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
|
||||
match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the
|
||||
arrow keys to move through the results and press `Enter` to open the selected board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Bookmarks"
|
||||
description: "Keeps useful links together"
|
||||
description: "Displays multiple app links"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
@@ -13,12 +13,11 @@ import { bookmarksWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
|
||||
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
|
||||
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.
|
||||
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
|
||||
|
||||
**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.
|
||||
The bookmarks widget allows you to display multiple links in a single widget.
|
||||
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.
|
||||
For links that do not need an App, add their URLs under **Custom links** instead.
|
||||
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.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
|
||||
export const bookmarksWidget: WidgetDefinition = {
|
||||
icon: IconBookmark,
|
||||
name: "Bookmarks",
|
||||
description: "Keeps useful links together",
|
||||
description: "Displays multiple app links",
|
||||
path: "../../widgets/bookmarks",
|
||||
configuration: {
|
||||
items: [
|
||||
@@ -16,21 +16,9 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Layout",
|
||||
description: "How bookmarks use the available widget space.",
|
||||
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
||||
defaultValue: "Adaptive",
|
||||
},
|
||||
{
|
||||
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",
|
||||
description: "Layout in which the bookmarks are displayed.",
|
||||
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
|
||||
defaultValue: "Vertical",
|
||||
},
|
||||
{
|
||||
name: "Hide title",
|
||||
@@ -58,10 +46,16 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Bookmarks",
|
||||
description: "URLs and existing Apps shown by the widget.",
|
||||
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
|
||||
description: "List of bookmarks to display in the widget.",
|
||||
values: "Select one or more Apps directly, then order them as you like.",
|
||||
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-devtools": "catalog:",
|
||||
"@tanstack/react-query-next-experimental": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/next": "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";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
@@ -36,13 +35,6 @@ import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
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";
|
||||
|
||||
const DevelopmentTools =
|
||||
@@ -75,29 +67,21 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
||||
const handleScopeChange = useCallback(() => {
|
||||
void queryPersister.removeClient();
|
||||
reloadPage();
|
||||
}, [queryPersister]);
|
||||
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
initialScope={initialSessionQueryScope}
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={handleScopeChange}
|
||||
onScopeChange={reloadPage}
|
||||
>
|
||||
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({
|
||||
children,
|
||||
queryPersister,
|
||||
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
@@ -152,15 +136,6 @@ const ScopedTRPCReactProvider = ({
|
||||
return client;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const flush = () => queryPersister.flush();
|
||||
window.addEventListener("pagehide", flush);
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", flush);
|
||||
flush();
|
||||
queryPersister.stop();
|
||||
};
|
||||
}, [queryPersister]);
|
||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||
|
||||
const trpcClient = useMemo(() => {
|
||||
@@ -211,18 +186,10 @@ const ScopedTRPCReactProvider = ({
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: queryPersister,
|
||||
maxAge: queryPersistenceMaxAgeMs,
|
||||
buster: queryPersistenceBuster,
|
||||
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
||||
}}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
</PersistQueryClientProvider>
|
||||
</QueryClientProvider>
|
||||
</clientApi.Provider>
|
||||
);
|
||||
};
|
||||
@@ -19,11 +19,35 @@
|
||||
align-self: start;
|
||||
box-sizing: border-box;
|
||||
height: var(--board-gutter-viewport-height);
|
||||
padding-top: 24px;
|
||||
margin-top: -24px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"] {
|
||||
outline: 0.0625rem solid var(--mantine-color-default-border);
|
||||
outline-offset: -0.0625rem;
|
||||
outline: 1px dashed var(--mantine-color-default-border);
|
||||
outline-offset: -1px;
|
||||
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";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconBulb } from "@tabler/icons-react";
|
||||
|
||||
@@ -16,14 +15,13 @@ import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalGridSize,
|
||||
getLogicalTrackSize,
|
||||
LOGICAL_GRID_GAP,
|
||||
getRootSectionForLane,
|
||||
} from "~/components/board/layout";
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
||||
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
|
||||
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
@@ -33,12 +31,6 @@ import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
|
||||
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
|
||||
<GridEditorRegistryProvider>
|
||||
<BoardSelectionProvider>{children}</BoardSelectionProvider>
|
||||
</GridEditorRegistryProvider>
|
||||
);
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board.landmark");
|
||||
@@ -61,22 +53,22 @@ export const ClientBoard = () => {
|
||||
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
||||
.filter((columnCount) => columnCount > 0)
|
||||
.map(getLogicalGridSize);
|
||||
.map(getLogicalTrackSize);
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||
const gridTemplateColumns = [
|
||||
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalGridSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
|
||||
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalTrackSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
|
||||
]
|
||||
.filter((value) => value !== null)
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<BoardSelectionGridProvider>
|
||||
<BoardSelectionProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
@@ -152,7 +144,7 @@ export const ClientBoard = () => {
|
||||
</FloatingTip>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</BoardSelectionGridProvider>
|
||||
</BoardSelectionProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
@@ -3,9 +3,18 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconReplace,
|
||||
IconSettings,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -16,6 +25,8 @@ import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
@@ -45,7 +56,9 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) return null;
|
||||
if (!hasChangeAccess) {
|
||||
return <SelectBoardsMenu />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -53,11 +66,15 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
{!demoReadOnly && (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -218,6 +235,47 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const t = useI18n("board");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
||||
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
|
||||
|
||||
return (
|
||||
<TourTarget id="board-switcher">
|
||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("action.switch")}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{isPending && (
|
||||
<Center p="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const anchorSelector = "a[href]:not([target='_blank'])";
|
||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
const tBoard = useI18n("board");
|
||||
|
||||
@@ -111,6 +111,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
|
||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||
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 (
|
||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||
@@ -126,25 +129,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
disabled={nextBreakpoint === null || !baseLayout}
|
||||
onClick={() => {
|
||||
if (nextBreakpoint === null || !baseLayout) return;
|
||||
const newLayout: FormValues["layouts"][number] = {
|
||||
id: createId(),
|
||||
name: tBoard("setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
breakpoint: nextBreakpoint,
|
||||
role: "custom",
|
||||
};
|
||||
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
|
||||
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
|
||||
form.insertListItem("layouts", newLayout, insertionIndex);
|
||||
form.setFieldValue("layouts", [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: tBoard("setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
breakpoint: nextBreakpoint,
|
||||
role: "custom",
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{tBoard("setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => {
|
||||
{sortedLayouts.map(({ layout, index }) => {
|
||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||
const customBreakpoints = form.values.layouts
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"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 { ActionIcon, Affix, Card } from "@mantine/core";
|
||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||
@@ -73,7 +71,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
),
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
previewDimensions: dimensions,
|
||||
},
|
||||
{
|
||||
title(translate) {
|
||||
@@ -89,10 +86,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
} finally {
|
||||
setIsEditorLoading(false);
|
||||
}
|
||||
}, [currentDefinition, dimensions, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((currentEditMode) => !currentEditMode);
|
||||
setEditMode((editMode) => !editMode);
|
||||
showSuccessNotification({
|
||||
message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"),
|
||||
});
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { Suspense } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
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 { ModalProvider } from "@homarr/modals";
|
||||
import { widgetKinds } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
@@ -22,24 +16,12 @@ export default async function WidgetPreview(props: Props) {
|
||||
if (!widgetKinds.includes(kind as WidgetKind)) {
|
||||
notFound();
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
redirect(`/auth/login?callbackUrl=${encodeURIComponent(`/widgets/${kind}`)}`);
|
||||
}
|
||||
|
||||
const [integrations, board] = await Promise.all([getIntegrationsWithPermissionsAsync(session), getHomeBoardAsync()]);
|
||||
|
||||
return (
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<BoardPreviewProvider board={board}>
|
||||
<ModalProvider>
|
||||
<Center h="100vh">
|
||||
<Suspense fallback={<Loader size="sm" />}>
|
||||
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||
</Suspense>
|
||||
</Center>
|
||||
</ModalProvider>
|
||||
</BoardPreviewProvider>
|
||||
</IntegrationProvider>
|
||||
<Center h="100vh">
|
||||
<Suspense fallback={<Loader size="sm" />}>
|
||||
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||
</Suspense>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -130,45 +130,6 @@
|
||||
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 {
|
||||
container-type: inline-size;
|
||||
overflow: auto;
|
||||
@@ -184,6 +145,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.widgetPanel[data-editing] .panelActions {
|
||||
padding-inline-end: 2.75rem;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
min-width: 0;
|
||||
min-height: 3.75rem;
|
||||
@@ -192,12 +157,30 @@
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.panelIdentity {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.panelIdentityIcon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panelIdentityTitle,
|
||||
.panelThreadTitle {
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.panelActions {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.newConversationButton {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panelAction {
|
||||
width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
@@ -224,6 +207,21 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.newConversationButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.newConversationButton :global(.mantine-Button-section) {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.newConversationButton :global(.mantine-Button-label) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 0.5rem 0.65rem 1rem;
|
||||
}
|
||||
@@ -279,7 +277,8 @@
|
||||
|
||||
@container assistant-widget (max-width: 22rem) {
|
||||
.panelAction,
|
||||
.historyButton {
|
||||
.historyButton,
|
||||
.newConversationButton {
|
||||
width: 2.25rem;
|
||||
max-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
@@ -302,6 +301,10 @@
|
||||
.composerContextLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panelThreadTitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 18rem) {
|
||||
|
||||
@@ -2368,7 +2368,7 @@ const EmptyThread = () => {
|
||||
<Box className={classes.empty}>
|
||||
<Stack align="center" gap="lg" maw={560} w="100%">
|
||||
<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} />
|
||||
</ThemeIcon>
|
||||
<Text size="xl" fw={700}>
|
||||
@@ -2957,6 +2957,7 @@ const Composer = (props: ComposerProps) => {
|
||||
) : (
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="filled"
|
||||
size="lg"
|
||||
aria-label={
|
||||
@@ -3161,6 +3162,27 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
|
||||
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 = ({
|
||||
isRunning,
|
||||
pendingAction,
|
||||
@@ -3190,20 +3212,23 @@ export const AssistantConversationSurface = ({
|
||||
return (
|
||||
<AssistantDirectiveEntitiesProvider>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<AssistantPanelIdentity />
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
<ConversationHistory />
|
||||
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
||||
<AutoApprovalControl />
|
||||
<Tooltip label={t("newConversation")}>
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
<Button
|
||||
className={classes.newConversationButton}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<IconPlus size={17} />}
|
||||
aria-label={t("newConversation")}
|
||||
>
|
||||
<IconPlus size={17} />
|
||||
</ActionIcon>
|
||||
{t("newConversation")}
|
||||
</Button>
|
||||
</ThreadListPrimitive.New>
|
||||
</Tooltip>
|
||||
{onExpand && (
|
||||
|
||||
@@ -32,7 +32,7 @@ import { showErrorNotification, showWarningNotification } from "@homarr/notifica
|
||||
import { closeSpotlight } from "@homarr/spotlight/store";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
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 { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||
|
||||
@@ -16,7 +16,7 @@ const AssistantPromptDetail = () => {
|
||||
const t = useI18n("assistant");
|
||||
return (
|
||||
<Group px="md" py="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="xl">
|
||||
<ThemeIcon color="red" variant="light" radius="xl">
|
||||
<IconRobot size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={1}>
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
|
||||
test("preserves generated notebook content and fills its hidden defaults", () => {
|
||||
const result = getAssistantWidgetConfiguration(
|
||||
widgetImports.notebook.definition,
|
||||
{
|
||||
boardId: "board-1",
|
||||
boardName: "Home",
|
||||
@@ -42,7 +41,6 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
|
||||
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
||||
const result = getAssistantWidgetConfiguration(
|
||||
widgetImports.audioStats.definition,
|
||||
{
|
||||
boardId: "board-1",
|
||||
boardName: "Home",
|
||||
@@ -90,13 +88,11 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
|
||||
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
||||
const mediaServer = getAssistantWidgetConfiguration(
|
||||
widgetImports.mediaServer.definition,
|
||||
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
||||
settings,
|
||||
[],
|
||||
);
|
||||
const calendar = getAssistantWidgetConfiguration(
|
||||
widgetImports.calendar.definition,
|
||||
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
||||
settings,
|
||||
[],
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
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 { useSession } from "@homarr/auth/client";
|
||||
@@ -15,8 +13,7 @@ import { useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
@@ -32,11 +29,11 @@ interface AssistantIntegration extends IntegrationSelectOption {
|
||||
}
|
||||
|
||||
export const getAssistantWidgetConfiguration = (
|
||||
definition: WidgetDefinition,
|
||||
args: ConfigureWidgetArgs,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
integrations: AssistantIntegration[],
|
||||
) => {
|
||||
const definition = widgetImports[args.kind].definition;
|
||||
const integrationSupport = "supportedIntegrations" in definition;
|
||||
const supportedIntegrations = integrationSupport
|
||||
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
||||
@@ -57,91 +54,35 @@ export const getAssistantWidgetConfiguration = (
|
||||
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: reduceWidgetOptionsWithDefinition(definition, settings, args.options),
|
||||
options: reduceWidgetOptionsWithDefaultValues(args.kind, settings, args.options),
|
||||
integrationIds,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type AssistantConfigureWidgetToolProps = ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>;
|
||||
|
||||
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 = ({
|
||||
export const AssistantConfigureWidgetTool = ({
|
||||
args,
|
||||
result,
|
||||
addResult,
|
||||
status,
|
||||
toolCallId,
|
||||
definition,
|
||||
}: AssistantConfigureWidgetToolProps & { definition: WidgetDefinition | undefined }) => {
|
||||
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
|
||||
const t = useI18n("assistant.configureWidget");
|
||||
const actionT = useI18n("common.action");
|
||||
const fullT = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const settings = useSettings();
|
||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
||||
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
|
||||
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
|
||||
const integrations = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
|
||||
retry: false,
|
||||
});
|
||||
const configuration = useMemo(() => {
|
||||
if (!args || !definition || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
||||
return getAssistantWidgetConfiguration(definition, args, settings, integrations.data ?? []);
|
||||
}, [args, definition, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||
if (!args || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
||||
return getAssistantWidgetConfiguration(args, settings, integrations.data ?? []);
|
||||
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||
const missingRequiredIntegration =
|
||||
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
||||
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
||||
@@ -193,7 +134,15 @@ const AssistantConfigureWidgetToolContent = ({
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -209,7 +158,7 @@ const AssistantConfigureWidgetToolContent = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!configuration || !definition) return null;
|
||||
if (!configuration) return null;
|
||||
|
||||
const openConfigurationModal = (
|
||||
value = configuration.value,
|
||||
@@ -218,7 +167,7 @@ const AssistantConfigureWidgetToolContent = ({
|
||||
openWidgetModal(
|
||||
{
|
||||
kind: args.kind,
|
||||
definition,
|
||||
definition: widgetImports[args.kind].definition,
|
||||
value,
|
||||
integrationData,
|
||||
integrationSupport: configuration.integrationSupport,
|
||||
@@ -291,7 +240,7 @@ const AssistantConfigureWidgetToolContent = ({
|
||||
);
|
||||
}
|
||||
|
||||
const WidgetIcon = definition.icon;
|
||||
const WidgetIcon = widgetImports[args.kind].definition.icon;
|
||||
return (
|
||||
<Box className={classes.appTool}>
|
||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||
|
||||
@@ -137,7 +137,7 @@ const CompactAssistantWidget = ({
|
||||
<Box className={classes.widgetPanel}>
|
||||
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<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} />
|
||||
</ThemeIcon>
|
||||
<Stack className={classes.compactCopy} gap={1}>
|
||||
@@ -167,7 +167,13 @@ interface AssistantWidgetStateProps {
|
||||
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">
|
||||
<Stack align="center" gap="sm" ta="center" maw={360}>
|
||||
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.canvas {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 8rem;
|
||||
aspect-ratio: 16 / 7;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
@@ -84,3 +83,9 @@
|
||||
gap: rem(1px);
|
||||
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 {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
fitFullLayout?: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
if (!preview || !layout) {
|
||||
return (
|
||||
@@ -27,7 +26,6 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
||||
}
|
||||
|
||||
const elements = projectBoardLayout(preview, layout, layout);
|
||||
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
||||
const roots = preview.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.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 < columnCount &&
|
||||
element.yOffset >= 0 &&
|
||||
element.yOffset < thumbnailRowLimit &&
|
||||
element.yOffset < maxThumbnailRows &&
|
||||
element.width > 0 &&
|
||||
element.height > 0,
|
||||
);
|
||||
const rowCount = Math.min(
|
||||
thumbnailRowLimit,
|
||||
maxThumbnailRows,
|
||||
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
.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 {
|
||||
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 {
|
||||
min-width: 0;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
.metadataBadge {
|
||||
width: rem(22px);
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.option:hover .card,
|
||||
.option:focus-visible .card,
|
||||
.option[data-active="true"] .card {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
useMatches,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
|
||||
import classes from "./board-switcher.module.css";
|
||||
|
||||
export const boardSwitcherHotkey = "shift+c";
|
||||
|
||||
interface BoardSwitcherProps {
|
||||
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
|
||||
}
|
||||
|
||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
const t = useI18n("board.action.switcher");
|
||||
const manageBoardsT = useI18n("management.page.board");
|
||||
const currentBoard = useOptionalBoard();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const responsiveColumnCount = useMatches({ base: 1, sm: 2, lg: 3, xl: 4 });
|
||||
const optionRefs = useRef<Array<HTMLAnchorElement | null>>([]);
|
||||
const utils = clientApi.useUtils();
|
||||
const {
|
||||
data: boards = [],
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.board.getManageOverview.useQuery(
|
||||
{ fullPreview: true },
|
||||
{
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const switcherBoards = useMemo(
|
||||
() => [
|
||||
...boards.filter((board) => board.id !== currentBoard?.id),
|
||||
...boards.filter((board) => board.id === currentBoard?.id),
|
||||
],
|
||||
[boards, currentBoard?.id],
|
||||
);
|
||||
const filteredBoards = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||
if (normalizedSearch.length === 0) return switcherBoards;
|
||||
return switcherBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
||||
}, [search, switcherBoards]);
|
||||
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
|
||||
const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
|
||||
|
||||
const openSwitcher = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
const closeSwitcher = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSearch("");
|
||||
setActiveIndex(0);
|
||||
}, []);
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||
|
||||
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || filteredBoards.length === 0) return;
|
||||
const normalizedIndex = Math.min(activeIndex, filteredBoards.length - 1);
|
||||
if (normalizedIndex !== activeIndex) {
|
||||
setActiveIndex(normalizedIndex);
|
||||
return;
|
||||
}
|
||||
optionRefs.current[normalizedIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}, [activeIndex, filteredBoards.length, isOpen]);
|
||||
|
||||
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const isSearchInput =
|
||||
event.target instanceof HTMLInputElement && event.target.dataset.boardSwitcherSearch !== undefined;
|
||||
|
||||
if (!isSearchInput && event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
setSearch((current) => current.slice(0, -1));
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSearchInput && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
setSearch((current) => `${current}${event.key}`);
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filteredBoards.length === 0) return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest("[data-board-switcher-option]")) return;
|
||||
event.preventDefault();
|
||||
optionRefs.current[activeIndex]?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
let nextIndex = activeIndex;
|
||||
if (event.key === "ArrowRight") {
|
||||
nextIndex = (activeIndex + 1) % filteredBoards.length;
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
nextIndex = (activeIndex - 1 + filteredBoards.length) % filteredBoards.length;
|
||||
} else if (event.key === "ArrowDown") {
|
||||
const nextRowIndex = activeIndex + columnCount;
|
||||
nextIndex = nextRowIndex;
|
||||
if (nextRowIndex >= filteredBoards.length) nextIndex = activeIndex % columnCount;
|
||||
} else if (event.key === "ArrowUp") {
|
||||
const previousRowIndex = activeIndex - columnCount;
|
||||
if (previousRowIndex >= 0) {
|
||||
nextIndex = previousRowIndex;
|
||||
} else {
|
||||
const lastRowStart = Math.floor((filteredBoards.length - 1) / columnCount) * columnCount;
|
||||
const lastIndexInColumn = lastRowStart + activeIndex;
|
||||
nextIndex = lastIndexInColumn;
|
||||
if (lastIndexInColumn >= filteredBoards.length) nextIndex -= columnCount;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setActiveIndex(nextIndex);
|
||||
requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus({ preventScroll: true }));
|
||||
};
|
||||
|
||||
const results = getBoardSwitcherResults({
|
||||
availableBoardCount: switcherBoards.length,
|
||||
filteredBoards,
|
||||
isError,
|
||||
isPending,
|
||||
search,
|
||||
t,
|
||||
activeIndex,
|
||||
closeSwitcher,
|
||||
setActiveIndex,
|
||||
optionRefs,
|
||||
columnCount,
|
||||
manageBoardsT,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
|
||||
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={closeSwitcher}
|
||||
withCloseButton={false}
|
||||
yOffset="15vh"
|
||||
aria-label={t("title")}
|
||||
size={`${modalColumnCount * 15 + Math.max(0, modalColumnCount - 1) * 0.75}rem`}
|
||||
overlayProps={{ backgroundOpacity: 0, blur: 2 }}
|
||||
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
|
||||
classNames={{ content: classes.modalContent, body: classes.modalBody }}
|
||||
>
|
||||
<Stack
|
||||
tabIndex={-1}
|
||||
gap="xs"
|
||||
onKeyDown={handleModalKeyDown}
|
||||
className={classes.content}
|
||||
aria-describedby="board-switcher-instructions"
|
||||
>
|
||||
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
|
||||
<TextInput
|
||||
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}
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface BoardSwitcherResultsProps {
|
||||
availableBoardCount: number;
|
||||
filteredBoards: RouterOutputs["board"]["getManageOverview"];
|
||||
isError: boolean;
|
||||
isPending: boolean;
|
||||
search: string;
|
||||
t: ReturnType<typeof useI18n<"board.action.switcher">>;
|
||||
activeIndex: number;
|
||||
closeSwitcher: () => void;
|
||||
setActiveIndex: (index: number) => void;
|
||||
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
|
||||
columnCount: number;
|
||||
manageBoardsT: ReturnType<typeof useI18n<"management.page.board">>;
|
||||
}
|
||||
|
||||
const getBoardSwitcherResults = ({
|
||||
availableBoardCount,
|
||||
filteredBoards,
|
||||
isError,
|
||||
isPending,
|
||||
search,
|
||||
t,
|
||||
activeIndex,
|
||||
closeSwitcher,
|
||||
setActiveIndex,
|
||||
optionRefs,
|
||||
columnCount,
|
||||
manageBoardsT,
|
||||
}: BoardSwitcherResultsProps) => {
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("error")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (availableBoardCount === 0) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap={4}>
|
||||
<ThemeIcon variant="light" color="gray" size="xl" radius="xl">
|
||||
<IconLayoutBoard stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>{t("empty.title")}</Text>
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t("empty.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredBoards.length === 0) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("search.empty", { search })}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="xs" p={2}>
|
||||
{filteredBoards.map((board, index) => (
|
||||
<UnstyledButton
|
||||
key={board.id}
|
||||
ref={(node) => {
|
||||
optionRefs.current[index] = node;
|
||||
}}
|
||||
className={classes.option}
|
||||
w="100%"
|
||||
maw={240}
|
||||
mx="auto"
|
||||
component={Link}
|
||||
href={`/boards/${encodeURIComponent(board.name)}`}
|
||||
data-active={index === activeIndex}
|
||||
data-board-switcher-option
|
||||
onClick={closeSwitcher}
|
||||
onFocus={() => setActiveIndex(index)}
|
||||
onPointerEnter={() => setActiveIndex(index)}
|
||||
>
|
||||
<Card padding={0} withBorder radius="lg" className={classes.card}>
|
||||
<Card.Section>
|
||||
<BoardLayoutThumbnail
|
||||
preview={board.preview}
|
||||
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||
fitFullLayout
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Section withBorder px="sm" py={6}>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{board.name}
|
||||
</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>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,17 @@
|
||||
.refreshIndicator {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
|
||||
pointer-events: none;
|
||||
.badge {
|
||||
@mixin dark {
|
||||
--background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity));
|
||||
--border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity));
|
||||
}
|
||||
@mixin light {
|
||||
--background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity));
|
||||
--border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity));
|
||||
}
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.settingsButton {
|
||||
position: absolute;
|
||||
top: -34px;
|
||||
right: 14px;
|
||||
z-index: 201;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
@@ -63,11 +58,3 @@
|
||||
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 { createPortal } from "react-dom";
|
||||
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 { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
@@ -19,16 +20,13 @@ import { WidgetError } from "@homarr/widgets/errors";
|
||||
import {
|
||||
createWidgetRuntimeState,
|
||||
getWidgetQueryKeys,
|
||||
getWidgetRuntimeQueries,
|
||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||
} from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||
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 { getLogicalTrackSize } from "~/components/board/layout";
|
||||
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
|
||||
@@ -36,11 +34,11 @@ import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced
|
||||
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
||||
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
||||
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
@@ -50,28 +48,47 @@ interface BoardItemContentProps {
|
||||
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;
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
const BoardItemCard = ({ item, innerRef, children }: BoardItemCardProps) => {
|
||||
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<WidgetCardShell
|
||||
innerRef={innerRef}
|
||||
kind={item.kind}
|
||||
advancedOptions={item.advancedOptions}
|
||||
opacity={board.opacity / 100}
|
||||
<Card
|
||||
{...cardProps}
|
||||
ref={innerRef}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-grid-item-content
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
</WidgetCardShell>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -150,7 +167,6 @@ const LoadedBoardItemContent = ({
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const previewEntranceFrameRef = useRef<number | null>(null);
|
||||
const board = useRequiredBoard();
|
||||
const boardCanvasScale = useBoardCanvasScale();
|
||||
const t = useI18n();
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
@@ -158,34 +174,12 @@ const LoadedBoardItemContent = ({
|
||||
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
||||
[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 [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||
const { width: viewportWidth, height: viewportHeight } = viewportSize;
|
||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
||||
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 advancedViewId = `advanced-focus-${item.id}`;
|
||||
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
||||
@@ -297,11 +291,8 @@ const LoadedBoardItemContent = ({
|
||||
: undefined;
|
||||
|
||||
const widgetCard = (
|
||||
<WidgetCardShell
|
||||
innerRef={cardRef}
|
||||
kind={item.kind}
|
||||
advancedOptions={item.advancedOptions}
|
||||
opacity={isAdvanced ? 0.98 : board.opacity / 100}
|
||||
<Card
|
||||
ref={cardRef}
|
||||
id={isPreview ? advancedViewId : undefined}
|
||||
role={isPreview ? "region" : undefined}
|
||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||
@@ -312,9 +303,21 @@ const LoadedBoardItemContent = ({
|
||||
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
isPreview && advancedFocusClasses.surface,
|
||||
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}
|
||||
>
|
||||
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
||||
@@ -342,21 +345,15 @@ const LoadedBoardItemContent = ({
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
{isWidgetRefreshing && (
|
||||
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
|
||||
<Loader size="xs" />
|
||||
</Box>
|
||||
)}
|
||||
</WidgetCardShell>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} previewDimensions={previewDimensions} />}
|
||||
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} />}
|
||||
<WidgetContextMenu
|
||||
item={item}
|
||||
definition={definition}
|
||||
previewDimensions={previewDimensions}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
sourceRef={sourceRef}
|
||||
@@ -421,12 +418,25 @@ const LoadedBoardItemContent = ({
|
||||
)}
|
||||
</Box>
|
||||
</WidgetContextMenu>
|
||||
{!isAdvanced && (
|
||||
<WidgetTitleBadge
|
||||
advancedOptions={item.advancedOptions}
|
||||
opacity={board.opacity / 100}
|
||||
{!isAdvanced && item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
style={{ zIndex: "var(--mantine-z-index-app)" }}
|
||||
top={2}
|
||||
left={16}
|
||||
size="xs"
|
||||
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 {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
previewDimensions: { width: number; height: number; scale?: number };
|
||||
resetErrorBoundary?: () => void;
|
||||
}
|
||||
|
||||
@@ -32,7 +31,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
|
||||
return <BoardItemMenuInner {...props} />;
|
||||
};
|
||||
const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
@@ -90,7 +89,6 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
previewDimensions,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
@@ -108,6 +106,10 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 30 }}
|
||||
className={itemContentClasses.settingsButton}
|
||||
data-menu-open={isMenuOpen || undefined}
|
||||
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 { getLayoutRowCount } from "../layout";
|
||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
|
||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } 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 tMoveResize = useI18n("item.moveResize");
|
||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||
const registeredGridEditors = useRegisteredGridEditors();
|
||||
const minimumSize = useMemo(
|
||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||
[board, currentLayoutId, entry],
|
||||
);
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
currentLayoutId,
|
||||
entry,
|
||||
{
|
||||
canvas: tLandmark("canvas"),
|
||||
container: tContainer("action.create"),
|
||||
leftRail: tLandmark("leftRail"),
|
||||
rightRail: tLandmark("rightRail"),
|
||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
tMoveResize("target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
},
|
||||
registeredGridEditors,
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
getMoveTargets(board, currentLayoutId, entry, {
|
||||
canvas: tLandmark("canvas"),
|
||||
container: tContainer("action.create"),
|
||||
leftRail: tLandmark("leftRail"),
|
||||
rightRail: tLandmark("rightRail"),
|
||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
tMoveResize("target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
}),
|
||||
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
|
||||
);
|
||||
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||
@@ -256,7 +252,6 @@ export const getMoveTargets = (
|
||||
layoutId: string,
|
||||
entry: MovableEntry,
|
||||
labels: MoveTargetLabels,
|
||||
registeredGridEditors?: ReadonlyMap<string, { placementMaxRowCount: number | null }>,
|
||||
): MoveTarget[] => {
|
||||
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
||||
|
||||
@@ -265,15 +260,13 @@ export const getMoveTargets = (
|
||||
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (columnCount === 0) return [];
|
||||
const registeredEditor = registeredGridEditors?.get(section.id);
|
||||
if (lane !== "main" && registeredGridEditors && !registeredEditor) return [];
|
||||
return minimum.width <= columnCount
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
||||
columnCount,
|
||||
maxRowCount: registeredEditor?.placementMaxRowCount ?? getSectionMaxRowCount(board, layoutId, section.id),
|
||||
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
getIntegrationName,
|
||||
getWidgetName,
|
||||
getRootSectionLane,
|
||||
widgetDefaultSizes,
|
||||
widgetIntegrationSupport,
|
||||
widgetKinds,
|
||||
widgetKindsWithOptionalIntegrations,
|
||||
@@ -48,7 +47,6 @@ import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { getSectionGridColumnCount } from "../sections/grid/section-grid-placements";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||
import {
|
||||
@@ -78,8 +76,6 @@ export interface WidgetItemData {
|
||||
}
|
||||
|
||||
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
||||
const defaultWidgetSize = { width: 1, height: 1 };
|
||||
const maximumWidgetHeight = 64;
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
@@ -128,13 +124,6 @@ const ItemSelectModalContent = ({
|
||||
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
|
||||
? targetSectionId
|
||||
: (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 selectionLock = useRef(false);
|
||||
@@ -257,7 +246,6 @@ const ItemSelectModalContent = ({
|
||||
try {
|
||||
const definition = await loadWidgetDefinition("customApi");
|
||||
const itemId = createId();
|
||||
const initialSize = widgetDefaultSizes.customApi ?? defaultWidgetSize;
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
@@ -275,7 +263,7 @@ const ItemSelectModalContent = ({
|
||||
options,
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }, selectedSize) => {
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
const updatedBoard = createItem({
|
||||
id: itemId,
|
||||
kind: "customApi",
|
||||
@@ -283,14 +271,12 @@ const ItemSelectModalContent = ({
|
||||
options: configuredOptions,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
size: selectedSize ?? initialSize,
|
||||
});
|
||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
previewResize: { initialSize, maximumSize: maximumWidgetSize },
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||
@@ -325,7 +311,6 @@ const ItemSelectModalContent = ({
|
||||
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
const initialSize = widgetDefaultSizes[kind] ?? defaultWidgetSize;
|
||||
const openEditor = (
|
||||
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
|
||||
) => {
|
||||
@@ -341,7 +326,7 @@ const ItemSelectModalContent = ({
|
||||
options: defaultOptions,
|
||||
integrationIds: selectedIntegrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }, selectedSize) => {
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
const updatedBoard = createItem({
|
||||
id: itemId,
|
||||
kind,
|
||||
@@ -349,7 +334,6 @@ const ItemSelectModalContent = ({
|
||||
integrationIds: newIntegrationIds,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
size: selectedSize ?? initialSize,
|
||||
});
|
||||
if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return;
|
||||
trackSetup("widget-completed", {
|
||||
@@ -362,7 +346,6 @@ const ItemSelectModalContent = ({
|
||||
integrationData: availableIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
previewResize: { initialSize, maximumSize: maximumWidgetSize },
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
@@ -8,49 +7,16 @@ import type { FallbackProps } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||
import type { WidgetEditModalProps, WidgetEditModalSize } from "@homarr/widgets/modals";
|
||||
import { loadWidgetComponent } from "@homarr/widgets/manifest";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
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 type * as IntegrationEditFormModule 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;
|
||||
|
||||
export const loadWidgetEditModal = () => {
|
||||
@@ -117,7 +83,6 @@ interface LazyWidgetEditModalContentProps {
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const { default: PreviewComponent } = use(loadWidgetComponent(props.innerProps.kind));
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const Component = WidgetEditModal.component;
|
||||
const utils = clientApi.useUtils();
|
||||
@@ -149,18 +114,6 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
||||
return [...existing, ...additional];
|
||||
}, [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 (
|
||||
<Component
|
||||
@@ -169,10 +122,6 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
...props.innerProps,
|
||||
integrationData: combinedIntegrationData,
|
||||
integrationEditForm: LazyIntegrationEditForm,
|
||||
previewComponent: PreviewComponent,
|
||||
previewDimensions,
|
||||
previewResize,
|
||||
previewWrapper: PreviewRuntimeWrapper,
|
||||
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||
}}
|
||||
/>
|
||||
@@ -208,10 +157,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: "100rem",
|
||||
transitionProps: {
|
||||
transition: "pop",
|
||||
duration: 180,
|
||||
},
|
||||
size: modalSizeForm,
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -35,7 +35,6 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
previewDimensions: { width: number; height: number; scale?: number };
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
sourceRef: RefObject<HTMLElement | null>;
|
||||
@@ -45,7 +44,6 @@ interface WidgetContextMenuProps {
|
||||
export const WidgetContextMenu = ({
|
||||
item,
|
||||
definition,
|
||||
previewDimensions,
|
||||
widgetRuntimeRef,
|
||||
sourceRef,
|
||||
children,
|
||||
@@ -162,7 +160,7 @@ export const WidgetContextMenu = ({
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options,
|
||||
options: item.options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: (editResult) => {
|
||||
@@ -191,9 +189,6 @@ export const WidgetContextMenu = ({
|
||||
),
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
previewDimensions,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -202,15 +197,12 @@ export const WidgetContextMenu = ({
|
||||
},
|
||||
);
|
||||
}, [
|
||||
board.id,
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
handleRefetch,
|
||||
openModal,
|
||||
options,
|
||||
persistBoard,
|
||||
previewDimensions,
|
||||
settings,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 40px 4px 8px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--background-color);
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
The board uses fixed logical units:
|
||||
|
||||
- one grid track has a `212 × 212` logical-pixel footprint;
|
||||
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter;
|
||||
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset);
|
||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
|
||||
- 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.
|
||||
|
||||
Grid footprints keep the same logical dimensions at every viewport. Widget cards
|
||||
use a scale-compensated inset so their painted size and content measurements
|
||||
match the legacy responsive grid. The visual canvas always fits the available
|
||||
width, every root lane keeps at least one viewport of height, and edit-mode
|
||||
previews extend it downward as needed.
|
||||
Items keep the same logical dimensions at every viewport. The viewport reserves
|
||||
the painted size while the inner canvas remains at its logical dimensions, so a
|
||||
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
|
||||
stable. The visual canvas always fits the available width, every root lane
|
||||
keeps at least one viewport of height, and edit-mode previews extend it downward
|
||||
as needed.
|
||||
|
||||
The read-only renderer should:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BoardLane } from "@homarr/definitions";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
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 type { GridPlacement } from "./types";
|
||||
|
||||
@@ -90,5 +90,5 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
|
||||
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.
|
||||
*/
|
||||
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 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 => ({
|
||||
position: "absolute",
|
||||
left: x * LOGICAL_GRID_PITCH,
|
||||
top: y * LOGICAL_GRID_PITCH,
|
||||
width: getLogicalGridSize(w),
|
||||
height: getLogicalGridSize(h),
|
||||
width: getLogicalTrackSize(w),
|
||||
height: getLogicalTrackSize(h),
|
||||
});
|
||||
|
||||
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(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>(
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
* zoom and enlarges everything together.
|
||||
*/
|
||||
--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-sm: calc(0.75rem * 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 { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalGridSize,
|
||||
getRootSectionForLane,
|
||||
} from "../index";
|
||||
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
|
||||
|
||||
describe("board lanes", () => {
|
||||
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 {
|
||||
getGridRowCountForVisualHeight,
|
||||
getLayoutRowCount,
|
||||
getLogicalGridSize,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
normalizeGridPlacement,
|
||||
} from "../index";
|
||||
|
||||
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(0.5)).toBe(94);
|
||||
expect(getLogicalTrackSize(2)).toBe(412);
|
||||
expect(getLogicalTrackSize(3)).toBe(624);
|
||||
expect(getLogicalGridSize(1)).toBe(212);
|
||||
expect(getLogicalGridSize(2)).toBe(424);
|
||||
expect(getLogicalGridSize(3)).toBe(636);
|
||||
expect(getLogicalTrackSize(0.5)).toBe(88);
|
||||
expect(getLogicalTrackSize(2)).toBe(424);
|
||||
expect(getLogicalTrackSize(3)).toBe(648);
|
||||
|
||||
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
||||
position: "absolute",
|
||||
left: 212,
|
||||
top: 424,
|
||||
left: 224,
|
||||
top: 448,
|
||||
width: 424,
|
||||
height: 636,
|
||||
height: 648,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +35,7 @@ describe("fixed dashboard geometry", () => {
|
||||
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,6 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
<Card
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
classes.containerCard,
|
||||
options.customCssClasses.join(" "),
|
||||
isVisuallyCollapsed && classes.collapsedContainerCard,
|
||||
)}
|
||||
@@ -57,7 +56,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
root: {
|
||||
overflow: "visible",
|
||||
"--opacity": board.opacity / 100,
|
||||
"--container-border-color": options.borderColor || undefined,
|
||||
"--border-color": options.borderColor || undefined,
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
@@ -67,7 +66,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
|
||||
top={isVisuallyCollapsed ? 0 : -24}
|
||||
left={0}
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
@@ -104,9 +103,8 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
)}
|
||||
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
||||
<Badge
|
||||
className={classes.containerLabel}
|
||||
pos="absolute"
|
||||
top="calc(var(--mantine-spacing-xs) * -1)"
|
||||
top={-24}
|
||||
left={labelLeft}
|
||||
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
||||
size="md"
|
||||
@@ -115,7 +113,12 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
c="var(--mantine-color-text)"
|
||||
style={{
|
||||
zIndex: 9,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: "var(--background-color)",
|
||||
borderColor: "var(--border-color)",
|
||||
}}
|
||||
title={options.title}
|
||||
data-board-container-label
|
||||
@@ -125,9 +128,8 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
)}
|
||||
{options.showOpenAll && !isEditMode && (
|
||||
<ActionIcon
|
||||
className={classes.containerAction}
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? "50%" : "calc(var(--mantine-spacing-xs) * -1)"}
|
||||
top={isVisuallyCollapsed ? "50%" : -24}
|
||||
right={8}
|
||||
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
|
||||
variant={options.collapsible ? "subtle" : "default"}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createPortal } from "react-dom";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
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";
|
||||
|
||||
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
||||
@@ -54,14 +54,14 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
||||
const GridEditor = resolvedEditorModule?.default ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridEditorRegistryProvider>
|
||||
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
|
||||
{isEditMode && Provider && GridEditor && (
|
||||
<Provider>
|
||||
<RegisteredGridEditors GridEditor={GridEditor} />
|
||||
</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", () => {
|
||||
const scale = 0.75;
|
||||
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
|
||||
const initialShape = {
|
||||
left: target.left,
|
||||
top: target.top,
|
||||
width: getLogicalTrackSize(2) * scale,
|
||||
height: 200 * scale,
|
||||
};
|
||||
const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale };
|
||||
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
|
||||
|
||||
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 { GridResizeDirection } from "./types";
|
||||
|
||||
@@ -38,7 +38,7 @@ export const getSnappedGridCoordinates = ({
|
||||
target: RectangleLike;
|
||||
columnCount: number;
|
||||
}) => {
|
||||
const logicalWidth = getLogicalGridSize(columnCount);
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
|
||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface GridEditorRenderProps {
|
||||
}
|
||||
|
||||
export interface RegisteredGridEditor extends GridEditorRenderProps {
|
||||
placementMaxRowCount: number | null;
|
||||
host: HTMLElement;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
.board-grid-entry[data-board-item-selected="true"]::after {
|
||||
position: absolute;
|
||||
inset: var(--board-grid-item-inset, 0px);
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
content: "";
|
||||
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 {
|
||||
z-index: 0;
|
||||
clip-path: inset(var(--board-grid-item-inset, 0px));
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||
border: 2px dashed var(--mantine-primary-color-filled);
|
||||
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 {
|
||||
z-index: 1002;
|
||||
clip-path: inset(var(--board-grid-item-inset, 0px));
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
|
||||
border: 2px solid var(--mantine-primary-color-filled);
|
||||
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);
|
||||
}
|
||||
|
||||
[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-body]
|
||||
> [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"] {
|
||||
top: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||
top: -0.25rem;
|
||||
}
|
||||
|
||||
.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"],
|
||||
@@ -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"] {
|
||||
right: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||
right: -0.25rem;
|
||||
}
|
||||
|
||||
.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"],
|
||||
@@ -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"] {
|
||||
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
top: -0.625rem;
|
||||
right: -0.625rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
right: -0.625rem;
|
||||
bottom: -0.625rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
bottom: -0.625rem;
|
||||
left: -0.625rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
top: -0.625rem;
|
||||
left: -0.625rem;
|
||||
cursor: nwse-resize;
|
||||
/* 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. */
|
||||
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) {
|
||||
|
||||
@@ -39,7 +39,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getWidgetName } from "@homarr/definitions";
|
||||
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 {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
@@ -792,8 +792,8 @@ export default function GridEditor({
|
||||
data-grid-depth={depth}
|
||||
style={
|
||||
{
|
||||
width: getLogicalGridSize(columnCount),
|
||||
height: getLogicalGridSize(rowCount),
|
||||
width: getLogicalTrackSize(columnCount),
|
||||
height: getLogicalTrackSize(rowCount),
|
||||
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
@@ -1234,8 +1234,8 @@ const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGrid
|
||||
if (!element) return null;
|
||||
const rectangle = element.getBoundingClientRect();
|
||||
const scale = {
|
||||
x: rectangle.width / getLogicalGridSize(placement.w),
|
||||
y: rectangle.height / getLogicalGridSize(placement.h),
|
||||
x: rectangle.width / getLogicalTrackSize(placement.w),
|
||||
y: rectangle.height / getLogicalTrackSize(placement.h),
|
||||
};
|
||||
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";
|
||||
|
||||
export interface GridPreviewDomState {
|
||||
@@ -75,8 +75,8 @@ export const clearGridPreviewGeometry = (
|
||||
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-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-height", `${getLogicalGridSize(preview.h)}px`);
|
||||
element.style.setProperty("--board-grid-preview-width", `${getLogicalTrackSize(preview.w)}px`);
|
||||
element.style.setProperty("--board-grid-preview-height", `${getLogicalTrackSize(preview.h)}px`);
|
||||
element.setAttribute("data-grid-preview", "true");
|
||||
setPlacementAttributes(element, preview);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { RefObject } 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 { GridInteractionStore } from "./grid-interaction-store";
|
||||
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
|
||||
@@ -87,7 +87,7 @@ export const GridPreviewLayer = ({
|
||||
} else {
|
||||
delete grid.dataset.dndPreviewRevision;
|
||||
}
|
||||
grid.style.height = `${getLogicalGridSize(renderedRowCount)}px`;
|
||||
grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
|
||||
|
||||
const viewport = grid.parentElement;
|
||||
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
|
||||
// drag ended, since nothing else would ever clear it back out.
|
||||
if (maxRowCount === null) {
|
||||
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalGridSize(renderedRowCount)}px`);
|
||||
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
|
||||
} else {
|
||||
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";
|
||||
|
||||
interface ResizeOutlinePreview {
|
||||
@@ -31,8 +31,8 @@ export const createGridResizeOutlineController = (
|
||||
outline.style.position = "absolute";
|
||||
outline.style.left = `${(preview.placement.x - visualX) * 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.height = `${getLogicalGridSize(preview.placement.h)}px`;
|
||||
outline.style.width = `${getLogicalTrackSize(preview.placement.w)}px`;
|
||||
outline.style.height = `${getLogicalTrackSize(preview.placement.h)}px`;
|
||||
outline.dataset.gridResizeValid = String(preview.valid);
|
||||
};
|
||||
|
||||
|
||||
@@ -46,18 +46,12 @@
|
||||
|
||||
.contentMount {
|
||||
position: absolute;
|
||||
inset: var(--board-grid-item-inset, 0px);
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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 {
|
||||
overflow: visible;
|
||||
cursor: grab;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getEditableCanvasAttributes,
|
||||
getGridRowCountForVisualHeight,
|
||||
getLayoutRowCount,
|
||||
getLogicalGridSize,
|
||||
getLogicalTrackSize,
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
@@ -130,17 +130,13 @@ export const SectionGrid = ({
|
||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
||||
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
|
||||
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
|
||||
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
||||
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
|
||||
const logicalWidth = getLogicalGridSize(columnCount);
|
||||
const logicalHeight = getLogicalGridSize(rowCount);
|
||||
const viewportHeight = getLogicalGridSize(viewportRowCount);
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||
const viewportHeight = getLogicalTrackSize(viewportRowCount);
|
||||
// A collapsed container's compact coordinates are display-only. Its own
|
||||
// nested grid stays inactive until an explicit edit interaction expands it.
|
||||
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
|
||||
@@ -179,7 +175,6 @@ export const SectionGrid = ({
|
||||
columnCount,
|
||||
rowCount,
|
||||
maxRowCount,
|
||||
placementMaxRowCount,
|
||||
placements: displayPlacements,
|
||||
transactionPlacements: placements,
|
||||
className: editorClassName,
|
||||
@@ -195,7 +190,6 @@ export const SectionGrid = ({
|
||||
entryElementStore,
|
||||
isInteractionDisabled,
|
||||
maxRowCount,
|
||||
placementMaxRowCount,
|
||||
placements,
|
||||
rowCount,
|
||||
section,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
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";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -250,14 +250,14 @@ describe("fixed grid item behavior", () => {
|
||||
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;
|
||||
renderWeather(root);
|
||||
|
||||
const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]');
|
||||
expect(item).not.toBeNull();
|
||||
expect(item?.style.width).toBe(`${getLogicalGridSize(1)}px`);
|
||||
expect(item?.style.height).toBe(`${getLogicalGridSize(1)}px`);
|
||||
expect(item?.style.width).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
|
||||
expect(item?.style.height).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
|
||||
expect(item?.getAttribute("role")).toBeNull();
|
||||
|
||||
act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
|
||||
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../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")?.getAttribute("data-dnd-active")).toBe("true");
|
||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe(
|
||||
`${-2 * LOGICAL_GRID_PITCH}px`,
|
||||
);
|
||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe(
|
||||
`${2 * LOGICAL_GRID_PITCH}px`,
|
||||
);
|
||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe("-448px");
|
||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px");
|
||||
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
|
||||
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
|
||||
|
||||
@@ -63,10 +57,10 @@ describe("grid preview geometry", () => {
|
||||
|
||||
const active = elements.get("active");
|
||||
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-y")).toBe(`${LOGICAL_GRID_PITCH}px`);
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalGridSize(3)}px`);
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalGridSize(4)}px`);
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe("224px");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe("224px");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe("648px");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe("872px");
|
||||
expect(active?.dataset.gridW).toBe("3");
|
||||
expect(active?.dataset.gridH).toBe("4");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { getLogicalGridSize } from "~/components/board/layout";
|
||||
|
||||
import { createGridResizeOutlineController } from "../grid-resize-outline";
|
||||
|
||||
describe("grid resize outline", () => {
|
||||
@@ -30,8 +28,8 @@ describe("grid resize outline", () => {
|
||||
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
|
||||
expect(outline?.style.left).toBe("0px");
|
||||
expect(outline?.style.top).toBe("0px");
|
||||
expect(outline?.style.width).toBe(`${getLogicalGridSize(1)}px`);
|
||||
expect(outline?.style.height).toBe(`${getLogicalGridSize(2)}px`);
|
||||
expect(outline?.style.width).toBe("200px");
|
||||
expect(outline?.style.height).toBe("424px");
|
||||
expect(outline?.dataset.gridResizeValid).toBe("false");
|
||||
|
||||
controller.schedule(null);
|
||||
|
||||
@@ -11,18 +11,6 @@
|
||||
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) {
|
||||
background-color: transparent !important;
|
||||
border-color: transparent !important;
|
||||
@@ -38,8 +26,8 @@
|
||||
z-index: 9;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-text);
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
background-color: var(--background-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.containerToggle :global(.mantine-Button-inner) {
|
||||
@@ -54,24 +42,6 @@
|
||||
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 {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
|
||||
@@ -31,7 +31,7 @@ interface BoardSelectionContextValue {
|
||||
copySelectedItems: () => Promise<void>;
|
||||
pasteItems: () => Promise<void>;
|
||||
removeSelectedItems: () => void;
|
||||
moveSelectedItemsToSection: (sectionId: string, maxRowCount?: number | null) => void;
|
||||
moveSelectedItemsToSection: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
|
||||
@@ -229,7 +229,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
|
||||
|
||||
const moveSelectedItemsToSection = useCallback(
|
||||
(targetSectionId: string, maxRowCount?: number | null) => {
|
||||
(targetSectionId: string) => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const idsToMove = new Set(selectedItemIds);
|
||||
@@ -248,7 +248,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
targetSection.kind === "container"
|
||||
? (containerLayout?.width ?? 0)
|
||||
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
|
||||
const rowCount = containerLayout?.height ?? maxRowCount ?? 9999;
|
||||
const rowCount = containerLayout?.height ?? 9999;
|
||||
if (columnCount === 0 || rowCount === 0) return previous;
|
||||
|
||||
const existingContainers = previous.sections
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
|
||||
import { useBoardSelection } from "./board-selection-context";
|
||||
|
||||
const MAX_AVATARS_DISPLAYED = 5;
|
||||
@@ -41,7 +40,6 @@ export const BoardSelectionToolbar = () => {
|
||||
moveSelectedItemsToSection,
|
||||
} = useBoardSelection();
|
||||
const board = useRequiredBoard();
|
||||
const registeredGridEditors = useRegisteredGridEditors();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const t = useI18n();
|
||||
@@ -98,15 +96,7 @@ export const BoardSelectionToolbar = () => {
|
||||
.at(0);
|
||||
|
||||
const placementOptions = [
|
||||
...(mainCanvasSection
|
||||
? [
|
||||
{
|
||||
value: mainCanvasSection.id,
|
||||
label: t("item.create.destination.mainCanvas"),
|
||||
maxRowCount: null,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "empty") return [];
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
@@ -115,7 +105,6 @@ export const BoardSelectionToolbar = () => {
|
||||
{
|
||||
value: section.id,
|
||||
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
||||
maxRowCount: registeredGridEditors.get(section.id)?.placementMaxRowCount,
|
||||
},
|
||||
];
|
||||
}),
|
||||
@@ -124,7 +113,6 @@ export const BoardSelectionToolbar = () => {
|
||||
.map((section) => ({
|
||||
value: section.id,
|
||||
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.Label>{tSelection("moveTo")}</Menu.Label>
|
||||
{placementOptions.map((opt) => (
|
||||
<Menu.Item
|
||||
key={opt.value}
|
||||
disabled={opt.maxRowCount === undefined}
|
||||
onClick={() => moveSelectedItemsToSection(opt.value, opt.maxRowCount)}
|
||||
>
|
||||
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
|
||||
{opt.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
|
||||
@@ -66,6 +66,16 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-switcher",
|
||||
title: t("switcher.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("switcher.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-user-menu",
|
||||
title: t("userMenu.title"),
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconReplace,
|
||||
IconSettings,
|
||||
IconRobot,
|
||||
IconTool,
|
||||
@@ -23,7 +22,6 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
||||
import { BoardSwitcher } from "./board/board-switcher";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||
@@ -43,7 +41,6 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useI18n("common.userAvatar.menu");
|
||||
const tBoard = useI18n("board");
|
||||
const session = useSession();
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
@@ -59,111 +56,98 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
|
||||
}, [logoutUrl]);
|
||||
|
||||
return (
|
||||
<BoardSwitcher>
|
||||
{({ open, preload, hotkey }) => (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
|
||||
onClick={open}
|
||||
onFocus={preload}
|
||||
onPointerEnter={preload}
|
||||
>
|
||||
{tBoard("action.switch")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</Text>
|
||||
) : (
|
||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||
)
|
||||
}
|
||||
onClick={assistant.open}
|
||||
>
|
||||
{t("assistant")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
>
|
||||
{t("preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{invariantTechnicalLabels.docker}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
inline
|
||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||
color="red"
|
||||
size={20}
|
||||
offset={4}
|
||||
label={
|
||||
assistant?.isRunning ? (
|
||||
<Loader type="bars" color="white" size={10} />
|
||||
) : assistant?.unreadCount ? (
|
||||
assistant.unreadCount > 99 ? (
|
||||
"99+"
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</Text>
|
||||
) : (
|
||||
assistant.unreadCount
|
||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
}
|
||||
onClick={assistant.open}
|
||||
>
|
||||
{t("assistant")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
>
|
||||
{children}
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
</Menu>
|
||||
)}
|
||||
</BoardSwitcher>
|
||||
{t("preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{invariantTechnicalLabels.docker}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
inline
|
||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||
color="red"
|
||||
size={20}
|
||||
offset={4}
|
||||
label={
|
||||
assistant?.isRunning ? (
|
||||
<Loader type="bars" color="white" size={10} />
|
||||
) : assistant?.unreadCount ? (
|
||||
assistant.unreadCount > 99 ? (
|
||||
"99+"
|
||||
) : (
|
||||
assistant.unreadCount
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
+28
-114
@@ -1,6 +1,5 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import type { Route } from "@playwright/test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { stringify } from "superjson";
|
||||
import { describe, test } from "vitest";
|
||||
@@ -22,9 +21,7 @@ describe("lazy widget application graph", () => {
|
||||
const boardId = createId();
|
||||
const layoutId = createId();
|
||||
const sectionId = createId();
|
||||
const clockItemId = createId();
|
||||
const downloadsItemId = createId();
|
||||
const mockIntegrationId = createId();
|
||||
const itemId = createId();
|
||||
|
||||
await db.insert(sqliteSchema.boards).values({
|
||||
id: boardId,
|
||||
@@ -44,56 +41,26 @@ describe("lazy widget application graph", () => {
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
await db.insert(sqliteSchema.integrations).values({
|
||||
id: mockIntegrationId,
|
||||
name: "Mock downloads",
|
||||
url: "http://mock.local",
|
||||
kind: "mock",
|
||||
await db.insert(sqliteSchema.items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringify({ showSeconds: true }),
|
||||
});
|
||||
await db.insert(sqliteSchema.items).values([
|
||||
{
|
||||
id: clockItemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringify({ showSeconds: true }),
|
||||
},
|
||||
{
|
||||
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.insert(sqliteSchema.itemLayouts).values({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 2,
|
||||
});
|
||||
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
||||
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "credentials",
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
@@ -102,18 +69,10 @@ describe("lazy widget application graph", () => {
|
||||
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ serviceWorkers: "block" });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const pageErrors: Error[] = [];
|
||||
const hydrationErrors: string[] = [];
|
||||
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 {
|
||||
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();
|
||||
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
||||
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.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, {
|
||||
timeout: 15_000,
|
||||
@@ -151,59 +101,23 @@ describe("lazy widget application graph", () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
||||
|
||||
let refreshRequestStarted = false;
|
||||
const refreshGate = new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
const downloadsRequestPattern = "**/*widget.downloads.getJobsAndStatuses*";
|
||||
const delayDownloadsRefresh = async (route: Route) => {
|
||||
refreshRequestStarted = true;
|
||||
await refreshGate;
|
||||
await route.continue();
|
||||
};
|
||||
await page.route(downloadsRequestPattern, delayDownloadsRefresh);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await page.evaluate(async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
return registrations[0]?.scope ?? null;
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(`${baseUrl}/`);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.reload();
|
||||
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(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 {
|
||||
releaseRefresh?.();
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}
|
||||
}, 180_000);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -8,8 +8,6 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
||||
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||
|
||||
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"]);
|
||||
|
||||
export const isWidgetDataTrpcPath = (path: string) => {
|
||||
|
||||
@@ -86,7 +86,6 @@ interface BoardItemPlacementRectangle {
|
||||
|
||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||
const maxManageOverviewPreviewRows = 12;
|
||||
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
|
||||
|
||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||
@@ -226,7 +225,7 @@ export const boardRouter = createTRPCRouter({
|
||||
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 { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
@@ -307,7 +306,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
@@ -332,7 +331,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||
gte(sectionLayouts.xOffset, 0),
|
||||
gte(sectionLayouts.yOffset, 0),
|
||||
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(sectionLayouts.width, 0),
|
||||
gt(sectionLayouts.height, 0),
|
||||
),
|
||||
@@ -367,7 +366,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
|
||||
@@ -337,7 +337,7 @@ describe("getManageOverview", () => {
|
||||
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 caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const boardId = createId();
|
||||
@@ -487,10 +487,6 @@ describe("getManageOverview", () => {
|
||||
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
||||
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
|
||||
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 () => {
|
||||
|
||||
@@ -10,16 +10,12 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection";
|
||||
import { updateBoardName } from "./updater";
|
||||
|
||||
type Board = RouterOutputs["board"]["getBoardByName"];
|
||||
|
||||
interface BoardContextValue {
|
||||
board: Board;
|
||||
const BoardContext = createContext<{
|
||||
board: RouterOutputs["board"]["getBoardByName"];
|
||||
layoutOverrideId: string | null;
|
||||
currentLayout: string;
|
||||
initialViewportWidth: number;
|
||||
}
|
||||
|
||||
const BoardContext = createContext<BoardContextValue | null>(null);
|
||||
} | null>(null);
|
||||
|
||||
const subscribeToViewport = (onStoreChange: () => void) => {
|
||||
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 = ({
|
||||
children,
|
||||
initialBoard,
|
||||
|
||||
+61
-213
@@ -514,9 +514,6 @@ const seedDefaultBoardAsync = async (db: Database) => {
|
||||
|
||||
interface DemoWidget {
|
||||
kind: WidgetKind;
|
||||
section?: "right";
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
needsIntegration: boolean;
|
||||
@@ -565,9 +562,9 @@ const demoApps = [
|
||||
href: "https://portainer.io",
|
||||
},
|
||||
{
|
||||
name: "Uptime Kuma",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/uptime-kuma.svg",
|
||||
href: "https://uptime.kuma.pet",
|
||||
name: "Home Assistant",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
|
||||
href: "https://home-assistant.io",
|
||||
},
|
||||
{
|
||||
name: "Nextcloud",
|
||||
@@ -586,163 +583,69 @@ const demoApps = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): DemoWidget[] => [
|
||||
// Daily focus
|
||||
{ kind: "calendar", xOffset: 0, yOffset: 0, width: 2, height: 2, needsIntegration: true },
|
||||
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
|
||||
// Row 1: calendar + downloads + clock = 12
|
||||
{ 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",
|
||||
xOffset: 2,
|
||||
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,
|
||||
kind: "bookmarks",
|
||||
width: 6,
|
||||
height: 3,
|
||||
needsIntegration: false,
|
||||
options: {
|
||||
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>`,
|
||||
},
|
||||
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
|
||||
},
|
||||
{ kind: "beszelSystemGrid", xOffset: 5, yOffset: 2, width: 4, height: 3, needsIntegration: true },
|
||||
{ kind: "assistant", xOffset: 9, yOffset: 2, width: 3, height: 3, needsIntegration: false },
|
||||
|
||||
// Operations center
|
||||
{ kind: "mediaRequests-requestList", xOffset: 0, yOffset: 5, width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "mediaMissing", xOffset: 9, yOffset: 5, width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "mediaServer", xOffset: 0, yOffset: 7, width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "mediaRequests-requestStats", xOffset: 9, yOffset: 7, width: 3, height: 2, needsIntegration: true },
|
||||
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
|
||||
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
|
||||
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
|
||||
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
|
||||
{ kind: "mediaReleases", width: 4, 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",
|
||||
xOffset: 0,
|
||||
yOffset: 9,
|
||||
width: 3,
|
||||
width: 6,
|
||||
height: 2,
|
||||
needsIntegration: false,
|
||||
options: {
|
||||
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
|
||||
maximumAmountPosts: 12,
|
||||
maximumAmountPosts: 20,
|
||||
textLinesClamp: 2,
|
||||
hideDescription: true,
|
||||
hideDescription: false,
|
||||
},
|
||||
},
|
||||
{ kind: "indexerManager", xOffset: 9, yOffset: 9, width: 3, height: 2, needsIntegration: true },
|
||||
|
||||
// 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
|
||||
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
|
||||
...appIds.map(
|
||||
(appId, index): DemoWidget => ({
|
||||
(appId): DemoWidget => ({
|
||||
kind: "app",
|
||||
section: "right",
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
width: 1,
|
||||
width: 2,
|
||||
height: 1,
|
||||
needsIntegration: 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) => {
|
||||
@@ -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();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "default",
|
||||
isPublic: false,
|
||||
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({
|
||||
id: mainSectionId,
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
|
||||
const rightSectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: rightSectionId,
|
||||
kind: "empty",
|
||||
xOffset: 1,
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
|
||||
const layoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 13,
|
||||
rightGutterColumnCount: 1,
|
||||
columnCount: 12,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
});
|
||||
|
||||
const demoWidgets = buildDemoWidgets(appIds, customWidgetDefinitionId);
|
||||
const demoWidgets = buildDemoWidgets(appIds);
|
||||
let xOffset = 0;
|
||||
let yOffset = 0;
|
||||
let rowMaxHeight = 0;
|
||||
for (const widget of demoWidgets) {
|
||||
let sectionId = mainSectionId;
|
||||
if (widget.section === "right") {
|
||||
sectionId = rightSectionId;
|
||||
if (xOffset + widget.width > 12) {
|
||||
xOffset = 0;
|
||||
yOffset += rowMaxHeight;
|
||||
rowMaxHeight = 0;
|
||||
}
|
||||
|
||||
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
|
||||
const itemId = createId();
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
@@ -912,8 +759,8 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: widget.xOffset,
|
||||
yOffset: widget.yOffset,
|
||||
xOffset,
|
||||
yOffset,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
});
|
||||
@@ -923,6 +770,7 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
integrationId,
|
||||
});
|
||||
}
|
||||
xOffset += widget.width;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const connection = new BetterSqlite3(":memory:");
|
||||
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
|
||||
|
||||
@@ -161,7 +161,7 @@ export interface DefaultWidgetConfig {
|
||||
|
||||
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
|
||||
{ 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 } },
|
||||
];
|
||||
|
||||
|
||||
@@ -1904,7 +1904,8 @@
|
||||
}
|
||||
},
|
||||
"widgetQuery": {
|
||||
"loading": "Loading widget data"
|
||||
"loading": "Loading widget data",
|
||||
"stale": "Could not refresh {widget}; showing saved data"
|
||||
},
|
||||
"total": "Total",
|
||||
"filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}",
|
||||
@@ -2082,12 +2083,10 @@
|
||||
"label": "Section or rail"
|
||||
},
|
||||
"width": {
|
||||
"label": "Width",
|
||||
"shortLabel": "W"
|
||||
"label": "Width"
|
||||
},
|
||||
"height": {
|
||||
"label": "Height",
|
||||
"shortLabel": "H"
|
||||
"label": "Height"
|
||||
},
|
||||
"xOffset": {
|
||||
"label": "X offset"
|
||||
@@ -2116,13 +2115,6 @@
|
||||
"loading": "Loading integration",
|
||||
"loadError": "This integration could not be loaded. Try again before changing its connection settings."
|
||||
},
|
||||
"preview": {
|
||||
"integrationRequired": "Choose a connection to preview this widget.",
|
||||
"size": "Size"
|
||||
},
|
||||
"settings": {
|
||||
"label": "Widget settings"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"label": "Advanced options",
|
||||
"title": "Advanced item options"
|
||||
@@ -2200,51 +2192,8 @@
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"adaptive": {
|
||||
"label": "Adaptive"
|
||||
},
|
||||
"gridHorizontal": {
|
||||
"label": "Compact grid"
|
||||
},
|
||||
"icons": {
|
||||
"label": "Icons"
|
||||
}
|
||||
}
|
||||
},
|
||||
"variant": {
|
||||
"label": "Appearance",
|
||||
"option": {
|
||||
"soft": {
|
||||
"label": "Soft"
|
||||
},
|
||||
"filled": {
|
||||
"label": "Filled"
|
||||
},
|
||||
"outline": {
|
||||
"label": "Outline"
|
||||
},
|
||||
"plain": {
|
||||
"label": "Plain"
|
||||
}
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"label": "Card spacing",
|
||||
"option": {
|
||||
"xs": {
|
||||
"label": "Extra small"
|
||||
},
|
||||
"sm": {
|
||||
"label": "Small"
|
||||
},
|
||||
"md": {
|
||||
"label": "Medium"
|
||||
},
|
||||
"lg": {
|
||||
"label": "Large"
|
||||
},
|
||||
"xl": {
|
||||
"label": "Extra large"
|
||||
"label": "Grid horizontal"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2261,21 +2210,17 @@
|
||||
"label": "Show border"
|
||||
},
|
||||
"customUrls": {
|
||||
"label": "Custom links"
|
||||
"label": "Custom links",
|
||||
"description": "Add URLs directly without creating an App first"
|
||||
},
|
||||
"items": {
|
||||
"label": "Bookmarks",
|
||||
"remove": "Remove bookmark",
|
||||
"apps": {
|
||||
"label": "Add bookmarks",
|
||||
"description": "Paste URLs or find existing Apps. Drag the bookmarks below to reorder them.",
|
||||
"placeholder": "Paste URLs or search Apps",
|
||||
"addUrl": "Add this URL",
|
||||
"hint": "Press Enter to add a URL. Paste comma-separated or one-per-line URLs to add several."
|
||||
"label": "Apps",
|
||||
"placeholder": "Select one or more apps"
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty": "Add a URL or App in the widget settings"
|
||||
}
|
||||
},
|
||||
"archiveTeamWarrior": {
|
||||
"name": "ArchiveTeam Warrior",
|
||||
@@ -3067,10 +3012,12 @@
|
||||
"label": "Animate weather icons"
|
||||
},
|
||||
"showHumidity": {
|
||||
"label": "Show humidity"
|
||||
"label": "Show humidity",
|
||||
"description": "Display humidity in current conditions, hourly details and daily forecasts"
|
||||
},
|
||||
"showCurrentWindSpeed": {
|
||||
"label": "Show current wind speed"
|
||||
"label": "Show current wind speed",
|
||||
"description": "Only on current weather"
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": "Use mph for windspeed"
|
||||
@@ -5183,25 +5130,6 @@
|
||||
"addContent": "Add board content",
|
||||
"settings": "Board settings",
|
||||
"switch": "Switch board",
|
||||
"switcher": {
|
||||
"title": "Switch board",
|
||||
"search": {
|
||||
"empty": "No boards match \"{search}\"."
|
||||
},
|
||||
"results": "Available boards",
|
||||
"preview": "Preview of {name} with {count} items",
|
||||
"currentHidden": "Current board: {name}",
|
||||
"keyboard": {
|
||||
"navigate": "Navigate",
|
||||
"open": "Open",
|
||||
"instructions": "Type to filter boards. Use the arrow keys to move through results and Enter to open a board."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No other boards",
|
||||
"description": "There are no other boards you can access."
|
||||
},
|
||||
"error": "Boards could not be loaded."
|
||||
},
|
||||
"create": {
|
||||
"availability": {
|
||||
"checking": "Checking availability…",
|
||||
@@ -7771,8 +7699,6 @@
|
||||
"ready": "Widget configuration ready",
|
||||
"cancelled": "Widget was not added",
|
||||
"automaticContinue": "Accepting the proposed widget configuration automatically",
|
||||
"definitionLoadErrorTitle": "Widget settings could not be loaded",
|
||||
"definitionLoadErrorDescription": "Homarr could not load this widget's settings. Try again.",
|
||||
"loadErrorTitle": "Integrations could not be loaded",
|
||||
"loadErrorDescription": "Homarr could not load the integrations available for this widget.",
|
||||
"noIntegrationTitle": "A compatible integration is required",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MantineProviderProps } from "@mantine/core";
|
||||
|
||||
import { theme } from "./theme";
|
||||
|
||||
export { scrollAreaDefaultProps, theme } from "./theme";
|
||||
export { theme } from "./theme";
|
||||
export { modalSizeForm, modalSizeSelect } from "./theme/modal";
|
||||
export * from "./components";
|
||||
|
||||
|
||||
@@ -2,13 +2,6 @@ import { createTheme, rem } from "@mantine/core";
|
||||
|
||||
import { modalComponent } from "./theme/modal";
|
||||
|
||||
export const scrollAreaDefaultProps = {
|
||||
type: "hover",
|
||||
scrollbarSize: 2,
|
||||
scrollHideDelay: 500,
|
||||
offsetScrollbars: true,
|
||||
} as const;
|
||||
|
||||
export const theme = createTheme({
|
||||
primaryColor: "red",
|
||||
autoContrast: true,
|
||||
@@ -32,7 +25,6 @@ export const theme = createTheme({
|
||||
},
|
||||
|
||||
fontSizes: {
|
||||
xxs: rem(11),
|
||||
xs: rem(12),
|
||||
sm: rem(14),
|
||||
md: rem(16),
|
||||
@@ -103,12 +95,6 @@ export const theme = createTheme({
|
||||
style: { borderRadius: 5 },
|
||||
},
|
||||
},
|
||||
ScrollArea: {
|
||||
defaultProps: scrollAreaDefaultProps,
|
||||
},
|
||||
ScrollAreaAutosize: {
|
||||
defaultProps: scrollAreaDefaultProps,
|
||||
},
|
||||
LoadingOverlay: {
|
||||
defaultProps: {
|
||||
zIndex: 1000,
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
"./widget-integration-select": "./src/widget-integration-select.tsx",
|
||||
"./modals": "./src/modals/index.ts",
|
||||
"./prefetch": "./src/prefetch.ts",
|
||||
"./refetch-intervals": "./src/refetch-intervals.ts",
|
||||
"./widget-card-shell": "./src/widget-card-shell.tsx"
|
||||
"./refetch-intervals": "./src/refetch-intervals.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
||||
boardId?: string;
|
||||
}
|
||||
|
||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder" | "remove") => string;
|
||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
|
||||
|
||||
/**
|
||||
* Short description why as and unknown convertions are used below:
|
||||
|
||||
@@ -324,7 +324,7 @@ export function JsonPathTreePicker({
|
||||
</Combobox.Header>
|
||||
)}
|
||||
<Combobox.Options>
|
||||
<ScrollArea.Autosize mah={420}>
|
||||
<ScrollArea.Autosize mah={420} type="scroll">
|
||||
{!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>}
|
||||
{hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>}
|
||||
{visibleNodes.map((node) => (
|
||||
|
||||
@@ -96,28 +96,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
|
||||
};
|
||||
|
||||
const migrateItems = useCallback(
|
||||
(items: TItem[], optionsPatch: Record<string, unknown>) => {
|
||||
const migratedEntries = items.map((item) => [options.uniqueIdentifier(item) as TOptionValue, item] as const);
|
||||
setTempMap((previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const [value, item] of migratedEntries) next.set(value, item);
|
||||
return next;
|
||||
});
|
||||
form.setFieldValue("options", (currentOptions) => {
|
||||
const currentItems = Array.isArray(currentOptions[property])
|
||||
? (currentOptions[property] as TOptionValue[])
|
||||
: options.defaultValue;
|
||||
const nextItems = [...currentItems];
|
||||
for (const [value] of migratedEntries) {
|
||||
if (!nextItems.includes(value)) nextItems.push(value);
|
||||
}
|
||||
return { ...currentOptions, ...optionsPatch, [property]: nextItems };
|
||||
});
|
||||
},
|
||||
[form, options, property],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(value: TOptionValue) => {
|
||||
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
|
||||
@@ -134,13 +112,7 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
return (
|
||||
<Fieldset legend={t("label")}>
|
||||
<Stack>
|
||||
<options.addButton
|
||||
addItem={addItem}
|
||||
migrateItems={migrateItems}
|
||||
removeItem={removeItem}
|
||||
values={values}
|
||||
initialOptions={initialOptions}
|
||||
/>
|
||||
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -181,7 +153,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
index={index}
|
||||
item={item}
|
||||
removeItem={() => removeItem(value)}
|
||||
removeLabel={t("remove")}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
@@ -206,7 +177,6 @@ interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
|
||||
item: TItem;
|
||||
index: number;
|
||||
removeItem: () => void;
|
||||
removeLabel: string;
|
||||
options: CommonWidgetInputProps<"sortableItemList">["options"];
|
||||
}
|
||||
|
||||
@@ -215,7 +185,6 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
||||
index,
|
||||
item,
|
||||
removeItem,
|
||||
removeLabel,
|
||||
options,
|
||||
}: ItemProps<TItem, TOptionValue>) => {
|
||||
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
|
||||
@@ -251,7 +220,6 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
||||
key={index}
|
||||
item={item}
|
||||
removeItem={removeItem}
|
||||
removeLabel={removeLabel}
|
||||
rootAttributes={attributes}
|
||||
handle={
|
||||
<ActionIcon
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
|
||||
import {
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./display";
|
||||
|
||||
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
|
||||
const t = useI18n("widget.airQuality");
|
||||
const query = clientApi.widget.airQuality.atLocation.useQuery({
|
||||
latitude: options.location.latitude,
|
||||
longitude: options.location.longitude,
|
||||
@@ -44,6 +45,11 @@ export default function AirQualityWidget({ options, width, height, displayMode }
|
||||
) : (
|
||||
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
|
||||
)}
|
||||
{query.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={query.error} label={t("name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -171,7 +177,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" w="100%">
|
||||
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
|
||||
<Stack gap="md" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { storedContentToPlainText } from "./content";
|
||||
|
||||
@@ -238,6 +239,7 @@ const AnchorNoteWidgetContent = ({
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<WidgetQueryErrorIndicator error={noteQuery.error} label={t("name")} />
|
||||
{(isEditing || canEdit) && (
|
||||
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
|
||||
{isEditing ? (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./app.module.css";
|
||||
import { PingDot } from "./ping/ping-dot";
|
||||
@@ -24,6 +24,7 @@ import { PingIndicator } from "./ping/ping-indicator";
|
||||
|
||||
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
|
||||
const tCommon = useI18n("common");
|
||||
const tApp = useI18n("widget.app");
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
||||
@@ -126,6 +127,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
</Suspense>
|
||||
) : null}
|
||||
</AppLink>
|
||||
{appQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={appQuery.error} label={tApp("name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getIconUrl } from "@homarr/definitions";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
|
||||
export default function ArchiveTeamWarriorWidget({
|
||||
integrationIds,
|
||||
@@ -49,7 +50,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
const t = useI18n("widget.archiveTeamWarrior");
|
||||
const tWidgetCommon = useI18n("widget.common");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
|
||||
const [data, statusQuery] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
|
||||
|
||||
const status = data.status;
|
||||
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
|
||||
@@ -73,6 +74,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Group>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<WidgetQueryErrorIndicator error={statusQuery.error} label={t("name")} />
|
||||
<Badge size="sm" color={getStatusColor(statusKey)}>
|
||||
{t(`status.${statusKey}`)}
|
||||
</Badge>
|
||||
@@ -80,7 +82,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Group>
|
||||
|
||||
{(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && (
|
||||
<Card withBorder p="xs" bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" lineClamp={isAdvanced ? undefined : 3}>
|
||||
{status.broadcastMessage}
|
||||
</Text>
|
||||
@@ -141,13 +143,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
{status.items.map((item) => {
|
||||
const itemStatusKey = getStatusKey(item.status);
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
withBorder
|
||||
p="xs"
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralSurfaceBorder }}
|
||||
>
|
||||
<Card key={item.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
@@ -205,9 +201,6 @@ export const getArchiveCompactLayout = (width: number, height: number, isAdvance
|
||||
|
||||
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
|
||||
|
||||
const neutralSurfaceBorder =
|
||||
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
|
||||
|
||||
const getStatusKey = (status?: string | null): WarriorStatusKey => {
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function AssistantWidget(props: WidgetComponentProps<"assistant">
|
||||
return (
|
||||
<Center h="100%" p="md">
|
||||
<Stack align="center" gap="xs" ta="center">
|
||||
<ThemeIcon variant="light" radius="xl" size="lg">
|
||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg">
|
||||
<IconRobot size="var(--mantine-font-size-xl)" />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
gap: clamp(2px, 1.5cqi, 4px);
|
||||
padding: clamp(4px, 2cqi, 8px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: transparent;
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { getQueryKey } from "@trpc/react-query";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatBytes, formatDuration } from "@homarr/common";
|
||||
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { AudioStatsContent } from "./audio-stats-content";
|
||||
@@ -21,6 +23,8 @@ export default function AudioStatsWidget({
|
||||
displayMode = "compact",
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"audioStats">) {
|
||||
const tAudio = useI18n("widget.audioStats");
|
||||
const tMediaServer = useI18n("widget.mediaServer");
|
||||
const statsInput = { integrationId: integrationIds[0] ?? "" };
|
||||
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
|
||||
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
|
||||
@@ -52,6 +56,9 @@ export default function AudioStatsWidget({
|
||||
if (displayMode === "compact") {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
|
||||
</Box>
|
||||
{summary}
|
||||
</Box>
|
||||
);
|
||||
@@ -66,8 +73,10 @@ export default function AudioStatsWidget({
|
||||
return (
|
||||
<Stack h="100%" gap="lg" p="md">
|
||||
<div style={{ minHeight: 150 }}>{summary}</div>
|
||||
{hasStreamErrors && (
|
||||
{(statsError || streamsError || hasStreamErrors) && (
|
||||
<Group justify="flex-end">
|
||||
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
|
||||
<WidgetQueryErrorIndicator error={streamsError} label={tMediaServer("name")} />
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
gap: clamp(2px, 1.5cqi, 5px);
|
||||
padding: clamp(4px, 2.5cqi, 10px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: transparent;
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { getBeszelAlertsQueryInput } from "./display";
|
||||
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
|
||||
@@ -125,6 +126,7 @@ export default function BeszelAlertsWidget({
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<Group gap={0}>
|
||||
<WidgetQueryErrorIndicator error={alertsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
import { isBeszelGridMetricVisible } from "./display";
|
||||
@@ -459,6 +460,7 @@ export default function BeszelSystemGridWidget({
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||
const t = useI18n("widget.beszel");
|
||||
const tFeature = useI18n("widget.beszelSystemGrid");
|
||||
const tCommon = useI18n("common");
|
||||
const board = useRequiredBoard();
|
||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||
@@ -483,6 +485,7 @@ export default function BeszelSystemGridWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
<Center h="100%">
|
||||
@@ -511,6 +514,7 @@ export default function BeszelSystemGridWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
<Box
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
@@ -143,6 +144,7 @@ export default function BeszelSystemStatsWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
</Group>
|
||||
<Center h="100%">
|
||||
@@ -182,6 +184,7 @@ export default function BeszelSystemStatsWidget({
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<Group gap={0}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
import { getBeszelTableVisibleMetricKeys } from "./display";
|
||||
@@ -452,6 +453,7 @@ export default function BeszelSystemTableWidget({
|
||||
<div style={{ position: "relative", height: "100%" }}>
|
||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||
<Group gap={0}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
@@ -63,11 +63,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
|
||||
}}
|
||||
>
|
||||
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
|
||||
<Progress
|
||||
value={system.disk}
|
||||
color={thresholdColor(system.disk)}
|
||||
size={trackSize}
|
||||
/>
|
||||
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
|
||||
<Group
|
||||
gap={dotGap}
|
||||
wrap="nowrap"
|
||||
@@ -105,12 +101,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
|
||||
<Text size="sm" fw={600} miw={48} lh={1.25}>
|
||||
{formatPercent(value)}
|
||||
</Text>
|
||||
<Progress
|
||||
value={value}
|
||||
color={thresholdColor(value)}
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
@@ -120,11 +111,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
|
||||
) : (
|
||||
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
|
||||
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
|
||||
<Progress
|
||||
value={system.disk}
|
||||
color={thresholdColor(system.disk)}
|
||||
size={trackSize}
|
||||
/>
|
||||
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,198 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Combobox,
|
||||
Group,
|
||||
Loader,
|
||||
PillsInput,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { IconCheck, IconLink, IconPlus } from "@tabler/icons-react";
|
||||
import type { MultiSelectProps } from "@mantine/core";
|
||||
import { Group, Loader, MultiSelect } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { SortableItemListInput } from "../options";
|
||||
import { createDirectBookmark, getBookmarkFaviconUrl, splitBookmarkUrls } from "./bookmark-item";
|
||||
import type { BookmarkItem } from "./bookmark-item";
|
||||
|
||||
type SelectableApp = RouterOutputs["app"]["selectable"][number];
|
||||
export type BookmarkSelectionItem = SelectableApp | BookmarkItem;
|
||||
|
||||
const createOptionValue = "$create-url";
|
||||
|
||||
export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, string>["AddButton"] = ({
|
||||
export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["AddButton"] = ({
|
||||
addItem,
|
||||
migrateItems,
|
||||
removeItem,
|
||||
values,
|
||||
initialOptions,
|
||||
}) => {
|
||||
const t = useI18n("widget.bookmarks.option.items.apps");
|
||||
const tCommon = useI18n("common");
|
||||
const tSelect = useI18n("app.action.select");
|
||||
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
|
||||
const [search, setSearch] = useState("");
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
const selectedValues = useMemo(() => new Set(values), [values]);
|
||||
const migrationStartedRef = useRef(false);
|
||||
const legacyUrls = useMemo(() => {
|
||||
if (!Array.isArray(initialOptions.customUrls)) return [];
|
||||
return initialOptions.customUrls.filter((value): value is string => typeof value === "string");
|
||||
}, [initialOptions.customUrls]);
|
||||
const appsById = new Map(apps.map((app) => [app.id, app]));
|
||||
|
||||
useEffect(() => {
|
||||
if (migrationStartedRef.current || legacyUrls.length === 0) return;
|
||||
|
||||
migrationStartedRef.current = true;
|
||||
const legacyItems = legacyUrls.flatMap((url) => createDirectBookmark(url) ?? []);
|
||||
migrateItems(legacyItems, { customUrls: [] });
|
||||
}, [legacyUrls, migrateItems]);
|
||||
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
const filteredApps = apps.filter((app) => {
|
||||
if (selectedValues.has(app.id)) return false;
|
||||
if (normalizedSearch.length === 0) return true;
|
||||
|
||||
return app.name.toLowerCase().includes(normalizedSearch) || app.href?.toLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
const pendingUrl = createDirectBookmark(search);
|
||||
|
||||
const addUrls = (input: string) => {
|
||||
const nextValues = new Set(values);
|
||||
for (const value of splitBookmarkUrls(input)) {
|
||||
const item = createDirectBookmark(value);
|
||||
if (!item || nextValues.has(item.id)) continue;
|
||||
addItem(item);
|
||||
nextValues.add(item.id);
|
||||
}
|
||||
setSearch("");
|
||||
combobox.closeDropdown();
|
||||
};
|
||||
|
||||
const handleOptionSubmit = (value: string) => {
|
||||
if (value === createOptionValue) {
|
||||
addUrls(search);
|
||||
return;
|
||||
const handleChange = (nextValues: string[]) => {
|
||||
const nextIds = new Set(nextValues);
|
||||
for (const value of values) {
|
||||
if (!nextIds.has(value)) removeItem(value);
|
||||
}
|
||||
|
||||
const app = apps.find((candidate) => candidate.id === value);
|
||||
if (app) addItem(app);
|
||||
setSearch("");
|
||||
const currentIds = new Set(values);
|
||||
for (const value of nextValues) {
|
||||
if (currentIds.has(value)) continue;
|
||||
const app = appsById.get(value);
|
||||
if (app) addItem(app);
|
||||
}
|
||||
};
|
||||
|
||||
const options = filteredApps.map((app) => (
|
||||
<Combobox.Option value={app.id} key={app.id}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar src={app.iconUrl} name={app.name} color="gray" size={28} radius="sm">
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<Stack gap={0} miw={0} flex={1}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{app.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{app.href}
|
||||
</Text>
|
||||
</Stack>
|
||||
<IconPlus size={16} aria-hidden />
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
onOptionSubmit={handleOptionSubmit}
|
||||
position="bottom-start"
|
||||
middlewares={{ flip: true, shift: true }}
|
||||
styles={{
|
||||
dropdown: { overflow: "hidden" },
|
||||
option: { borderRadius: "var(--mantine-radius-sm)" },
|
||||
}}
|
||||
>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
label={t("label")}
|
||||
description={t("description")}
|
||||
error={error ? tCommon("error") : undefined}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
leftSection={
|
||||
<ThemeIcon variant="light" size="sm" radius="xl">
|
||||
<IconLink size={14} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
rightSection={isPending ? <Loader size="xs" /> : undefined}
|
||||
styles={{ input: { minHeight: 44 } }}
|
||||
>
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
value={search}
|
||||
placeholder={t("placeholder")}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => combobox.closeDropdown()}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const pastedValue = event.clipboardData.getData("text");
|
||||
if (splitBookmarkUrls(pastedValue).length < 2) return;
|
||||
event.preventDefault();
|
||||
addUrls(pastedValue);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
if (!pendingUrl) return;
|
||||
event.preventDefault();
|
||||
addUrls(search);
|
||||
}}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
</PillsInput>
|
||||
</Combobox.DropdownTarget>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<ScrollArea.Autosize mah={280}>
|
||||
<Combobox.Options>
|
||||
{pendingUrl && !selectedValues.has(pendingUrl.id) ? (
|
||||
<Combobox.Option value={createOptionValue}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar src={getBookmarkFaviconUrl(pendingUrl.href)} size={28} radius="sm" color="gray">
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<Stack gap={0} miw={0} flex={1}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{t("addUrl")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{pendingUrl.href}
|
||||
</Text>
|
||||
</Stack>
|
||||
<IconCheck size={16} aria-hidden />
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
) : null}
|
||||
{options}
|
||||
{!pendingUrl && options.length === 0 ? <Combobox.Empty>{tSelect("notFound")}</Combobox.Empty> : null}
|
||||
</Combobox.Options>
|
||||
</ScrollArea.Autosize>
|
||||
<Combobox.Footer>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("hint")}
|
||||
</Text>
|
||||
</Combobox.Footer>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
<MultiSelect
|
||||
label={t("label")}
|
||||
placeholder={t("placeholder")}
|
||||
value={values}
|
||||
onChange={handleChange}
|
||||
searchable
|
||||
clearable
|
||||
clearSearchOnChange
|
||||
hidePickedOptions
|
||||
disabled={isPending || Boolean(error)}
|
||||
error={error ? tCommon("error") : undefined}
|
||||
rightSection={isPending ? <Loader size="xs" /> : undefined}
|
||||
nothingFoundMessage={tSelect("notFound")}
|
||||
renderOption={renderAppOption}
|
||||
data={apps.map((app) => ({
|
||||
value: app.id,
|
||||
label: app.name,
|
||||
iconUrl: app.iconUrl,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const iconProps = {
|
||||
stroke: 1.5,
|
||||
color: "currentColor",
|
||||
opacity: 0.6,
|
||||
size: 18,
|
||||
};
|
||||
|
||||
const renderAppOption: MultiSelectProps["renderOption"] = ({ option, checked }) => (
|
||||
<Group flex="1" gap="xs">
|
||||
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
|
||||
<img width={20} height={20} src={option.iconUrl} alt="" />
|
||||
) : null}
|
||||
{option.label}
|
||||
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
|
||||
</Group>
|
||||
);
|
||||
@@ -1,66 +0,0 @@
|
||||
import { getSafeApplicationUrl } from "../common/application-url";
|
||||
|
||||
export const directBookmarkPrefix = "url:";
|
||||
|
||||
export interface BookmarkItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
iconUrl?: string;
|
||||
href: string | null;
|
||||
}
|
||||
|
||||
export const normalizeBookmarkUrl = (value: string): string | undefined => {
|
||||
const trimmedValue = value.trim();
|
||||
if (trimmedValue.length === 0) return undefined;
|
||||
|
||||
const directUrl = getSafeApplicationUrl(trimmedValue);
|
||||
if (directUrl) return directUrl;
|
||||
|
||||
if (/^(?:localhost(?::\d+)?|(?:[\w-]+\.)+[\w-]+(?::\d+)?)(?:[/?#].*)?$/i.test(trimmedValue)) {
|
||||
return getSafeApplicationUrl(`https://${trimmedValue}`);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getDirectBookmarkValue = (url: string) => `${directBookmarkPrefix}${url}`;
|
||||
|
||||
export const getDirectBookmarkUrl = (value: string): string | undefined => {
|
||||
if (!value.startsWith(directBookmarkPrefix)) return undefined;
|
||||
return normalizeBookmarkUrl(value.slice(directBookmarkPrefix.length));
|
||||
};
|
||||
|
||||
export const getBookmarkFaviconUrl = (href: string | null): string | undefined => {
|
||||
const safeUrl = getSafeApplicationUrl(href);
|
||||
if (!safeUrl) return undefined;
|
||||
|
||||
const url = new URL(safeUrl);
|
||||
return `${url.origin}/favicon.ico`;
|
||||
};
|
||||
|
||||
export const createDirectBookmark = (url: string): BookmarkItem | undefined => {
|
||||
const href = normalizeBookmarkUrl(url);
|
||||
if (!href) return undefined;
|
||||
|
||||
const parsedUrl = new URL(href);
|
||||
return {
|
||||
id: getDirectBookmarkValue(href),
|
||||
name: parsedUrl.hostname,
|
||||
description: null,
|
||||
href,
|
||||
};
|
||||
};
|
||||
|
||||
export const splitBookmarkUrls = (value: string) =>
|
||||
value.split(/\r?\n+/).flatMap((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.length === 0) return [];
|
||||
|
||||
const commaSeparatedValues = trimmedLine.split(",").map((part) => part.trim());
|
||||
if (commaSeparatedValues.length > 1 && commaSeparatedValues.every((part) => normalizeBookmarkUrl(part))) {
|
||||
return commaSeparatedValues;
|
||||
}
|
||||
|
||||
return [trimmedLine];
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
.card:hover {
|
||||
background-color: var(--mantine-color-primaryColor-light-hover);
|
||||
}
|
||||
|
||||
.bookmarkButton:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="light"] .card-grid {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .card-grid {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
|
||||
.card:hover > div > div.bookmarkIcon {
|
||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
||||
}
|
||||
|
||||
.bookmarkButton:focus-visible .bookmarkIcon {
|
||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
||||
}
|
||||
|
||||
.compactScrollViewport > div {
|
||||
display: flex !important;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.compactScrollContent {
|
||||
flex: 1;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useReducedMotion } from "@mantine/hooks";
|
||||
import { IconArrowUpRight, IconBookmark, IconLink } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
||||
import type { BookmarkItem } from "./bookmark-item";
|
||||
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
|
||||
import type { BookmarkOrientation } from "./layout";
|
||||
|
||||
type BookmarkVariant = WidgetComponentProps<"bookmarks">["options"]["variant"];
|
||||
|
||||
export default function BookmarksWidget({
|
||||
options,
|
||||
itemId,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bookmarks">) {
|
||||
const t = useI18n("widget.bookmarks");
|
||||
const board = useRequiredBoard();
|
||||
const advanced = displayMode === "advanced";
|
||||
const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]);
|
||||
const appsQuery = clientApi.app.byIds.useQuery(appIds, {
|
||||
select(selectedApps) {
|
||||
return selectedApps;
|
||||
},
|
||||
});
|
||||
const apps = getUsableWidgetQueryData(appsQuery) ?? [];
|
||||
const appsById = new Map(apps.map((app) => [app.id, app]));
|
||||
const configuredItems = options.items.flatMap((value) => {
|
||||
const directUrl = getDirectBookmarkUrl(value);
|
||||
if (directUrl) return createDirectBookmark(directUrl) ?? [];
|
||||
return appsById.get(value) ?? [];
|
||||
});
|
||||
const configuredHrefs = new Set(configuredItems.map((item) => item.href));
|
||||
const legacyItems = options.customUrls.flatMap((url) => {
|
||||
const item = createDirectBookmark(url);
|
||||
if (!item || configuredHrefs.has(item.href)) return [];
|
||||
return [item];
|
||||
});
|
||||
const data = [...configuredItems, ...legacyItems];
|
||||
|
||||
useRegisterSpotlightContextResults(
|
||||
`bookmark-${itemId}`,
|
||||
data.flatMap((bookmark) => {
|
||||
const href = getSafeAppHref(bookmark.href);
|
||||
if (!href) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: bookmark.id,
|
||||
name: bookmark.name,
|
||||
icon: bookmark.iconUrl ?? IconLink,
|
||||
interaction() {
|
||||
return {
|
||||
type: "link" as const,
|
||||
href,
|
||||
newTab: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
const plan = useMemo(
|
||||
() =>
|
||||
getBookmarkDisplayPlan({
|
||||
advanced,
|
||||
height: options.title.length > 0 ? height - 36 : height,
|
||||
itemCount: data.length,
|
||||
layout: options.layout,
|
||||
width,
|
||||
}),
|
||||
[advanced, data.length, height, options.layout, options.title.length, width],
|
||||
);
|
||||
|
||||
const cardDisplay = getBookmarkCardDisplay({
|
||||
advanced,
|
||||
hideHostname: options.hideHostname,
|
||||
hideIcon: options.hideIcon,
|
||||
hideTitle: options.hideTitle,
|
||||
plan,
|
||||
});
|
||||
|
||||
const cards = data.map((bookmark) => (
|
||||
<BookmarkCard
|
||||
key={bookmark.id}
|
||||
bookmark={bookmark}
|
||||
advanced={advanced}
|
||||
orientation={cardDisplay.orientation}
|
||||
showHostname={cardDisplay.showHostname}
|
||||
showIcon={cardDisplay.showIcon}
|
||||
showTitle={cardDisplay.showTitle}
|
||||
openNewTab={options.openNewTab}
|
||||
variant={options.variant}
|
||||
withBorder={options.withBorder}
|
||||
radius={board.itemRadius}
|
||||
height={plan.itemHeight}
|
||||
width={plan.horizontalScroll ? plan.itemWidth : undefined}
|
||||
/>
|
||||
));
|
||||
|
||||
if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return <WidgetQueryLoadingState />;
|
||||
|
||||
return (
|
||||
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
|
||||
{options.title.length > 0 ? (
|
||||
<Text fz={11} fw={600} px={2} lh={1.2} lineClamp={1}>
|
||||
{options.title}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{data.length === 0 ? (
|
||||
<Center flex={1}>
|
||||
<Stack align="center" gap={6}>
|
||||
<ThemeIcon variant="light" size="lg" radius="xl">
|
||||
<IconBookmark size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("empty")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollArea
|
||||
scrollbars={plan.horizontalScroll ? "x" : "y"}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<Box miw="100%" mih="100%" pb={2}>
|
||||
{plan.horizontalScroll ? (
|
||||
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">
|
||||
{cards}
|
||||
</Group>
|
||||
) : (
|
||||
<SimpleGrid cols={plan.columns} spacing={options.spacing} verticalSpacing={options.spacing}>
|
||||
{cards}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface BookmarkCardProps {
|
||||
advanced: boolean;
|
||||
bookmark: BookmarkItem;
|
||||
height: number;
|
||||
openNewTab: boolean;
|
||||
orientation: BookmarkOrientation;
|
||||
radius: string;
|
||||
showHostname: boolean;
|
||||
showIcon: boolean;
|
||||
showTitle: boolean;
|
||||
variant: BookmarkVariant;
|
||||
width?: number;
|
||||
withBorder: boolean;
|
||||
}
|
||||
|
||||
const BookmarkCard = ({
|
||||
advanced,
|
||||
bookmark,
|
||||
height,
|
||||
openNewTab,
|
||||
orientation,
|
||||
radius,
|
||||
showHostname,
|
||||
showIcon,
|
||||
showTitle,
|
||||
variant,
|
||||
width,
|
||||
withBorder,
|
||||
}: BookmarkCardProps) => {
|
||||
const [active, setActive] = useState(false);
|
||||
const reduceMotion = useReducedMotion();
|
||||
const href = getSafeAppHref(bookmark.href);
|
||||
const hostname = bookmark.href ? getBookmarkHostname(bookmark.href) : undefined;
|
||||
const iconUrl = bookmark.iconUrl ?? getBookmarkFaviconUrl(bookmark.href);
|
||||
const iconOnly = orientation === "icon" || (!showTitle && !showHostname);
|
||||
const background = getBookmarkBackground(variant, active);
|
||||
|
||||
const content =
|
||||
orientation === "vertical" && !advanced ? (
|
||||
<Stack h="100%" align="center" justify="center" gap={6} flex={1}>
|
||||
{showIcon ? <BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={iconOnly ? 34 : 38} /> : null}
|
||||
{showTitle ? (
|
||||
<Text size="xxs" fw={600} lh={1.2} ta="center" lineClamp={2}>
|
||||
{bookmark.name}
|
||||
</Text>
|
||||
) : null}
|
||||
{showHostname ? (
|
||||
<Text fz={9} c="dimmed" ta="center" truncate w="100%">
|
||||
{hostname}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Group
|
||||
h="100%"
|
||||
gap={advanced ? "sm" : "xs"}
|
||||
wrap="nowrap"
|
||||
justify={iconOnly ? "center" : "flex-start"}
|
||||
align="center"
|
||||
flex={1}
|
||||
>
|
||||
{showIcon ? (
|
||||
<BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={advanced ? 42 : iconOnly ? 32 : 30} />
|
||||
) : null}
|
||||
{!iconOnly ? (
|
||||
<Stack gap={advanced ? 3 : 0} miw={0} flex={1}>
|
||||
{showTitle ? (
|
||||
<Text size={advanced ? "xs" : "xxs"} fw={600} lh={1.2} truncate>
|
||||
{bookmark.name}
|
||||
</Text>
|
||||
) : null}
|
||||
{showHostname ? (
|
||||
<Text fz={advanced ? 10 : 9} c="dimmed" truncate>
|
||||
{hostname}
|
||||
</Text>
|
||||
) : null}
|
||||
{advanced && bookmark.description ? (
|
||||
<Text fz={10} c="dimmed" lineClamp={2}>
|
||||
{bookmark.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
{advanced ? (
|
||||
<ThemeIcon
|
||||
variant={active ? "light" : "transparent"}
|
||||
color={active ? "primaryColor" : "gray"}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
style={{
|
||||
opacity: active ? 1 : 0.45,
|
||||
transform: active && !reduceMotion ? "translate(1px, -1px)" : undefined,
|
||||
transition: reduceMotion ? undefined : "opacity 120ms ease, transform 120ms ease",
|
||||
}}
|
||||
>
|
||||
<IconArrowUpRight size={14} />
|
||||
</ThemeIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" fw={600}>
|
||||
{bookmark.name}
|
||||
</Text>
|
||||
{hostname ? (
|
||||
<Text size="xs" opacity={0.8}>
|
||||
{hostname}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
}
|
||||
openDelay={450}
|
||||
disabled={advanced}
|
||||
withArrow
|
||||
>
|
||||
<Card
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
aria-label={bookmark.name}
|
||||
radius={radius}
|
||||
withBorder={withBorder || variant === "outline"}
|
||||
p={iconOnly ? 6 : advanced ? "md" : "xs"}
|
||||
h={height}
|
||||
w={width}
|
||||
miw={width}
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
onFocus={() => setActive(true)}
|
||||
onBlur={() => setActive(false)}
|
||||
styles={{
|
||||
root: {
|
||||
background,
|
||||
borderColor: active
|
||||
? "rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.62))"
|
||||
: "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38))",
|
||||
boxShadow: active ? "var(--mantine-shadow-sm)" : undefined,
|
||||
color: "var(--mantine-color-text)",
|
||||
cursor: href ? "pointer" : "default",
|
||||
display: "flex",
|
||||
outline: active ? "2px solid var(--mantine-primary-color-light)" : undefined,
|
||||
outlineOffset: -2,
|
||||
overflow: "hidden",
|
||||
textDecoration: "none",
|
||||
transform: active && !reduceMotion ? "translateY(-1px)" : undefined,
|
||||
transition: reduceMotion
|
||||
? undefined
|
||||
: "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const BookmarkAvatar = ({ bookmark, iconUrl, size }: { bookmark: BookmarkItem; iconUrl?: string; size: number }) => (
|
||||
<Avatar
|
||||
src={iconUrl}
|
||||
name={bookmark.name}
|
||||
color="gray"
|
||||
radius="sm"
|
||||
size={size}
|
||||
imageProps={{ referrerPolicy: "no-referrer" }}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
>
|
||||
<IconLink size={Math.max(14, size / 2)} />
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
const getBookmarkBackground = (variant: BookmarkVariant, active: boolean): string => {
|
||||
if (variant === "plain" || variant === "outline") return "transparent";
|
||||
if (variant === "filled") {
|
||||
const opacity = active ? 0.64 : 0.48;
|
||||
return `rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * ${opacity}))`;
|
||||
}
|
||||
|
||||
const opacity = active ? 0.14 : 0.08;
|
||||
return `rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * ${opacity}))`;
|
||||
};
|
||||
|
||||
const getBookmarkHostname = (href: string): string | undefined => {
|
||||
try {
|
||||
return new URL(href).hostname || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,517 @@
|
||||
"use client";
|
||||
|
||||
export { default } from "./bookmarks-widget";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconLink } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import classes from "./bookmark.module.css";
|
||||
|
||||
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
||||
|
||||
export interface BookmarkDisplayItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
iconUrl?: string;
|
||||
href: string | null;
|
||||
}
|
||||
|
||||
export interface CompactBookmarkLayout {
|
||||
columns: number;
|
||||
hideHostname: boolean;
|
||||
hideTitle: boolean;
|
||||
minimumItemWidth: number;
|
||||
minimumItemSize: number;
|
||||
}
|
||||
|
||||
export function getCompactBookmarkLayout(
|
||||
width: number,
|
||||
height: number,
|
||||
itemCount: number,
|
||||
layout: BookmarkLayout,
|
||||
): CompactBookmarkLayout {
|
||||
const count = Math.max(1, itemCount);
|
||||
const isGrid = layout === "grid" || layout === "gridHorizontal";
|
||||
const targetWidth = layout === "gridHorizontal" ? 180 : 104;
|
||||
const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1;
|
||||
const rows = Math.max(1, Math.ceil(count / columns));
|
||||
const cellWidth = width / columns;
|
||||
const cellHeight = height / rows;
|
||||
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
|
||||
|
||||
return {
|
||||
columns,
|
||||
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
|
||||
hideTitle: cellWidth < 64 || cellHeight < 40,
|
||||
minimumItemWidth: targetWidth,
|
||||
minimumItemSize: usesHorizontalItems ? 48 : 96,
|
||||
};
|
||||
}
|
||||
|
||||
export default function BookmarksWidget({
|
||||
options,
|
||||
itemId,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bookmarks">) {
|
||||
const board = useRequiredBoard();
|
||||
const apps =
|
||||
getUsableWidgetQueryData(
|
||||
clientApi.app.byIds.useQuery(options.items, {
|
||||
select(selectedApps) {
|
||||
return selectedApps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
}),
|
||||
) ?? [];
|
||||
const customLinks = useMemo(
|
||||
() => options.customUrls.flatMap((url) => createCustomBookmark(url) ?? []),
|
||||
[options.customUrls],
|
||||
);
|
||||
const data: BookmarkDisplayItem[] = [...apps, ...customLinks];
|
||||
|
||||
useRegisterSpotlightContextResults(
|
||||
`bookmark-${itemId}`,
|
||||
data.flatMap((app) => {
|
||||
const href = getSafeAppHref(app.href);
|
||||
return href
|
||||
? [
|
||||
{
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
icon: app.iconUrl ?? IconLink,
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
href,
|
||||
newTab: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
const compactLayout = useMemo(
|
||||
() => getCompactBookmarkLayout(width, height, data.length, options.layout),
|
||||
[width, height, data.length, options.layout],
|
||||
);
|
||||
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
|
||||
const compactHideHostname =
|
||||
options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon));
|
||||
|
||||
return (
|
||||
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
|
||||
{options.title.length > 0 && (
|
||||
<Title order={4} px="0.25rem" lineClamp={displayMode === "advanced" ? undefined : 1}>
|
||||
{options.title}
|
||||
</Title>
|
||||
)}
|
||||
{displayMode === "advanced" && (
|
||||
<AdvancedBookmarksLayout
|
||||
data={data}
|
||||
width={width}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
{displayMode !== "advanced" && (
|
||||
<ScrollArea
|
||||
classNames={{ viewport: classes.compactScrollViewport }}
|
||||
type="auto"
|
||||
scrollbarSize={6}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<div className={classes.compactScrollContent}>
|
||||
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
|
||||
<GridLayout
|
||||
data={data}
|
||||
minimumItemWidth={compactLayout.minimumItemWidth}
|
||||
minimumItemHeight={compactLayout.minimumItemSize}
|
||||
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
|
||||
hideTitle={compactHideTitle}
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={compactHideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
) : (
|
||||
<FlexLayout
|
||||
data={data}
|
||||
direction={options.layout}
|
||||
minimumItemSize={compactLayout.minimumItemSize}
|
||||
hideTitle={compactHideTitle}
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={compactHideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface FlexLayoutProps {
|
||||
data: BookmarkDisplayItem[];
|
||||
direction: "row" | "column";
|
||||
hideTitle: boolean;
|
||||
hideIcon: boolean;
|
||||
hideHostname: boolean;
|
||||
openNewTab: boolean;
|
||||
withBorder: boolean;
|
||||
hasIconColor: boolean;
|
||||
minimumItemSize: number;
|
||||
}
|
||||
|
||||
const FlexLayout = ({
|
||||
data,
|
||||
direction,
|
||||
hideTitle,
|
||||
hideIcon,
|
||||
hideHostname,
|
||||
openNewTab,
|
||||
withBorder,
|
||||
hasIconColor,
|
||||
minimumItemSize,
|
||||
}: FlexLayoutProps) => {
|
||||
const board = useRequiredBoard();
|
||||
return (
|
||||
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
|
||||
{data.map((app) => {
|
||||
const href = getSafeAppHref(app.href);
|
||||
return (
|
||||
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
key={app.id}
|
||||
w="100%"
|
||||
>
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
className={classes.card}
|
||||
w="100%"
|
||||
display="flex"
|
||||
p={4}
|
||||
h="100%"
|
||||
withBorder={withBorder}
|
||||
>
|
||||
{direction === "row" ? (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
interface AdvancedBookmarksLayoutProps {
|
||||
data: BookmarkDisplayItem[];
|
||||
width: number;
|
||||
openNewTab: boolean;
|
||||
withBorder: boolean;
|
||||
hasIconColor: boolean;
|
||||
}
|
||||
|
||||
export const getAdvancedBookmarkColumns = (width: number, itemCount: number): number =>
|
||||
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
|
||||
|
||||
export const getBookmarkHostname = (href: string | null): string | undefined => {
|
||||
const safeHref = getSafeApplicationUrl(href);
|
||||
return safeHref ? new URL(safeHref).hostname : undefined;
|
||||
};
|
||||
|
||||
export const createCustomBookmark = (url: string): BookmarkDisplayItem | null => {
|
||||
const normalizedUrl = url.trim();
|
||||
const href = getSafeAppHref(normalizedUrl);
|
||||
if (!href) return null;
|
||||
|
||||
return {
|
||||
id: `custom-link:${normalizedUrl}`,
|
||||
name: getBookmarkHostname(href) ?? normalizedUrl,
|
||||
description: null,
|
||||
href,
|
||||
};
|
||||
};
|
||||
|
||||
const AdvancedBookmarksLayout = ({
|
||||
data,
|
||||
width,
|
||||
openNewTab,
|
||||
withBorder,
|
||||
hasIconColor,
|
||||
}: AdvancedBookmarksLayoutProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
|
||||
{data.map((app) => {
|
||||
const href = getSafeAppHref(app.href);
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={app.id}
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
>
|
||||
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<BookmarkIcon
|
||||
app={app}
|
||||
hasColor={hasIconColor}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
||||
/>
|
||||
<Stack gap={3} miw={0}>
|
||||
<Text fw={700} size="sm">
|
||||
{app.name}
|
||||
</Text>
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
{app.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{app.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
interface GridLayoutProps {
|
||||
data: BookmarkDisplayItem[];
|
||||
hideTitle: boolean;
|
||||
hideIcon: boolean;
|
||||
hideHostname: boolean;
|
||||
openNewTab: boolean;
|
||||
withBorder: boolean;
|
||||
itemDirection: "horizontal" | "vertical";
|
||||
hasIconColor: boolean;
|
||||
minimumItemWidth: number;
|
||||
minimumItemHeight: number;
|
||||
}
|
||||
|
||||
const GridLayout = ({
|
||||
data,
|
||||
hideTitle,
|
||||
hideIcon,
|
||||
hideHostname,
|
||||
openNewTab,
|
||||
withBorder,
|
||||
itemDirection,
|
||||
hasIconColor,
|
||||
minimumItemWidth,
|
||||
minimumItemHeight,
|
||||
}: GridLayoutProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Flex gap={4} wrap="wrap" miw="100%" mih="100%" style={{ alignContent: "stretch" }}>
|
||||
{data.map((app) => {
|
||||
const href = getSafeAppHref(app.href);
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
key={app.id}
|
||||
style={{
|
||||
flex: `1 0 calc(${minimumItemWidth}px * var(--mantine-scale, 1))`,
|
||||
minWidth: 0,
|
||||
minHeight: `calc(${minimumItemHeight}px * var(--mantine-scale, 1))`,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
h="100%"
|
||||
className={combineClasses(classes.card, classes["card-grid"])}
|
||||
radius={board.itemRadius}
|
||||
withBorder={withBorder}
|
||||
p="xs"
|
||||
>
|
||||
{itemDirection === "horizontal" ? (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const VerticalItem = ({
|
||||
app,
|
||||
hideTitle,
|
||||
hideIcon,
|
||||
hideHostname,
|
||||
hasIconColor,
|
||||
}: {
|
||||
app: BookmarkDisplayItem;
|
||||
hideTitle: boolean;
|
||||
hideIcon: boolean;
|
||||
hideHostname: boolean;
|
||||
hasIconColor: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
|
||||
{!hideTitle && (
|
||||
<Text fw={700} ta="center" size="xs" lineClamp={2}>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
{!hideIcon && (
|
||||
<BookmarkIcon
|
||||
app={app}
|
||||
hasColor={hasIconColor}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{
|
||||
width: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
|
||||
height: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
|
||||
overflow: "auto",
|
||||
flex: "unset",
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!hideHostname && (
|
||||
<Anchor ta="center" component="span" size="xs" truncate="end" w="100%">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const HorizontalItem = ({
|
||||
app,
|
||||
hideTitle,
|
||||
hideIcon,
|
||||
hideHostname,
|
||||
hasIconColor,
|
||||
}: {
|
||||
app: BookmarkDisplayItem;
|
||||
hideTitle: boolean;
|
||||
hideIcon: boolean;
|
||||
hideHostname: boolean;
|
||||
hasIconColor: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Group wrap="nowrap" gap="xs" h="100%" justify="start">
|
||||
{!hideIcon && (
|
||||
<BookmarkIcon
|
||||
app={app}
|
||||
hasColor={hasIconColor}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{
|
||||
overflow: "auto",
|
||||
width: hideHostname ? 16 : 24,
|
||||
height: hideHostname ? 16 : 24,
|
||||
flex: "unset",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!(hideTitle && hideHostname) && (
|
||||
<>
|
||||
<Stack justify="space-between" gap={0} miw={0} style={{ flex: 1 }}>
|
||||
{!hideTitle && (
|
||||
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!hideHostname && (
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface BookmarkIconProps {
|
||||
app: BookmarkDisplayItem;
|
||||
hasColor: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const BookmarkIcon = ({ app, hasColor, className, style }: BookmarkIconProps) =>
|
||||
app.iconUrl ? (
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={hasColor}
|
||||
alt={app.name}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
) : (
|
||||
<IconLink aria-label={app.name} className={className} style={style} />
|
||||
);
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconBookmark, IconLink, IconX } from "@tabler/icons-react";
|
||||
import { IconBookmark, IconX } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { getSafeAppHref } from "../common/application-url";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import { BookmarkAddButton } from "./add-button";
|
||||
import type { BookmarkSelectionItem } from "./add-button";
|
||||
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||
icon: IconBookmark,
|
||||
@@ -17,63 +18,42 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
||||
return optionsBuilder.from((factory) => ({
|
||||
title: factory.text(),
|
||||
layout: factory.select({
|
||||
options: (["adaptive", "column", "row", "grid", "gridHorizontal", "icons"] as const).map((value) => ({
|
||||
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => {
|
||||
if (value === "adaptive") return t("widget.bookmarks.option.layout.option.adaptive.label");
|
||||
if (value === "grid") return t("widget.common.layout.option.grid");
|
||||
if (value === "row") return t("widget.common.layout.option.horizontal");
|
||||
if (value === "column") return t("widget.common.layout.option.vertical");
|
||||
if (value === "icons") return t("widget.bookmarks.option.layout.option.icons.label");
|
||||
return t("widget.bookmarks.option.layout.option.gridHorizontal.label");
|
||||
},
|
||||
})),
|
||||
defaultValue: "adaptive",
|
||||
}),
|
||||
variant: factory.select({
|
||||
options: (["soft", "filled", "outline", "plain"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.bookmarks.option.variant.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "soft",
|
||||
}),
|
||||
spacing: factory.select({
|
||||
options: (["xs", "sm", "md", "lg", "xl"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.bookmarks.option.spacing.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "xs",
|
||||
defaultValue: "column",
|
||||
}),
|
||||
hideTitle: factory.switch({ defaultValue: false }),
|
||||
hideIcon: factory.switch({ defaultValue: false }),
|
||||
hideHostname: factory.switch({ defaultValue: false }),
|
||||
openNewTab: factory.switch({ defaultValue: true }),
|
||||
withBorder: factory.switch({ defaultValue: false }),
|
||||
customUrls: factory.internal({ defaultValue: [] as string[] }),
|
||||
items: factory.sortableItemList<BookmarkSelectionItem, string>({
|
||||
ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => {
|
||||
const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href);
|
||||
customUrls: factory.multiText({
|
||||
validate: z.string().refine((value) => getSafeAppHref(value) !== undefined),
|
||||
withDescription: true,
|
||||
}),
|
||||
items: factory.sortableItemList<RouterOutputs["app"]["selectable"][number], string>({
|
||||
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
|
||||
return (
|
||||
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap" miw={0}>
|
||||
<Group wrap="nowrap">
|
||||
{handle}
|
||||
|
||||
<Group wrap="nowrap" miw={0}>
|
||||
<Avatar src={iconUrl} name={item.name} color="gray" radius="sm">
|
||||
<IconLink size={20} />
|
||||
</Avatar>
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text fw={600} truncate>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{item.href}
|
||||
</Text>
|
||||
<Group>
|
||||
<Avatar src={item.iconUrl} alt={item.name} />
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ActionIcon variant="subtle" color="red" onClick={removeItem} aria-label={removeLabel}>
|
||||
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
|
||||
<IconX size="var(--mantine-font-size-xl)" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
@@ -82,14 +62,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
||||
AddButton: BookmarkAddButton,
|
||||
uniqueIdentifier: (item) => item.id,
|
||||
useData: (initialIds) => {
|
||||
const appIds = initialIds.filter((value) => !getDirectBookmarkUrl(value));
|
||||
const { data: apps, error, isLoading } = clientApi.app.byIds.useQuery(appIds);
|
||||
const appsById = new Map(apps?.map((app) => [app.id, app]));
|
||||
const data = initialIds.flatMap((value) => {
|
||||
const directUrl = getDirectBookmarkUrl(value);
|
||||
if (directUrl) return createDirectBookmark(directUrl) ?? [];
|
||||
return appsById.get(value) ?? [];
|
||||
});
|
||||
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -1,69 +1,54 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item";
|
||||
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
|
||||
import {
|
||||
createCustomBookmark,
|
||||
getAdvancedBookmarkColumns,
|
||||
getBookmarkHostname,
|
||||
getCompactBookmarkLayout,
|
||||
} from "./component";
|
||||
|
||||
describe("bookmark display helpers", () => {
|
||||
test("uses explicit width breakpoints for advanced columns", () => {
|
||||
expect(
|
||||
getBookmarkDisplayPlan({ advanced: true, width: 900, height: 500, itemCount: 8, layout: "adaptive" }),
|
||||
).toMatchObject({
|
||||
columns: 3,
|
||||
orientation: "horizontal",
|
||||
});
|
||||
expect(
|
||||
getBookmarkDisplayPlan({ advanced: true, width: 120, height: 500, itemCount: 8, layout: "adaptive" }),
|
||||
).toMatchObject({
|
||||
columns: 1,
|
||||
orientation: "horizontal",
|
||||
});
|
||||
test("uses bounded advanced columns", () => {
|
||||
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
|
||||
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
|
||||
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
|
||||
});
|
||||
|
||||
test("selects an adaptive plan from width and height breakpoints", () => {
|
||||
expect(
|
||||
getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }),
|
||||
).toMatchObject({
|
||||
test("uses container geometry to keep compact cards legible", () => {
|
||||
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
|
||||
columns: 2,
|
||||
itemHeight: 128,
|
||||
orientation: "horizontal",
|
||||
showHostname: true,
|
||||
showTitle: true,
|
||||
hideHostname: false,
|
||||
hideTitle: false,
|
||||
minimumItemSize: 48,
|
||||
});
|
||||
expect(
|
||||
getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }),
|
||||
).toMatchObject({
|
||||
columns: 1,
|
||||
orientation: "icon",
|
||||
showHostname: false,
|
||||
showTitle: false,
|
||||
|
||||
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
|
||||
columns: 4,
|
||||
hideHostname: true,
|
||||
hideTitle: true,
|
||||
minimumItemSize: 96,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps compact cards visible when icons are hidden", () => {
|
||||
const plan = getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "icons" });
|
||||
expect(
|
||||
getBookmarkCardDisplay({ advanced: false, hideHostname: false, hideIcon: true, hideTitle: false, plan }),
|
||||
).toEqual({
|
||||
orientation: "horizontal",
|
||||
showHostname: false,
|
||||
showIcon: false,
|
||||
showTitle: true,
|
||||
});
|
||||
test("does not crash on malformed bookmark URLs", () => {
|
||||
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
|
||||
expect(getBookmarkHostname("not a URL")).toBeUndefined();
|
||||
expect(getBookmarkHostname(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("creates standalone bookmarks without app records", () => {
|
||||
expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({
|
||||
id: "url:https://homarr.dev/docs",
|
||||
expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({
|
||||
id: "custom-link:https://homarr.dev/docs",
|
||||
name: "homarr.dev",
|
||||
description: null,
|
||||
href: "https://homarr.dev/docs",
|
||||
});
|
||||
expect(normalizeBookmarkUrl("homarr.dev/docs")).toBe("https://homarr.dev/docs");
|
||||
expect(normalizeBookmarkUrl("javascript:alert(1)")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("splits URL lists without corrupting commas inside a URL", () => {
|
||||
expect(splitBookmarkUrls("https://one.example, two.example")).toEqual(["https://one.example", "two.example"]);
|
||||
expect(splitBookmarkUrls("https://maps.example/@52.5,13.4,15z")).toEqual(["https://maps.example/@52.5,13.4,15z"]);
|
||||
expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({
|
||||
id: "custom-link:https://homarr.dev/docs",
|
||||
name: "homarr.dev",
|
||||
description: null,
|
||||
href: "https://homarr.dev/docs",
|
||||
});
|
||||
expect(createCustomBookmark("javascript:alert(1)")).toBeNull();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user