Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb238ed4f | ||
|
|
449adc5b12 | ||
|
|
240397e7ac | ||
|
|
3efb719eee | ||
|
|
382cffc015 | ||
|
|
0073dfe044 | ||
|
|
a22fb60647 | ||
|
|
89e4cb843d | ||
|
|
65a96b6b14 | ||
|
|
357355283c | ||
|
|
a2a408410a | ||
|
|
0d9eeb5752 | ||
|
|
bf0878c68b | ||
|
|
eb6d337dfb | ||
|
|
978b3c7f33 | ||
|
|
07547320f1 | ||
|
|
02870fc392 | ||
|
|
b6fc27e44d | ||
|
|
d5cf1b82f2 | ||
|
|
e022c3f735 | ||
|
|
14cc3e8a44 | ||
|
|
93d160aebd | ||
|
|
493e93ea42 | ||
|
|
85c20702cd | ||
|
|
91b248d395 | ||
|
|
b7eadc7867 | ||
|
|
1861d8415c | ||
|
|
7fe5f8f81c | ||
|
|
b3a30e0cf6 | ||
|
|
763e1324e8 | ||
|
|
5188ed89d6 | ||
|
|
9eafd4f7a7 | ||
|
|
71ffa5d1be | ||
|
|
1b843817a4 | ||
|
|
8ae9630b3e | ||
|
|
63f5c43b69 | ||
|
|
e97fe7861d |
No files matched your search
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
name: homarr-local-harness
|
|
||||||
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Homarr Local Harness
|
|
||||||
|
|
||||||
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
|
|
||||||
|
|
||||||
## Set up the harness
|
|
||||||
|
|
||||||
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness setup feature-board
|
|
||||||
```
|
|
||||||
|
|
||||||
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
|
|
||||||
|
|
||||||
- `DEMO_MODE=true`
|
|
||||||
- `DEMO_READ_ONLY=false`
|
|
||||||
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
|
|
||||||
|
|
||||||
Pass repeatable environment overrides when the feature needs them:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness setup feature-board \
|
|
||||||
--env WORKSHOP_WEB_URL=https://example.test \
|
|
||||||
--env FEATURE_FLAG=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
|
|
||||||
|
|
||||||
Always use the URL printed by the command. If the output is no longer available, query it directly:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness port feature-board
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
|
|
||||||
|
|
||||||
## Browser validation
|
|
||||||
|
|
||||||
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
agent-browser skills get core
|
|
||||||
```
|
|
||||||
|
|
||||||
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
|
|
||||||
Stop the named container when finished:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness stop feature-board
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Homarr Local Harness"
|
|
||||||
short_description: "Build and browse a writable local Homarr branch harness"
|
|
||||||
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
|
|
||||||
@@ -30,6 +30,26 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"reportUnusedDisableDirectives": "off"
|
"reportUnusedDisableDirectives": "off"
|
||||||
},
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["apps/nextjs/src/**/*.ts", "apps/nextjs/src/**/*.tsx"],
|
||||||
|
"excludeFiles": ["**/*.spec.ts", "**/*.spec.tsx"],
|
||||||
|
"rules": {
|
||||||
|
"eslint/no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"name": "@homarr/widgets",
|
||||||
|
"allowTypeImports": true,
|
||||||
|
"message": "Import a narrow @homarr/widgets export to preserve widget lazy loading."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"**/*.config.js",
|
"**/*.config.js",
|
||||||
"**/*.config.mjs",
|
"**/*.config.mjs",
|
||||||
|
|||||||
@@ -198,9 +198,13 @@ In the modal you can edit the app that you want to display:
|
|||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
||||||
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
|
<figcaption>
|
||||||
|
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
|
||||||
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
When adding a widget, set its initial grid width and height directly in the preview. These size controls are only shown before the widget is added.
|
||||||
|
|
||||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||||
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
|
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
|
||||||
Press and hold a handle, move the pointer, and release it when the tile has the size you want.
|
Press and hold a handle, move the pointer, and release it when the tile has the size you want.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 45 KiB |
@@ -27,9 +27,10 @@ opening it. Containers include their visible widgets and app icons instead of ap
|
|||||||
|
|
||||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||||
|
|
||||||
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows
|
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
|
||||||
structural previews of the other boards you can access. Start typing to filter by name, use the arrow keys to move
|
previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
|
||||||
through the results, and press `Enter` to open the selected board.
|
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
|
## Create a new board
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Bookmarks"
|
title: "Bookmarks"
|
||||||
description: "Displays multiple app links"
|
description: "Keeps useful links together"
|
||||||
hide_title: true
|
hide_title: true
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,11 +13,12 @@ import { bookmarksWidget } from ".";
|
|||||||
|
|
||||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||||
|
|
||||||
The bookmarks widget allows you to display multiple links in a single widget.
|
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
|
||||||
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.
|
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
|
||||||
For links that do not need an App, add their URLs under **Custom links** instead.
|
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.
|
||||||
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.
|
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
|
||||||
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
|
|
||||||
|
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
|
|||||||
export const bookmarksWidget: WidgetDefinition = {
|
export const bookmarksWidget: WidgetDefinition = {
|
||||||
icon: IconBookmark,
|
icon: IconBookmark,
|
||||||
name: "Bookmarks",
|
name: "Bookmarks",
|
||||||
description: "Displays multiple app links",
|
description: "Keeps useful links together",
|
||||||
path: "../../widgets/bookmarks",
|
path: "../../widgets/bookmarks",
|
||||||
configuration: {
|
configuration: {
|
||||||
items: [
|
items: [
|
||||||
@@ -16,9 +16,21 @@ export const bookmarksWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
description: "Layout in which the bookmarks are displayed.",
|
description: "How bookmarks use the available widget space.",
|
||||||
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
|
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
||||||
defaultValue: "Vertical",
|
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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Hide title",
|
name: "Hide title",
|
||||||
@@ -46,16 +58,10 @@ export const bookmarksWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Bookmarks",
|
name: "Bookmarks",
|
||||||
description: "List of bookmarks to display in the widget.",
|
description: "URLs and existing Apps shown by the widget.",
|
||||||
values: "Select one or more Apps directly, then order them as you like.",
|
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
|
||||||
defaultValue: "No bookmarks selected",
|
defaultValue: "No bookmarks selected",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Custom links",
|
|
||||||
description: "URLs to display without first creating an App.",
|
|
||||||
values: "List of URLs.",
|
|
||||||
defaultValue: "No custom links",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-query-next-experimental": "catalog:",
|
"@tanstack/react-query-next-experimental": "catalog:",
|
||||||
|
"@tanstack/react-query-persist-client": "catalog:",
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/next": "catalog:",
|
"@trpc/next": "catalog:",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
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,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||||
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import {
|
import {
|
||||||
createWSClient,
|
createWSClient,
|
||||||
httpBatchStreamLink,
|
httpBatchStreamLink,
|
||||||
@@ -35,6 +36,13 @@ import { showWarningNotification } from "@homarr/notifications";
|
|||||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||||
|
|
||||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||||
|
import {
|
||||||
|
createSessionQueryPersister,
|
||||||
|
queryPersistenceBuster,
|
||||||
|
queryPersistenceMaxAgeMs,
|
||||||
|
shouldPersistDashboardQuery,
|
||||||
|
} from "./query-persistence";
|
||||||
|
import type { SessionQueryPersister } from "./query-persistence";
|
||||||
import { createQueryRetry } from "./query-retry";
|
import { createQueryRetry } from "./query-retry";
|
||||||
|
|
||||||
const DevelopmentTools =
|
const DevelopmentTools =
|
||||||
@@ -67,21 +75,29 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const sessionQueryScope = getSessionQueryScope(session);
|
const sessionQueryScope = getSessionQueryScope(session);
|
||||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||||
|
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
||||||
|
const handleScopeChange = useCallback(() => {
|
||||||
|
void queryPersister.removeClient();
|
||||||
|
reloadPage();
|
||||||
|
}, [queryPersister]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionQueryScopeGuard
|
<SessionQueryScopeGuard
|
||||||
initialScope={initialSessionQueryScope}
|
initialScope={initialSessionQueryScope}
|
||||||
currentScope={sessionQueryScope}
|
currentScope={sessionQueryScope}
|
||||||
onScopeChange={reloadPage}
|
onScopeChange={handleScopeChange}
|
||||||
>
|
>
|
||||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||||
</SessionQueryScopeGuard>
|
</SessionQueryScopeGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadPage = () => window.location.reload();
|
const reloadPage = () => window.location.reload();
|
||||||
|
|
||||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
const ScopedTRPCReactProvider = ({
|
||||||
|
children,
|
||||||
|
queryPersister,
|
||||||
|
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||||
const wsClient = useMemo(
|
const wsClient = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createWSClient({
|
createWSClient({
|
||||||
@@ -136,6 +152,15 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
|||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const flush = () => queryPersister.flush();
|
||||||
|
window.addEventListener("pagehide", flush);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", flush);
|
||||||
|
flush();
|
||||||
|
queryPersister.stop();
|
||||||
|
};
|
||||||
|
}, [queryPersister]);
|
||||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||||
|
|
||||||
const trpcClient = useMemo(() => {
|
const trpcClient = useMemo(() => {
|
||||||
@@ -186,10 +211,18 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister: queryPersister,
|
||||||
|
maxAge: queryPersistenceMaxAgeMs,
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
</clientApi.Provider>
|
</clientApi.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -16,7 +16,7 @@ import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
|||||||
import {
|
import {
|
||||||
getBoardLaneColumnCount,
|
getBoardLaneColumnCount,
|
||||||
getInitialBoardLogicalHeight,
|
getInitialBoardLogicalHeight,
|
||||||
getLogicalTrackSize,
|
getLogicalGridSize,
|
||||||
LOGICAL_GRID_GAP,
|
LOGICAL_GRID_GAP,
|
||||||
getRootSectionForLane,
|
getRootSectionForLane,
|
||||||
} from "~/components/board/layout";
|
} from "~/components/board/layout";
|
||||||
@@ -61,16 +61,16 @@ export const ClientBoard = () => {
|
|||||||
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
||||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
||||||
.filter((columnCount) => columnCount > 0)
|
.filter((columnCount) => columnCount > 0)
|
||||||
.map(getLogicalTrackSize);
|
.map(getLogicalGridSize);
|
||||||
const logicalWidth =
|
const logicalWidth =
|
||||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||||
const gridTemplateColumns = [
|
const gridTemplateColumns = [
|
||||||
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
|
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
|
||||||
`${getLogicalTrackSize(mainColumnCount)}px`,
|
`${getLogicalGridSize(mainColumnCount)}px`,
|
||||||
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
|
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
|
||||||
]
|
]
|
||||||
.filter((value) => value !== null)
|
.filter((value) => value !== null)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|||||||
@@ -53,13 +53,11 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
|||||||
|
|
||||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||||
|
|
||||||
{!demoReadOnly && (
|
<TourTarget id="board-settings">
|
||||||
<TourTarget id="board-settings">
|
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
<IconSettings stroke={1.5} />
|
||||||
<IconSettings stroke={1.5} />
|
</HeaderButton>
|
||||||
</HeaderButton>
|
</TourTarget>
|
||||||
</TourTarget>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -111,9 +111,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
|
|
||||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||||
const sortedLayouts = form.values.layouts
|
|
||||||
.map((layout, index) => ({ layout, index }))
|
|
||||||
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||||
@@ -129,25 +126,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
disabled={nextBreakpoint === null || !baseLayout}
|
disabled={nextBreakpoint === null || !baseLayout}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (nextBreakpoint === null || !baseLayout) return;
|
if (nextBreakpoint === null || !baseLayout) return;
|
||||||
form.setFieldValue("layouts", [
|
const newLayout: FormValues["layouts"][number] = {
|
||||||
...form.values.layouts,
|
id: createId(),
|
||||||
{
|
name: tBoard("setting.section.layout.custom.defaultName"),
|
||||||
id: createId(),
|
columnCount: baseLayout.columnCount,
|
||||||
name: tBoard("setting.section.layout.custom.defaultName"),
|
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||||
columnCount: baseLayout.columnCount,
|
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
breakpoint: nextBreakpoint,
|
||||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
role: "custom",
|
||||||
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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tBoard("setting.section.layout.responsive.action.add")}
|
{tBoard("setting.section.layout.responsive.action.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{sortedLayouts.map(({ layout, index }) => {
|
{form.values.layouts.map((layout, index) => {
|
||||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||||
const customBreakpoints = form.values.layouts
|
const customBreakpoints = form.values.layouts
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
|
||||||
|
|
||||||
import { use, useCallback, useState } from "react";
|
import { use, useCallback, useState } from "react";
|
||||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||||
@@ -71,6 +73,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
),
|
),
|
||||||
integrationSupport: hasIntegrationSupport,
|
integrationSupport: hasIntegrationSupport,
|
||||||
settings,
|
settings,
|
||||||
|
previewDimensions: dimensions,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title(translate) {
|
title(translate) {
|
||||||
@@ -86,10 +89,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
} finally {
|
} finally {
|
||||||
setIsEditorLoading(false);
|
setIsEditorLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
}, [currentDefinition, dimensions, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||||
|
|
||||||
const toggleEditMode = useCallback(() => {
|
const toggleEditMode = useCallback(() => {
|
||||||
setEditMode((editMode) => !editMode);
|
setEditMode((currentEditMode) => !currentEditMode);
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"),
|
message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { Center, Loader } from "@mantine/core";
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
|
||||||
|
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
||||||
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||||
|
import { BoardPreviewProvider } from "@homarr/boards/context";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
import { ModalProvider } from "@homarr/modals";
|
||||||
import { widgetKinds } from "@homarr/widgets/manifest";
|
import { widgetKinds } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import { WidgetPreviewPageContent } from "./_content";
|
import { WidgetPreviewPageContent } from "./_content";
|
||||||
@@ -16,12 +22,24 @@ export default async function WidgetPreview(props: Props) {
|
|||||||
if (!widgetKinds.includes(kind as WidgetKind)) {
|
if (!widgetKinds.includes(kind as WidgetKind)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
redirect(`/auth/login?callbackUrl=${encodeURIComponent(`/widgets/${kind}`)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [integrations, board] = await Promise.all([getIntegrationsWithPermissionsAsync(session), getHomeBoardAsync()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center h="100vh">
|
<IntegrationProvider integrations={integrations}>
|
||||||
<Suspense fallback={<Loader size="sm" />}>
|
<BoardPreviewProvider board={board}>
|
||||||
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
<ModalProvider>
|
||||||
</Suspense>
|
<Center h="100vh">
|
||||||
</Center>
|
<Suspense fallback={<Loader size="sm" />}>
|
||||||
|
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||||
|
</Suspense>
|
||||||
|
</Center>
|
||||||
|
</ModalProvider>
|
||||||
|
</BoardPreviewProvider>
|
||||||
|
</IntegrationProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -184,10 +184,6 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widgetPanel[data-editing] .panelActions {
|
|
||||||
padding-inline-end: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelHeader {
|
.panelHeader {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 3.75rem;
|
min-height: 3.75rem;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { showErrorNotification, showWarningNotification } from "@homarr/notifica
|
|||||||
import { closeSpotlight } from "@homarr/spotlight/store";
|
import { closeSpotlight } from "@homarr/spotlight/store";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
import { AssistantWidgetRendererProvider } from "@homarr/widgets/assistant/context";
|
||||||
import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
||||||
|
|
||||||
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("preserves generated notebook content and fills its hidden defaults", () => {
|
test("preserves generated notebook content and fills its hidden defaults", () => {
|
||||||
const result = getAssistantWidgetConfiguration(
|
const result = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.notebook.definition,
|
||||||
{
|
{
|
||||||
boardId: "board-1",
|
boardId: "board-1",
|
||||||
boardName: "Home",
|
boardName: "Home",
|
||||||
@@ -41,6 +42,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
||||||
const result = getAssistantWidgetConfiguration(
|
const result = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.audioStats.definition,
|
||||||
{
|
{
|
||||||
boardId: "board-1",
|
boardId: "board-1",
|
||||||
boardName: "Home",
|
boardName: "Home",
|
||||||
@@ -88,11 +90,13 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
||||||
const mediaServer = getAssistantWidgetConfiguration(
|
const mediaServer = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.mediaServer.definition,
|
||||||
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
||||||
settings,
|
settings,
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const calendar = getAssistantWidgetConfiguration(
|
const calendar = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.calendar.definition,
|
||||||
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
||||||
settings,
|
settings,
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { Suspense, use, useMemo } from "react";
|
||||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||||
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
|
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -13,7 +15,8 @@ import { useModalAction } from "@homarr/modals";
|
|||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||||
|
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
@@ -29,11 +32,11 @@ interface AssistantIntegration extends IntegrationSelectOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getAssistantWidgetConfiguration = (
|
export const getAssistantWidgetConfiguration = (
|
||||||
|
definition: WidgetDefinition,
|
||||||
args: ConfigureWidgetArgs,
|
args: ConfigureWidgetArgs,
|
||||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||||
integrations: AssistantIntegration[],
|
integrations: AssistantIntegration[],
|
||||||
) => {
|
) => {
|
||||||
const definition = widgetImports[args.kind].definition;
|
|
||||||
const integrationSupport = "supportedIntegrations" in definition;
|
const integrationSupport = "supportedIntegrations" in definition;
|
||||||
const supportedIntegrations = integrationSupport
|
const supportedIntegrations = integrationSupport
|
||||||
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
||||||
@@ -54,35 +57,91 @@ export const getAssistantWidgetConfiguration = (
|
|||||||
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
|
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||||
options: reduceWidgetOptionsWithDefaultValues(args.kind, settings, args.options),
|
options: reduceWidgetOptionsWithDefinition(definition, settings, args.options),
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AssistantConfigureWidgetTool = ({
|
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 = ({
|
||||||
args,
|
args,
|
||||||
result,
|
result,
|
||||||
addResult,
|
addResult,
|
||||||
status,
|
status,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
|
definition,
|
||||||
|
}: AssistantConfigureWidgetToolProps & { definition: WidgetDefinition | undefined }) => {
|
||||||
const t = useI18n("assistant.configureWidget");
|
const t = useI18n("assistant.configureWidget");
|
||||||
const actionT = useI18n("common.action");
|
const actionT = useI18n("common.action");
|
||||||
const fullT = useI18n();
|
const fullT = useI18n();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
||||||
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
|
|
||||||
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
|
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
|
||||||
const integrations = clientApi.integration.all.useQuery(undefined, {
|
const integrations = clientApi.integration.all.useQuery(undefined, {
|
||||||
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
|
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const configuration = useMemo(() => {
|
const configuration = useMemo(() => {
|
||||||
if (!args || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
if (!args || !definition || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
||||||
return getAssistantWidgetConfiguration(args, settings, integrations.data ?? []);
|
return getAssistantWidgetConfiguration(definition, args, settings, integrations.data ?? []);
|
||||||
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
}, [args, definition, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||||
const missingRequiredIntegration =
|
const missingRequiredIntegration =
|
||||||
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
||||||
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
||||||
@@ -134,15 +193,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
|
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
|
||||||
return (
|
return <AssistantConfigureWidgetSkeleton />;
|
||||||
<Box className={classes.appTool} aria-label={t("preparing")}>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Skeleton height={18} width="45%" />
|
|
||||||
<Skeleton height={14} width="76%" />
|
|
||||||
<Skeleton height={38} />
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (integrations.isError) {
|
if (integrations.isError) {
|
||||||
@@ -158,7 +209,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configuration) return null;
|
if (!configuration || !definition) return null;
|
||||||
|
|
||||||
const openConfigurationModal = (
|
const openConfigurationModal = (
|
||||||
value = configuration.value,
|
value = configuration.value,
|
||||||
@@ -167,7 +218,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
openWidgetModal(
|
openWidgetModal(
|
||||||
{
|
{
|
||||||
kind: args.kind,
|
kind: args.kind,
|
||||||
definition: widgetImports[args.kind].definition,
|
definition,
|
||||||
value,
|
value,
|
||||||
integrationData,
|
integrationData,
|
||||||
integrationSupport: configuration.integrationSupport,
|
integrationSupport: configuration.integrationSupport,
|
||||||
@@ -240,7 +291,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WidgetIcon = widgetImports[args.kind].definition.icon;
|
const WidgetIcon = definition.icon;
|
||||||
return (
|
return (
|
||||||
<Box className={classes.appTool}>
|
<Box className={classes.appTool}>
|
||||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.canvas {
|
.canvas {
|
||||||
min-height: 8rem;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
aspect-ratio: 16 / 7;
|
aspect-ratio: 16 / 7;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--mantine-color-dimmed);
|
color: var(--mantine-color-dimmed);
|
||||||
@@ -83,9 +84,3 @@
|
|||||||
gap: rem(1px);
|
gap: rem(1px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: $mantine-breakpoint-xs) {
|
|
||||||
.canvas {
|
|
||||||
min-height: 7rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,11 +11,12 @@ import classes from "./board-layout-thumbnail.module.css";
|
|||||||
interface BoardLayoutThumbnailProps {
|
interface BoardLayoutThumbnailProps {
|
||||||
preview: BoardPreviewData | null;
|
preview: BoardPreviewData | null;
|
||||||
label: string;
|
label: string;
|
||||||
|
fitFullLayout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxThumbnailRows = 12;
|
const maxThumbnailRows = 12;
|
||||||
|
|
||||||
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
|
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
|
||||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||||
if (!preview || !layout) {
|
if (!preview || !layout) {
|
||||||
return (
|
return (
|
||||||
@@ -26,6 +27,7 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elements = projectBoardLayout(preview, layout, layout);
|
const elements = projectBoardLayout(preview, layout, layout);
|
||||||
|
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
||||||
const roots = preview.sections
|
const roots = preview.sections
|
||||||
.filter((section) => section.kind === "empty")
|
.filter((section) => section.kind === "empty")
|
||||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||||
@@ -65,12 +67,12 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
|
|||||||
element.xOffset >= 0 &&
|
element.xOffset >= 0 &&
|
||||||
element.xOffset < columnCount &&
|
element.xOffset < columnCount &&
|
||||||
element.yOffset >= 0 &&
|
element.yOffset >= 0 &&
|
||||||
element.yOffset < maxThumbnailRows &&
|
element.yOffset < thumbnailRowLimit &&
|
||||||
element.width > 0 &&
|
element.width > 0 &&
|
||||||
element.height > 0,
|
element.height > 0,
|
||||||
);
|
);
|
||||||
const rowCount = Math.min(
|
const rowCount = Math.min(
|
||||||
maxThumbnailRows,
|
thumbnailRowLimit,
|
||||||
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
|
.modalContent {
|
||||||
|
overflow: hidden;
|
||||||
|
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
|
||||||
|
border-radius: rem(24px);
|
||||||
|
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
|
||||||
|
box-shadow: 0 rem(18px) rem(56px) rgb(0 0 0 / 22%);
|
||||||
|
backdrop-filter: blur(rem(20px)) saturate(140%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBody {
|
||||||
|
padding: var(--mantine-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent);
|
||||||
|
backdrop-filter: blur(rem(8px));
|
||||||
|
}
|
||||||
|
|
||||||
.option {
|
.option {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
@@ -11,6 +30,12 @@
|
|||||||
.card {
|
.card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: var(--mantine-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadataBadge {
|
||||||
|
width: rem(22px);
|
||||||
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option:hover .card,
|
.option:hover .card,
|
||||||
|
|||||||
@@ -3,29 +3,31 @@
|
|||||||
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
Kbd,
|
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
useMatches,
|
useMatches,
|
||||||
VisuallyHidden,
|
VisuallyHidden,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import { IconArrowDown, IconArrowRight, IconLayoutBoard, IconReplace } from "@tabler/icons-react";
|
import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useOptionalBoard } from "@homarr/boards/context";
|
import { useOptionalBoard } from "@homarr/boards/context";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ interface BoardSwitcherProps {
|
|||||||
|
|
||||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||||
const t = useI18n("board.action.switcher");
|
const t = useI18n("board.action.switcher");
|
||||||
|
const manageBoardsT = useI18n("management.page.board");
|
||||||
const currentBoard = useOptionalBoard();
|
const currentBoard = useOptionalBoard();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -50,20 +53,27 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
data: boards = [],
|
data: boards = [],
|
||||||
isPending,
|
isPending,
|
||||||
isError,
|
isError,
|
||||||
} = clientApi.board.getManageOverview.useQuery(undefined, {
|
} = clientApi.board.getManageOverview.useQuery(
|
||||||
enabled: isOpen,
|
{ fullPreview: true },
|
||||||
});
|
{
|
||||||
|
enabled: isOpen,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const availableBoards = useMemo(
|
const switcherBoards = useMemo(
|
||||||
() => boards.filter((board) => board.id !== currentBoard?.id),
|
() => [
|
||||||
|
...boards.filter((board) => board.id !== currentBoard?.id),
|
||||||
|
...boards.filter((board) => board.id === currentBoard?.id),
|
||||||
|
],
|
||||||
[boards, currentBoard?.id],
|
[boards, currentBoard?.id],
|
||||||
);
|
);
|
||||||
const filteredBoards = useMemo(() => {
|
const filteredBoards = useMemo(() => {
|
||||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||||
if (normalizedSearch.length === 0) return availableBoards;
|
if (normalizedSearch.length === 0) return switcherBoards;
|
||||||
return availableBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
return switcherBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
||||||
}, [availableBoards, search]);
|
}, [search, switcherBoards]);
|
||||||
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
|
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
|
||||||
|
const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
|
||||||
|
|
||||||
const openSwitcher = useCallback(() => {
|
const openSwitcher = useCallback(() => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
@@ -73,7 +83,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
}, []);
|
}, []);
|
||||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch();
|
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||||
|
|
||||||
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
||||||
|
|
||||||
@@ -88,14 +98,17 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
}, [activeIndex, filteredBoards.length, isOpen]);
|
}, [activeIndex, filteredBoards.length, isOpen]);
|
||||||
|
|
||||||
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (event.key === "Backspace") {
|
const isSearchInput =
|
||||||
|
event.target instanceof HTMLInputElement && event.target.dataset.boardSwitcherSearch !== undefined;
|
||||||
|
|
||||||
|
if (!isSearchInput && event.key === "Backspace") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSearch((current) => current.slice(0, -1));
|
setSearch((current) => current.slice(0, -1));
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
if (!isSearchInput && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSearch((current) => `${current}${event.key}`);
|
setSearch((current) => `${current}${event.key}`);
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
@@ -141,7 +154,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const results = getBoardSwitcherResults({
|
const results = getBoardSwitcherResults({
|
||||||
availableBoardCount: availableBoards.length,
|
availableBoardCount: switcherBoards.length,
|
||||||
filteredBoards,
|
filteredBoards,
|
||||||
isError,
|
isError,
|
||||||
isPending,
|
isPending,
|
||||||
@@ -152,6 +165,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
setActiveIndex,
|
setActiveIndex,
|
||||||
optionRefs,
|
optionRefs,
|
||||||
columnCount,
|
columnCount,
|
||||||
|
manageBoardsT,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -161,54 +175,40 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
<Modal
|
<Modal
|
||||||
opened={isOpen}
|
opened={isOpen}
|
||||||
onClose={closeSwitcher}
|
onClose={closeSwitcher}
|
||||||
title={
|
withCloseButton={false}
|
||||||
<Group gap="sm" wrap="nowrap">
|
yOffset="15vh"
|
||||||
<ThemeIcon variant="light" radius="md">
|
aria-label={t("title")}
|
||||||
<IconReplace size={18} stroke={1.5} />
|
size={`${modalColumnCount * 15 + Math.max(0, modalColumnCount - 1) * 0.75}rem`}
|
||||||
</ThemeIcon>
|
overlayProps={{ backgroundOpacity: 0, blur: 2 }}
|
||||||
<Text fw={700}>{t("title")}</Text>
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
size={1280}
|
|
||||||
yOffset={32}
|
|
||||||
overlayProps={{ backgroundOpacity: 0.18, blur: 2 }}
|
|
||||||
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
|
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
|
||||||
|
classNames={{ content: classes.modalContent, body: classes.modalBody }}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
data-autofocus
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
gap="md"
|
gap="xs"
|
||||||
onKeyDown={handleModalKeyDown}
|
onKeyDown={handleModalKeyDown}
|
||||||
className={classes.content}
|
className={classes.content}
|
||||||
aria-describedby="board-switcher-instructions"
|
aria-describedby="board-switcher-instructions"
|
||||||
>
|
>
|
||||||
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
|
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
|
||||||
<ScrollArea.Autosize mah={640} type="auto" offsetScrollbars>
|
<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}
|
{results}
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea.Autosize>
|
||||||
|
|
||||||
<Group justify={currentBoard ? "space-between" : "flex-end"} gap="xs" visibleFrom="sm">
|
|
||||||
{currentBoard && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("currentHidden", { name: currentBoard.name })}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Group gap="xs">
|
|
||||||
<Kbd size="xs">
|
|
||||||
<IconArrowRight size={12} />
|
|
||||||
</Kbd>
|
|
||||||
<Kbd size="xs">
|
|
||||||
<IconArrowDown size={12} />
|
|
||||||
</Kbd>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("keyboard.navigate")}
|
|
||||||
</Text>
|
|
||||||
<Kbd size="xs">Enter</Kbd>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("keyboard.open")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
@@ -227,6 +227,7 @@ interface BoardSwitcherResultsProps {
|
|||||||
setActiveIndex: (index: number) => void;
|
setActiveIndex: (index: number) => void;
|
||||||
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
|
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
|
manageBoardsT: ReturnType<typeof useI18n<"management.page.board">>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBoardSwitcherResults = ({
|
const getBoardSwitcherResults = ({
|
||||||
@@ -241,6 +242,7 @@ const getBoardSwitcherResults = ({
|
|||||||
setActiveIndex,
|
setActiveIndex,
|
||||||
optionRefs,
|
optionRefs,
|
||||||
columnCount,
|
columnCount,
|
||||||
|
manageBoardsT,
|
||||||
}: BoardSwitcherResultsProps) => {
|
}: BoardSwitcherResultsProps) => {
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
@@ -287,7 +289,7 @@ const getBoardSwitcherResults = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="md" p={4}>
|
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="xs" p={2}>
|
||||||
{filteredBoards.map((board, index) => (
|
{filteredBoards.map((board, index) => (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
key={board.id}
|
key={board.id}
|
||||||
@@ -296,7 +298,7 @@ const getBoardSwitcherResults = ({
|
|||||||
}}
|
}}
|
||||||
className={classes.option}
|
className={classes.option}
|
||||||
w="100%"
|
w="100%"
|
||||||
maw={320}
|
maw={240}
|
||||||
mx="auto"
|
mx="auto"
|
||||||
component={Link}
|
component={Link}
|
||||||
href={`/boards/${encodeURIComponent(board.name)}`}
|
href={`/boards/${encodeURIComponent(board.name)}`}
|
||||||
@@ -306,21 +308,50 @@ const getBoardSwitcherResults = ({
|
|||||||
onFocus={() => setActiveIndex(index)}
|
onFocus={() => setActiveIndex(index)}
|
||||||
onPointerEnter={() => setActiveIndex(index)}
|
onPointerEnter={() => setActiveIndex(index)}
|
||||||
>
|
>
|
||||||
<Card padding={0} withBorder radius="md" className={classes.card}>
|
<Card padding={0} withBorder radius="lg" className={classes.card}>
|
||||||
<Card.Section>
|
<Card.Section>
|
||||||
<BoardLayoutThumbnail
|
<BoardLayoutThumbnail
|
||||||
preview={board.preview}
|
preview={board.preview}
|
||||||
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||||
|
fitFullLayout
|
||||||
/>
|
/>
|
||||||
</Card.Section>
|
</Card.Section>
|
||||||
<Card.Section withBorder p="sm">
|
<Card.Section withBorder px="sm" py={6}>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||||
<ThemeIcon variant="light" radius="md" size="md">
|
<Text fw={600} size="sm" truncate>
|
||||||
<IconLayoutBoard size={16} stroke={1.5} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={650} truncate>
|
|
||||||
{board.name}
|
{board.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{board.isHome && (
|
||||||
|
<Tooltip label={manageBoardsT("action.setHomeBoard.badge.tooltip")}>
|
||||||
|
<Badge
|
||||||
|
className={classes.metadataBadge}
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
aria-label={manageBoardsT("action.setHomeBoard.badge.label")}
|
||||||
|
>
|
||||||
|
<IconHomeFilled size="0.7rem" />
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{board.isMobileHome && (
|
||||||
|
<Tooltip label={manageBoardsT("action.setMobileHomeBoard.badge.tooltip")}>
|
||||||
|
<Badge
|
||||||
|
className={classes.metadataBadge}
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
aria-label={manageBoardsT("action.setMobileHomeBoard.badge.label")}
|
||||||
|
>
|
||||||
|
<IconDeviceMobile size="0.7rem" />
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{board.creator && (
|
||||||
|
<Tooltip label={board.creator.name ?? manageBoardsT("preview.unknownCreator")}>
|
||||||
|
<UserAvatar user={board.creator} size="xs" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Card.Section>
|
</Card.Section>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
.badge {
|
.refreshIndicator {
|
||||||
@mixin dark {
|
position: absolute;
|
||||||
--background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity));
|
top: 0.25rem;
|
||||||
--border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity));
|
right: 0.25rem;
|
||||||
}
|
z-index: 3;
|
||||||
@mixin light {
|
display: grid;
|
||||||
--background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity));
|
place-items: center;
|
||||||
--border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity));
|
width: 1.25rem;
|
||||||
}
|
height: 1.25rem;
|
||||||
background-color: var(--background-color) !important;
|
border-radius: 999px;
|
||||||
border-color: var(--border-color) !important;
|
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settingsButton {
|
.settingsButton {
|
||||||
|
position: absolute;
|
||||||
|
top: -34px;
|
||||||
|
right: 14px;
|
||||||
|
z-index: 201;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 120ms ease;
|
transition: opacity 120ms ease;
|
||||||
@@ -58,3 +63,11 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settingsButton {
|
||||||
|
right: 4px;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { ComponentType, CSSProperties, MutableRefObject } from "react";
|
import type { ComponentType, CSSProperties, MutableRefObject, PropsWithChildren } from "react";
|
||||||
import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type { CardProps } from "@mantine/core";
|
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
||||||
import { Badge, Box, Button, Card, Center, Loader, Portal } from "@mantine/core";
|
|
||||||
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
||||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||||
import type { FallbackProps } from "react-error-boundary";
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
@@ -20,13 +19,16 @@ import { WidgetError } from "@homarr/widgets/errors";
|
|||||||
import {
|
import {
|
||||||
createWidgetRuntimeState,
|
createWidgetRuntimeState,
|
||||||
getWidgetQueryKeys,
|
getWidgetQueryKeys,
|
||||||
|
getWidgetRuntimeQueries,
|
||||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||||
} from "@homarr/widgets/definition";
|
} from "@homarr/widgets/definition";
|
||||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
import { WidgetCardShell, WidgetTitleBadge } from "@homarr/widgets/widget-card-shell";
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { getLogicalTrackSize } from "~/components/board/layout";
|
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||||
|
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||||
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
||||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||||
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
|
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
|
||||||
@@ -34,11 +36,11 @@ import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced
|
|||||||
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
||||||
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
||||||
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
|
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
|
||||||
import classes from "../sections/item.module.css";
|
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
import { WidgetContextMenu } from "./widget-context-menu";
|
import { WidgetContextMenu } from "./widget-context-menu";
|
||||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||||
|
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||||
|
|
||||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -48,47 +50,28 @@ interface BoardItemContentProps {
|
|||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
|
type BoardItemCardProps = PropsWithChildren<{
|
||||||
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
|
|
||||||
if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" } as const;
|
|
||||||
if (hasCustomCssClasses) return {};
|
|
||||||
return { overflowX: "hidden", overflowY: "auto" } as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BoardItemCardProps = CardProps & {
|
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
innerRef: (element: HTMLDivElement | null) => void;
|
innerRef: (element: HTMLDivElement | null) => void;
|
||||||
};
|
}>;
|
||||||
|
|
||||||
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
const BoardItemCard = ({ item, innerRef, children }: BoardItemCardProps) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<WidgetCardShell
|
||||||
{...cardProps}
|
innerRef={innerRef}
|
||||||
ref={innerRef}
|
kind={item.kind}
|
||||||
|
advancedOptions={item.advancedOptions}
|
||||||
|
opacity={board.opacity / 100}
|
||||||
w="100%"
|
w="100%"
|
||||||
h="100%"
|
h="100%"
|
||||||
data-grid-item-content
|
data-grid-item-content
|
||||||
className={combineClasses(
|
|
||||||
classes.itemCard,
|
|
||||||
`${item.kind}-wrapper`,
|
|
||||||
"board-grid-item-content",
|
|
||||||
item.advancedOptions.customCssClasses.join(" "),
|
|
||||||
)}
|
|
||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
"--opacity": board.opacity / 100,
|
|
||||||
containerType: "size",
|
|
||||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
|
||||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
p={0}
|
p={0}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Card>
|
</WidgetCardShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +150,7 @@ const LoadedBoardItemContent = ({
|
|||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
const previewEntranceFrameRef = useRef<number | null>(null);
|
const previewEntranceFrameRef = useRef<number | null>(null);
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const boardCanvasScale = useBoardCanvasScale();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
@@ -174,12 +158,34 @@ const LoadedBoardItemContent = ({
|
|||||||
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
||||||
[definition, item, settings],
|
[definition, item, settings],
|
||||||
);
|
);
|
||||||
|
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||||
|
const isWidgetRefreshing =
|
||||||
|
useIsFetching({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.state.data !== undefined &&
|
||||||
|
matchesWidgetItemQuery(
|
||||||
|
query.queryKey,
|
||||||
|
widgetQueryKeys,
|
||||||
|
{
|
||||||
|
itemId: item.id,
|
||||||
|
boardId: board.id,
|
||||||
|
integrationIds: item.integrationIds,
|
||||||
|
options: menuItem.options,
|
||||||
|
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
||||||
|
},
|
||||||
|
definition.queryMatcher,
|
||||||
|
),
|
||||||
|
}) > 0;
|
||||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||||
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||||
const { width: viewportWidth, height: viewportHeight } = viewportSize;
|
const { width: viewportWidth, height: viewportHeight } = viewportSize;
|
||||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
||||||
const widgetName = getWidgetName(item.kind, t);
|
const widgetName = getWidgetName(item.kind, t);
|
||||||
|
const previewDimensions = useMemo(
|
||||||
|
() => ({ width, height, scale: boardCanvasScale }),
|
||||||
|
[boardCanvasScale, height, width],
|
||||||
|
);
|
||||||
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
||||||
const advancedViewId = `advanced-focus-${item.id}`;
|
const advancedViewId = `advanced-focus-${item.id}`;
|
||||||
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
||||||
@@ -291,8 +297,11 @@ const LoadedBoardItemContent = ({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const widgetCard = (
|
const widgetCard = (
|
||||||
<Card
|
<WidgetCardShell
|
||||||
ref={cardRef}
|
innerRef={cardRef}
|
||||||
|
kind={item.kind}
|
||||||
|
advancedOptions={item.advancedOptions}
|
||||||
|
opacity={isAdvanced ? 0.98 : board.opacity / 100}
|
||||||
id={isPreview ? advancedViewId : undefined}
|
id={isPreview ? advancedViewId : undefined}
|
||||||
role={isPreview ? "region" : undefined}
|
role={isPreview ? "region" : undefined}
|
||||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||||
@@ -303,21 +312,9 @@ const LoadedBoardItemContent = ({
|
|||||||
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||||
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||||
className={combineClasses(
|
className={combineClasses(
|
||||||
classes.itemCard,
|
|
||||||
`${item.kind}-wrapper`,
|
|
||||||
"board-grid-item-content",
|
|
||||||
isPreview && advancedFocusClasses.surface,
|
isPreview && advancedFocusClasses.surface,
|
||||||
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
|
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
|
||||||
item.advancedOptions.customCssClasses.join(" "),
|
|
||||||
)}
|
)}
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
|
|
||||||
containerType: "size",
|
|
||||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
|
||||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
style={previewStyle}
|
style={previewStyle}
|
||||||
>
|
>
|
||||||
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
||||||
@@ -345,15 +342,21 @@ const LoadedBoardItemContent = ({
|
|||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
{isWidgetRefreshing && (
|
||||||
|
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</WidgetCardShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} />}
|
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} previewDimensions={previewDimensions} />}
|
||||||
<WidgetContextMenu
|
<WidgetContextMenu
|
||||||
item={item}
|
item={item}
|
||||||
definition={definition}
|
definition={definition}
|
||||||
|
previewDimensions={previewDimensions}
|
||||||
widgetStateRef={widgetStateRef}
|
widgetStateRef={widgetStateRef}
|
||||||
widgetRuntimeRef={widgetRuntimeRef}
|
widgetRuntimeRef={widgetRuntimeRef}
|
||||||
sourceRef={sourceRef}
|
sourceRef={sourceRef}
|
||||||
@@ -418,25 +421,12 @@ const LoadedBoardItemContent = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</WidgetContextMenu>
|
</WidgetContextMenu>
|
||||||
{!isAdvanced && item.advancedOptions.title?.trim() && (
|
{!isAdvanced && (
|
||||||
<Badge
|
<WidgetTitleBadge
|
||||||
pos="absolute"
|
advancedOptions={item.advancedOptions}
|
||||||
style={{ zIndex: "var(--mantine-z-index-app)" }}
|
opacity={board.opacity / 100}
|
||||||
top={2}
|
|
||||||
left={16}
|
|
||||||
size="xs"
|
|
||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
styles={{
|
/>
|
||||||
root: {
|
|
||||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
|
||||||
"--opacity": board.opacity / 100,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
className={itemContentClasses.badge}
|
|
||||||
c="var(--mantine-color-text)"
|
|
||||||
>
|
|
||||||
{item.advancedOptions.title}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
|
|||||||
interface BoardItemMenuProps {
|
interface BoardItemMenuProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
definition: WidgetDefinition;
|
definition: WidgetDefinition;
|
||||||
|
previewDimensions: { width: number; height: number; scale?: number };
|
||||||
resetErrorBoundary?: () => void;
|
resetErrorBoundary?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
|||||||
|
|
||||||
return <BoardItemMenuInner {...props} />;
|
return <BoardItemMenuInner {...props} />;
|
||||||
};
|
};
|
||||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||||
@@ -89,6 +90,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
|||||||
integrationSupport: "supportedIntegrations" in definition,
|
integrationSupport: "supportedIntegrations" in definition,
|
||||||
settings,
|
settings,
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
|
previewDimensions,
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -106,10 +108,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
|||||||
variant="default"
|
variant="default"
|
||||||
size={24}
|
size={24}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
pos="absolute"
|
|
||||||
top={4}
|
|
||||||
right={4}
|
|
||||||
style={{ zIndex: 30 }}
|
|
||||||
className={itemContentClasses.settingsButton}
|
className={itemContentClasses.settingsButton}
|
||||||
data-menu-open={isMenuOpen || undefined}
|
data-menu-open={isMenuOpen || undefined}
|
||||||
data-board-widget-settings
|
data-board-widget-settings
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
getIntegrationName,
|
getIntegrationName,
|
||||||
getWidgetName,
|
getWidgetName,
|
||||||
getRootSectionLane,
|
getRootSectionLane,
|
||||||
|
widgetDefaultSizes,
|
||||||
widgetIntegrationSupport,
|
widgetIntegrationSupport,
|
||||||
widgetKinds,
|
widgetKinds,
|
||||||
widgetKindsWithOptionalIntegrations,
|
widgetKindsWithOptionalIntegrations,
|
||||||
@@ -47,6 +48,7 @@ import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr
|
|||||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||||
|
import { getSectionGridColumnCount } from "../sections/grid/section-grid-placements";
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +78,8 @@ export interface WidgetItemData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
||||||
|
const defaultWidgetSize = { width: 1, height: 1 };
|
||||||
|
const maximumWidgetHeight = 64;
|
||||||
|
|
||||||
const ItemSelectModalContent = ({
|
const ItemSelectModalContent = ({
|
||||||
actions,
|
actions,
|
||||||
@@ -124,6 +128,13 @@ const ItemSelectModalContent = ({
|
|||||||
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
|
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
|
||||||
? targetSectionId
|
? targetSectionId
|
||||||
: (placementOptions[0]?.value ?? null);
|
: (placementOptions[0]?.value ?? null);
|
||||||
|
const maximumWidgetSize = useMemo(() => {
|
||||||
|
if (!effectiveSectionId) return defaultWidgetSize;
|
||||||
|
return {
|
||||||
|
width: Math.max(1, getSectionGridColumnCount(board, currentLayoutId, effectiveSectionId) ?? 1),
|
||||||
|
height: maximumWidgetHeight,
|
||||||
|
};
|
||||||
|
}, [board, currentLayoutId, effectiveSectionId]);
|
||||||
|
|
||||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||||
const selectionLock = useRef(false);
|
const selectionLock = useRef(false);
|
||||||
@@ -246,6 +257,7 @@ const ItemSelectModalContent = ({
|
|||||||
try {
|
try {
|
||||||
const definition = await loadWidgetDefinition("customApi");
|
const definition = await loadWidgetDefinition("customApi");
|
||||||
const itemId = createId();
|
const itemId = createId();
|
||||||
|
const initialSize = widgetDefaultSizes.customApi ?? defaultWidgetSize;
|
||||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||||
const options = {
|
const options = {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
@@ -263,7 +275,7 @@ const ItemSelectModalContent = ({
|
|||||||
options,
|
options,
|
||||||
integrationIds: [],
|
integrationIds: [],
|
||||||
},
|
},
|
||||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }, selectedSize) => {
|
||||||
const updatedBoard = createItem({
|
const updatedBoard = createItem({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
kind: "customApi",
|
kind: "customApi",
|
||||||
@@ -271,12 +283,14 @@ const ItemSelectModalContent = ({
|
|||||||
options: configuredOptions,
|
options: configuredOptions,
|
||||||
targetSectionId: effectiveSectionId ?? undefined,
|
targetSectionId: effectiveSectionId ?? undefined,
|
||||||
advancedOptions,
|
advancedOptions,
|
||||||
|
size: selectedSize ?? initialSize,
|
||||||
});
|
});
|
||||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||||
},
|
},
|
||||||
integrationData: [],
|
integrationData: [],
|
||||||
integrationSupport: false,
|
integrationSupport: false,
|
||||||
settings,
|
settings,
|
||||||
|
previewResize: { initialSize, maximumSize: maximumWidgetSize },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||||
@@ -311,6 +325,7 @@ const ItemSelectModalContent = ({
|
|||||||
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
|
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
|
||||||
const itemId = createId();
|
const itemId = createId();
|
||||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||||
|
const initialSize = widgetDefaultSizes[kind] ?? defaultWidgetSize;
|
||||||
const openEditor = (
|
const openEditor = (
|
||||||
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
|
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
|
||||||
) => {
|
) => {
|
||||||
@@ -326,7 +341,7 @@ const ItemSelectModalContent = ({
|
|||||||
options: defaultOptions,
|
options: defaultOptions,
|
||||||
integrationIds: selectedIntegrationIds,
|
integrationIds: selectedIntegrationIds,
|
||||||
},
|
},
|
||||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }, selectedSize) => {
|
||||||
const updatedBoard = createItem({
|
const updatedBoard = createItem({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
kind,
|
kind,
|
||||||
@@ -334,6 +349,7 @@ const ItemSelectModalContent = ({
|
|||||||
integrationIds: newIntegrationIds,
|
integrationIds: newIntegrationIds,
|
||||||
targetSectionId: effectiveSectionId ?? undefined,
|
targetSectionId: effectiveSectionId ?? undefined,
|
||||||
advancedOptions,
|
advancedOptions,
|
||||||
|
size: selectedSize ?? initialSize,
|
||||||
});
|
});
|
||||||
if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return;
|
if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return;
|
||||||
trackSetup("widget-completed", {
|
trackSetup("widget-completed", {
|
||||||
@@ -346,6 +362,7 @@ const ItemSelectModalContent = ({
|
|||||||
integrationData: availableIntegrations,
|
integrationData: availableIntegrations,
|
||||||
integrationSupport: hasIntegrationSupport,
|
integrationSupport: hasIntegrationSupport,
|
||||||
settings,
|
settings,
|
||||||
|
previewResize: { initialSize, maximumSize: maximumWidgetSize },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
||||||
import { Center, Loader } from "@mantine/core";
|
import { Center, Loader } from "@mantine/core";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
@@ -7,16 +8,49 @@ import type { FallbackProps } from "react-error-boundary";
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||||
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
import { WidgetError } from "@homarr/widgets/errors";
|
||||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
import type { WidgetEditModalProps, WidgetEditModalSize } from "@homarr/widgets/modals";
|
||||||
|
import { loadWidgetComponent } from "@homarr/widgets/manifest";
|
||||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||||
|
|
||||||
|
import { AssistantContext, useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||||
|
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||||
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
||||||
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
||||||
|
|
||||||
|
const defaultWidgetSize = { width: 1, height: 1 };
|
||||||
|
const ignoreAssistantAction = () => undefined;
|
||||||
|
const ignoreAssistantPrompt = () => false;
|
||||||
|
const ignoreAssistantRefresh = () => Promise.resolve();
|
||||||
|
const getPreviewDimensions = (size: WidgetEditModalSize) => ({
|
||||||
|
width: getLogicalTrackSize(size.width),
|
||||||
|
height: getLogicalTrackSize(size.height),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PreviewRuntimeWrapper = ({ children }: PropsWithChildren) => {
|
||||||
|
const assistant = useOptionalHomarrAssistant();
|
||||||
|
const previewAssistant = useMemo(() => {
|
||||||
|
if (!assistant) return null;
|
||||||
|
return {
|
||||||
|
...assistant,
|
||||||
|
open: ignoreAssistantAction,
|
||||||
|
close: ignoreAssistantAction,
|
||||||
|
toggle: ignoreAssistantAction,
|
||||||
|
sendPrompt: ignoreAssistantPrompt,
|
||||||
|
refreshCurrentView: ignoreAssistantRefresh,
|
||||||
|
setWidgetVisible: ignoreAssistantAction,
|
||||||
|
activateWidget: ignoreAssistantAction,
|
||||||
|
};
|
||||||
|
}, [assistant]);
|
||||||
|
|
||||||
|
if (!previewAssistant) return children;
|
||||||
|
return <AssistantContext.Provider value={previewAssistant}>{children}</AssistantContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
||||||
|
|
||||||
export const loadWidgetEditModal = () => {
|
export const loadWidgetEditModal = () => {
|
||||||
@@ -83,6 +117,7 @@ interface LazyWidgetEditModalContentProps {
|
|||||||
|
|
||||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||||
|
const { default: PreviewComponent } = use(loadWidgetComponent(props.innerProps.kind));
|
||||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||||
const Component = WidgetEditModal.component;
|
const Component = WidgetEditModal.component;
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
@@ -114,6 +149,18 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
|||||||
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
||||||
return [...existing, ...additional];
|
return [...existing, ...additional];
|
||||||
}, [props.innerProps.integrationData, createdIntegrations]);
|
}, [props.innerProps.integrationData, createdIntegrations]);
|
||||||
|
const previewDimensions = useMemo(() => {
|
||||||
|
if (props.innerProps.previewDimensions) return props.innerProps.previewDimensions;
|
||||||
|
const size = widgetDefaultSizes[props.innerProps.kind] ?? defaultWidgetSize;
|
||||||
|
return {
|
||||||
|
width: getLogicalTrackSize(size.width),
|
||||||
|
height: getLogicalTrackSize(size.height),
|
||||||
|
};
|
||||||
|
}, [props.innerProps.kind, props.innerProps.previewDimensions]);
|
||||||
|
let previewResize = props.innerProps.previewResize;
|
||||||
|
if (previewResize) {
|
||||||
|
previewResize = { ...previewResize, getDimensions: getPreviewDimensions };
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
@@ -122,6 +169,10 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
|||||||
...props.innerProps,
|
...props.innerProps,
|
||||||
integrationData: combinedIntegrationData,
|
integrationData: combinedIntegrationData,
|
||||||
integrationEditForm: LazyIntegrationEditForm,
|
integrationEditForm: LazyIntegrationEditForm,
|
||||||
|
previewComponent: PreviewComponent,
|
||||||
|
previewDimensions,
|
||||||
|
previewResize,
|
||||||
|
previewWrapper: PreviewRuntimeWrapper,
|
||||||
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -157,6 +208,10 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
|||||||
defaultTitle(t) {
|
defaultTitle(t) {
|
||||||
return t("item.edit.title");
|
return t("item.edit.title");
|
||||||
},
|
},
|
||||||
size: modalSizeForm,
|
size: "100rem",
|
||||||
|
transitionProps: {
|
||||||
|
transition: "pop",
|
||||||
|
duration: 180,
|
||||||
|
},
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
});
|
});
|
||||||
@@ -35,6 +35,7 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
|
|||||||
interface WidgetContextMenuProps {
|
interface WidgetContextMenuProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
definition: WidgetDefinition;
|
definition: WidgetDefinition;
|
||||||
|
previewDimensions: { width: number; height: number; scale?: number };
|
||||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||||
widgetRuntimeRef: WidgetRuntimeRef;
|
widgetRuntimeRef: WidgetRuntimeRef;
|
||||||
sourceRef: RefObject<HTMLElement | null>;
|
sourceRef: RefObject<HTMLElement | null>;
|
||||||
@@ -44,6 +45,7 @@ interface WidgetContextMenuProps {
|
|||||||
export const WidgetContextMenu = ({
|
export const WidgetContextMenu = ({
|
||||||
item,
|
item,
|
||||||
definition,
|
definition,
|
||||||
|
previewDimensions,
|
||||||
widgetRuntimeRef,
|
widgetRuntimeRef,
|
||||||
sourceRef,
|
sourceRef,
|
||||||
children,
|
children,
|
||||||
@@ -160,7 +162,7 @@ export const WidgetContextMenu = ({
|
|||||||
definition,
|
definition,
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: item.advancedOptions,
|
advancedOptions: item.advancedOptions,
|
||||||
options: item.options,
|
options,
|
||||||
integrationIds: item.integrationIds,
|
integrationIds: item.integrationIds,
|
||||||
},
|
},
|
||||||
onSuccessfulEdit: (editResult) => {
|
onSuccessfulEdit: (editResult) => {
|
||||||
@@ -189,6 +191,9 @@ export const WidgetContextMenu = ({
|
|||||||
),
|
),
|
||||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||||
settings,
|
settings,
|
||||||
|
itemId: item.id,
|
||||||
|
boardId: board.id,
|
||||||
|
previewDimensions,
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -197,12 +202,15 @@ export const WidgetContextMenu = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
|
board.id,
|
||||||
definition,
|
definition,
|
||||||
integrationData,
|
integrationData,
|
||||||
item,
|
item,
|
||||||
handleRefetch,
|
handleRefetch,
|
||||||
openModal,
|
openModal,
|
||||||
|
options,
|
||||||
persistBoard,
|
persistBoard,
|
||||||
|
previewDimensions,
|
||||||
settings,
|
settings,
|
||||||
updateItemAdvancedOptions,
|
updateItemAdvancedOptions,
|
||||||
updateItemIntegrations,
|
updateItemIntegrations,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 4px 8px;
|
padding: 4px 40px 4px 8px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
|
|||||||
@@ -2,17 +2,16 @@
|
|||||||
|
|
||||||
The board uses fixed logical units:
|
The board uses fixed logical units:
|
||||||
|
|
||||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
- one grid track has a `212 × 212` logical-pixel footprint;
|
||||||
- the gap matches Mantine's `sm` spacing at `12` logical pixels, giving a `212` pixel track pitch;
|
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter;
|
||||||
- multi-cell items include the gaps between their cells (`2 × 1` is `412 × 200`);
|
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset);
|
||||||
- the canvas is zoomed once with one uniform scale derived from its available width.
|
- the canvas is zoomed once with one uniform scale derived from its available width.
|
||||||
|
|
||||||
Items keep the same logical dimensions at every viewport. The viewport reserves
|
Grid footprints keep the same logical dimensions at every viewport. Widget cards
|
||||||
the painted size while the inner canvas remains at its logical dimensions, so a
|
use a scale-compensated inset so their painted size and content measurements
|
||||||
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
|
match the legacy responsive grid. The visual canvas always fits the available
|
||||||
stable. The visual canvas always fits the available width, every root lane
|
width, every root lane keeps at least one viewport of height, and edit-mode
|
||||||
keeps at least one viewport of height, and edit-mode previews extend it downward
|
previews extend it downward as needed.
|
||||||
as needed.
|
|
||||||
|
|
||||||
The read-only renderer should:
|
The read-only renderer should:
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { BoardLane } from "@homarr/definitions";
|
|||||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||||
|
|
||||||
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
|
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
|
import { getLogicalGridSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
|
||||||
import { getCollapsedDisplayLayout } from "./reflow";
|
import { getCollapsedDisplayLayout } from "./reflow";
|
||||||
import type { GridPlacement } from "./types";
|
import type { GridPlacement } from "./types";
|
||||||
|
|
||||||
@@ -90,5 +90,5 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
|
|||||||
return [getLayoutRowCount(displayPlacements)];
|
return [getLayoutRowCount(displayPlacements)];
|
||||||
});
|
});
|
||||||
|
|
||||||
return getLogicalTrackSize(Math.max(0, ...rowCounts));
|
return getLogicalGridSize(Math.max(0, ...rowCounts));
|
||||||
};
|
};
|
||||||
@@ -10,12 +10,23 @@ export const getLogicalTrackSize = (trackCount: number) => {
|
|||||||
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
|
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the complete footprint occupied by grid tracks. Cards are inset
|
||||||
|
* inside this footprint so their visual gutters stay fixed while the canvas
|
||||||
|
* is scaled.
|
||||||
|
*/
|
||||||
|
export const getLogicalGridSize = (trackCount: number) => {
|
||||||
|
const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount"));
|
||||||
|
|
||||||
|
return normalizedTrackCount * LOGICAL_GRID_PITCH;
|
||||||
|
};
|
||||||
|
|
||||||
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
|
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: x * LOGICAL_GRID_PITCH,
|
left: x * LOGICAL_GRID_PITCH,
|
||||||
top: y * LOGICAL_GRID_PITCH,
|
top: y * LOGICAL_GRID_PITCH,
|
||||||
width: getLogicalTrackSize(w),
|
width: getLogicalGridSize(w),
|
||||||
height: getLogicalTrackSize(h),
|
height: getLogicalGridSize(h),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
|
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
|
||||||
@@ -25,7 +36,7 @@ export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale
|
|||||||
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
|
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
|
||||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
|
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
|
||||||
|
|
||||||
return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
|
return Math.max(1, Math.ceil(visualHeight / visualScale / LOGICAL_GRID_PITCH));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
|
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/ite
|
|||||||
import {
|
import {
|
||||||
getBoardLaneColumnCount,
|
getBoardLaneColumnCount,
|
||||||
getInitialBoardLogicalHeight,
|
getInitialBoardLogicalHeight,
|
||||||
getLogicalTrackSize,
|
getLogicalGridSize,
|
||||||
getRootSectionForLane,
|
getRootSectionForLane,
|
||||||
} from "../index";
|
} from "../index";
|
||||||
|
|
||||||
@@ -131,6 +131,6 @@ describe("board lanes", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalTrackSize(4));
|
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalGridSize(4));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3,24 +3,28 @@ import { describe, expect, test } from "vitest";
|
|||||||
import {
|
import {
|
||||||
getGridRowCountForVisualHeight,
|
getGridRowCountForVisualHeight,
|
||||||
getLayoutRowCount,
|
getLayoutRowCount,
|
||||||
|
getLogicalGridSize,
|
||||||
getLogicalItemStyle,
|
getLogicalItemStyle,
|
||||||
getLogicalTrackSize,
|
getLogicalTrackSize,
|
||||||
normalizeGridPlacement,
|
normalizeGridPlacement,
|
||||||
} from "../index";
|
} from "../index";
|
||||||
|
|
||||||
describe("fixed dashboard geometry", () => {
|
describe("fixed dashboard geometry", () => {
|
||||||
test("keeps a 1x1 item exactly 200px with a fixed 12px gap", () => {
|
test("separates content track sizes from complete grid footprints", () => {
|
||||||
expect(getLogicalTrackSize(1)).toBe(200);
|
expect(getLogicalTrackSize(1)).toBe(200);
|
||||||
expect(getLogicalTrackSize(0.5)).toBe(94);
|
expect(getLogicalTrackSize(0.5)).toBe(94);
|
||||||
expect(getLogicalTrackSize(2)).toBe(412);
|
expect(getLogicalTrackSize(2)).toBe(412);
|
||||||
expect(getLogicalTrackSize(3)).toBe(624);
|
expect(getLogicalTrackSize(3)).toBe(624);
|
||||||
|
expect(getLogicalGridSize(1)).toBe(212);
|
||||||
|
expect(getLogicalGridSize(2)).toBe(424);
|
||||||
|
expect(getLogicalGridSize(3)).toBe(636);
|
||||||
|
|
||||||
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: 212,
|
left: 212,
|
||||||
top: 424,
|
top: 424,
|
||||||
width: 412,
|
width: 424,
|
||||||
height: 624,
|
height: 636,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
import type { GridPlacement } from "~/components/board/layout";
|
import type { GridPlacement } from "~/components/board/layout";
|
||||||
import type { GridResizeDirection } from "./types";
|
import type { GridResizeDirection } from "./types";
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export const getSnappedGridCoordinates = ({
|
|||||||
target: RectangleLike;
|
target: RectangleLike;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
}) => {
|
}) => {
|
||||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
const logicalWidth = getLogicalGridSize(columnCount);
|
||||||
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
|
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
|
||||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
|
if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
|||||||
import { getWidgetName } from "@homarr/definitions";
|
import { getWidgetName } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
import {
|
import {
|
||||||
beginGridTransaction,
|
beginGridTransaction,
|
||||||
cancelGridTransaction,
|
cancelGridTransaction,
|
||||||
@@ -792,8 +792,8 @@ export default function GridEditor({
|
|||||||
data-grid-depth={depth}
|
data-grid-depth={depth}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
width: getLogicalTrackSize(columnCount),
|
width: getLogicalGridSize(columnCount),
|
||||||
height: getLogicalTrackSize(rowCount),
|
height: getLogicalGridSize(rowCount),
|
||||||
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
|
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
@@ -1234,8 +1234,8 @@ const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGrid
|
|||||||
if (!element) return null;
|
if (!element) return null;
|
||||||
const rectangle = element.getBoundingClientRect();
|
const rectangle = element.getBoundingClientRect();
|
||||||
const scale = {
|
const scale = {
|
||||||
x: rectangle.width / getLogicalTrackSize(placement.w),
|
x: rectangle.width / getLogicalGridSize(placement.w),
|
||||||
y: rectangle.height / getLogicalTrackSize(placement.h),
|
y: rectangle.height / getLogicalGridSize(placement.h),
|
||||||
};
|
};
|
||||||
return Number.isFinite(scale.x) && scale.x > 0 && Number.isFinite(scale.y) && scale.y > 0 ? scale : null;
|
return Number.isFinite(scale.x) && scale.x > 0 && Number.isFinite(scale.y) && scale.y > 0 ? scale : null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
import type { GridPlacement } from "~/components/board/layout";
|
import type { GridPlacement } from "~/components/board/layout";
|
||||||
|
|
||||||
export interface GridPreviewDomState {
|
export interface GridPreviewDomState {
|
||||||
@@ -75,8 +75,8 @@ export const clearGridPreviewGeometry = (
|
|||||||
const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => {
|
const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => {
|
||||||
element.style.setProperty("--board-grid-preview-x", `${(preview.x - controlled.x) * LOGICAL_GRID_PITCH}px`);
|
element.style.setProperty("--board-grid-preview-x", `${(preview.x - controlled.x) * LOGICAL_GRID_PITCH}px`);
|
||||||
element.style.setProperty("--board-grid-preview-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`);
|
element.style.setProperty("--board-grid-preview-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`);
|
||||||
element.style.setProperty("--board-grid-preview-width", `${getLogicalTrackSize(preview.w)}px`);
|
element.style.setProperty("--board-grid-preview-width", `${getLogicalGridSize(preview.w)}px`);
|
||||||
element.style.setProperty("--board-grid-preview-height", `${getLogicalTrackSize(preview.h)}px`);
|
element.style.setProperty("--board-grid-preview-height", `${getLogicalGridSize(preview.h)}px`);
|
||||||
element.setAttribute("data-grid-preview", "true");
|
element.setAttribute("data-grid-preview", "true");
|
||||||
setPlacementAttributes(element, preview);
|
setPlacementAttributes(element, preview);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
|
import { getLayoutRowCount, getLogicalItemStyle, getLogicalGridSize } from "~/components/board/layout";
|
||||||
import type { TransactionalGridState } from "./dnd";
|
import type { TransactionalGridState } from "./dnd";
|
||||||
import type { GridInteractionStore } from "./grid-interaction-store";
|
import type { GridInteractionStore } from "./grid-interaction-store";
|
||||||
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
|
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
|
||||||
@@ -87,7 +87,7 @@ export const GridPreviewLayer = ({
|
|||||||
} else {
|
} else {
|
||||||
delete grid.dataset.dndPreviewRevision;
|
delete grid.dataset.dndPreviewRevision;
|
||||||
}
|
}
|
||||||
grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
|
grid.style.height = `${getLogicalGridSize(renderedRowCount)}px`;
|
||||||
|
|
||||||
const viewport = grid.parentElement;
|
const viewport = grid.parentElement;
|
||||||
if (viewport?.hasAttribute("data-section-id")) {
|
if (viewport?.hasAttribute("data-section-id")) {
|
||||||
@@ -98,7 +98,7 @@ export const GridPreviewLayer = ({
|
|||||||
// stray value would silently blow the cap open for the rest of the session, well after any
|
// stray value would silently blow the cap open for the rest of the session, well after any
|
||||||
// drag ended, since nothing else would ever clear it back out.
|
// drag ended, since nothing else would ever clear it back out.
|
||||||
if (maxRowCount === null) {
|
if (maxRowCount === null) {
|
||||||
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
|
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalGridSize(renderedRowCount)}px`);
|
||||||
} else {
|
} else {
|
||||||
viewport.style.removeProperty("--board-grid-drag-height");
|
viewport.style.removeProperty("--board-grid-drag-height");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
import type { GridPlacement } from "~/components/board/layout";
|
import type { GridPlacement } from "~/components/board/layout";
|
||||||
|
|
||||||
interface ResizeOutlinePreview {
|
interface ResizeOutlinePreview {
|
||||||
@@ -31,8 +31,8 @@ export const createGridResizeOutlineController = (
|
|||||||
outline.style.position = "absolute";
|
outline.style.position = "absolute";
|
||||||
outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`;
|
outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`;
|
||||||
outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`;
|
outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`;
|
||||||
outline.style.width = `${getLogicalTrackSize(preview.placement.w)}px`;
|
outline.style.width = `${getLogicalGridSize(preview.placement.w)}px`;
|
||||||
outline.style.height = `${getLogicalTrackSize(preview.placement.h)}px`;
|
outline.style.height = `${getLogicalGridSize(preview.placement.h)}px`;
|
||||||
outline.dataset.gridResizeValid = String(preview.valid);
|
outline.dataset.gridResizeValid = String(preview.valid);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.staticItem[data-type="item"] > .contentMount {
|
||||||
|
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
|
||||||
|
}
|
||||||
|
|
||||||
.staticItem:has([data-board-container-label]) {
|
.staticItem:has([data-board-container-label]) {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
getEditableCanvasAttributes,
|
getEditableCanvasAttributes,
|
||||||
getGridRowCountForVisualHeight,
|
getGridRowCountForVisualHeight,
|
||||||
getLayoutRowCount,
|
getLayoutRowCount,
|
||||||
getLogicalTrackSize,
|
getLogicalGridSize,
|
||||||
getReadonlyCanvasAttributes,
|
getReadonlyCanvasAttributes,
|
||||||
normalizeGridPlacement,
|
normalizeGridPlacement,
|
||||||
} from "~/components/board/layout";
|
} from "~/components/board/layout";
|
||||||
@@ -138,9 +138,9 @@ export const SectionGrid = ({
|
|||||||
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
|
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
|
||||||
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
||||||
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
|
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
|
||||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
const logicalWidth = getLogicalGridSize(columnCount);
|
||||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
const logicalHeight = getLogicalGridSize(rowCount);
|
||||||
const viewportHeight = getLogicalTrackSize(viewportRowCount);
|
const viewportHeight = getLogicalGridSize(viewportRowCount);
|
||||||
// A collapsed container's compact coordinates are display-only. Its own
|
// A collapsed container's compact coordinates are display-only. Its own
|
||||||
// nested grid stays inactive until an explicit edit interaction expands it.
|
// nested grid stays inactive until an explicit edit interaction expands it.
|
||||||
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
|
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import type { Root } from "react-dom/client";
|
import type { Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { getCollapsedDisplayLayout, LOGICAL_GRID_CELL_SIZE } from "../../../layout";
|
import { getCollapsedDisplayLayout, getLogicalGridSize } from "../../../layout";
|
||||||
import { FixedGridItem } from "../fixed-grid-item";
|
import { FixedGridItem } from "../fixed-grid-item";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@@ -250,14 +250,14 @@ describe("fixed grid item behavior", () => {
|
|||||||
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull();
|
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders a view-only 1x1 card at the fixed 200px logical size", () => {
|
test("renders a view-only 1x1 card at the complete grid footprint", () => {
|
||||||
mocks.editMode = false;
|
mocks.editMode = false;
|
||||||
renderWeather(root);
|
renderWeather(root);
|
||||||
|
|
||||||
const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]');
|
const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]');
|
||||||
expect(item).not.toBeNull();
|
expect(item).not.toBeNull();
|
||||||
expect(item?.style.width).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
|
expect(item?.style.width).toBe(`${getLogicalGridSize(1)}px`);
|
||||||
expect(item?.style.height).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
|
expect(item?.style.height).toBe(`${getLogicalGridSize(1)}px`);
|
||||||
expect(item?.getAttribute("role")).toBeNull();
|
expect(item?.getAttribute("role")).toBeNull();
|
||||||
|
|
||||||
act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, test } from "vitest";
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
|
|
||||||
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ describe("grid preview geometry", () => {
|
|||||||
expect(active?.getAttribute("data-grid-preview")).toBe("true");
|
expect(active?.getAttribute("data-grid-preview")).toBe("true");
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe(`${LOGICAL_GRID_PITCH}px`);
|
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe(`${LOGICAL_GRID_PITCH}px`);
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-y")).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(`${getLogicalTrackSize(3)}px`);
|
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalGridSize(3)}px`);
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalTrackSize(4)}px`);
|
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalGridSize(4)}px`);
|
||||||
expect(active?.dataset.gridW).toBe("3");
|
expect(active?.dataset.gridW).toBe("3");
|
||||||
expect(active?.dataset.gridH).toBe("4");
|
expect(active?.dataset.gridH).toBe("4");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { getLogicalTrackSize } from "~/components/board/layout";
|
import { getLogicalGridSize } from "~/components/board/layout";
|
||||||
|
|
||||||
import { createGridResizeOutlineController } from "../grid-resize-outline";
|
import { createGridResizeOutlineController } from "../grid-resize-outline";
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ describe("grid resize outline", () => {
|
|||||||
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
|
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
|
||||||
expect(outline?.style.left).toBe("0px");
|
expect(outline?.style.left).toBe("0px");
|
||||||
expect(outline?.style.top).toBe("0px");
|
expect(outline?.style.top).toBe("0px");
|
||||||
expect(outline?.style.width).toBe(`${getLogicalTrackSize(1)}px`);
|
expect(outline?.style.width).toBe(`${getLogicalGridSize(1)}px`);
|
||||||
expect(outline?.style.height).toBe(`${getLogicalTrackSize(2)}px`);
|
expect(outline?.style.height).toBe(`${getLogicalGridSize(2)}px`);
|
||||||
expect(outline?.dataset.gridResizeValid).toBe("false");
|
expect(outline?.dataset.gridResizeValid).toBe("false");
|
||||||
|
|
||||||
controller.schedule(null);
|
controller.schedule(null);
|
||||||
|
|||||||
+114
-28
@@ -1,5 +1,6 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { chromium, expect } from "@playwright/test";
|
import { chromium, expect } from "@playwright/test";
|
||||||
|
import type { Route } from "@playwright/test";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { stringify } from "superjson";
|
import { stringify } from "superjson";
|
||||||
import { describe, test } from "vitest";
|
import { describe, test } from "vitest";
|
||||||
@@ -21,7 +22,9 @@ describe("lazy widget application graph", () => {
|
|||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
const layoutId = createId();
|
const layoutId = createId();
|
||||||
const sectionId = createId();
|
const sectionId = createId();
|
||||||
const itemId = createId();
|
const clockItemId = createId();
|
||||||
|
const downloadsItemId = createId();
|
||||||
|
const mockIntegrationId = createId();
|
||||||
|
|
||||||
await db.insert(sqliteSchema.boards).values({
|
await db.insert(sqliteSchema.boards).values({
|
||||||
id: boardId,
|
id: boardId,
|
||||||
@@ -41,26 +44,56 @@ describe("lazy widget application graph", () => {
|
|||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
});
|
});
|
||||||
await db.insert(sqliteSchema.items).values({
|
await db.insert(sqliteSchema.integrations).values({
|
||||||
id: itemId,
|
id: mockIntegrationId,
|
||||||
boardId,
|
name: "Mock downloads",
|
||||||
kind: "clock",
|
url: "http://mock.local",
|
||||||
options: stringify({ showSeconds: true }),
|
kind: "mock",
|
||||||
});
|
});
|
||||||
await db.insert(sqliteSchema.itemLayouts).values({
|
await db.insert(sqliteSchema.items).values([
|
||||||
itemId,
|
{
|
||||||
sectionId,
|
id: clockItemId,
|
||||||
layoutId,
|
boardId,
|
||||||
xOffset: 0,
|
kind: "clock",
|
||||||
yOffset: 0,
|
options: stringify({ showSeconds: true }),
|
||||||
width: 4,
|
},
|
||||||
height: 2,
|
{
|
||||||
|
id: downloadsItemId,
|
||||||
|
boardId,
|
||||||
|
kind: "downloads",
|
||||||
|
options: stringify({}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(sqliteSchema.itemLayouts).values([
|
||||||
|
{
|
||||||
|
itemId: clockItemId,
|
||||||
|
sectionId,
|
||||||
|
layoutId,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 4,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: downloadsItemId,
|
||||||
|
sectionId,
|
||||||
|
layoutId,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 2,
|
||||||
|
width: 8,
|
||||||
|
height: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(sqliteSchema.integrationItems).values({
|
||||||
|
itemId: downloadsItemId,
|
||||||
|
integrationId: mockIntegrationId,
|
||||||
});
|
});
|
||||||
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
||||||
|
|
||||||
const homarrContainer = await createHomarrContainer({
|
const homarrContainer = await createHomarrContainer({
|
||||||
environment: {
|
environment: {
|
||||||
AUTH_PROVIDERS: "credentials",
|
AUTH_PROVIDERS: "credentials",
|
||||||
|
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
|
||||||
},
|
},
|
||||||
mounts: {
|
mounts: {
|
||||||
"/appdata": localMountPath,
|
"/appdata": localMountPath,
|
||||||
@@ -69,10 +102,18 @@ describe("lazy widget application graph", () => {
|
|||||||
|
|
||||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext({ serviceWorkers: "block" });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const pageErrors: Error[] = [];
|
const pageErrors: Error[] = [];
|
||||||
|
const hydrationErrors: string[] = [];
|
||||||
page.on("pageerror", (error) => pageErrors.push(error));
|
page.on("pageerror", (error) => pageErrors.push(error));
|
||||||
|
const hydrationErrorPattern = /hydrat|did not match|react\.dev\/errors\/4(18|2[1235])|react error #4(18|2[1235])/i;
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error" && hydrationErrorPattern.test(message.text())) {
|
||||||
|
hydrationErrors.push(message.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let releaseRefresh: (() => void) | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`${baseUrl}/auth/login`);
|
await page.goto(`${baseUrl}/auth/login`);
|
||||||
@@ -88,9 +129,18 @@ describe("lazy widget application graph", () => {
|
|||||||
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
||||||
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
||||||
const clockWidget = page.locator(
|
const clockWidget = page.locator(
|
||||||
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
`[data-id="${clockItemId}"] .clock-widget-container, [data-grid-item-id="${clockItemId}"] .clock-widget-container`,
|
||||||
);
|
);
|
||||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
const downloadsWidget = page
|
||||||
|
.locator(`[data-id="${downloadsItemId}"], [data-grid-item-id="${downloadsItemId}"]`)
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
const cachedDownload = downloadsWidget.getByText("Big.Buck.Bunny.2008.1080p.BluRay.x264");
|
||||||
|
await expect(cachedDownload).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
@@ -101,23 +151,59 @@ describe("lazy widget application graph", () => {
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
||||||
|
|
||||||
await expect
|
let refreshRequestStarted = false;
|
||||||
.poll(
|
const refreshGate = new Promise<void>((resolve) => {
|
||||||
async () =>
|
releaseRefresh = resolve;
|
||||||
await page.evaluate(async () => {
|
});
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
const downloadsRequestPattern = "**/*widget.downloads.getJobsAndStatuses*";
|
||||||
return registrations[0]?.scope ?? null;
|
const delayDownloadsRefresh = async (route: Route) => {
|
||||||
}),
|
refreshRequestStarted = true;
|
||||||
{ timeout: 15_000 },
|
await refreshGate;
|
||||||
)
|
await route.continue();
|
||||||
.toBe(`${baseUrl}/`);
|
};
|
||||||
|
await page.route(downloadsRequestPattern, delayDownloadsRefresh);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
||||||
|
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
|
||||||
|
|
||||||
|
const refreshedDownloadsResponse = page.waitForResponse(
|
||||||
|
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
||||||
|
);
|
||||||
|
releaseRefresh();
|
||||||
|
releaseRefresh = undefined;
|
||||||
|
await refreshedDownloadsResponse;
|
||||||
|
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
|
||||||
|
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
|
expect(hydrationErrors).toEqual([]);
|
||||||
|
|
||||||
|
const serviceWorkerContext = await browser.newContext();
|
||||||
|
const serviceWorkerPage = await serviceWorkerContext.newPage();
|
||||||
|
try {
|
||||||
|
await serviceWorkerPage.goto(`${baseUrl}/auth/login`);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
await serviceWorkerPage.evaluate(async () => {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
return registrations[0]?.scope ?? null;
|
||||||
|
}),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(`${baseUrl}/`);
|
||||||
|
} finally {
|
||||||
|
await serviceWorkerContext.close();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
releaseRefresh?.();
|
||||||
await browser.close();
|
await browser.close();
|
||||||
await homarrContainer.stop();
|
await homarrContainer.stop();
|
||||||
}
|
}
|
||||||
}, 120_000);
|
}, 180_000);
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,8 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
|||||||
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||||
|
|
||||||
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||||
|
// Beszel system stats are large time-series payloads and were deliberately kept out of
|
||||||
|
// the persisted cache in #6289; live stats come from an SSE subscription instead.
|
||||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||||
|
|
||||||
export const isWidgetDataTrpcPath = (path: string) => {
|
export const isWidgetDataTrpcPath = (path: string) => {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ interface BoardItemPlacementRectangle {
|
|||||||
|
|
||||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||||
const maxManageOverviewPreviewRows = 12;
|
const maxManageOverviewPreviewRows = 12;
|
||||||
|
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
|
||||||
|
|
||||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||||
@@ -225,7 +226,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
getManageOverview: publicProcedure.query(async ({ ctx }) => {
|
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
|
||||||
const userId = ctx.session?.user.id;
|
const userId = ctx.session?.user.id;
|
||||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||||
@@ -306,7 +307,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||||
gte(itemLayouts.xOffset, 0),
|
gte(itemLayouts.xOffset, 0),
|
||||||
gte(itemLayouts.yOffset, 0),
|
gte(itemLayouts.yOffset, 0),
|
||||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(itemLayouts.width, 0),
|
gt(itemLayouts.width, 0),
|
||||||
gt(itemLayouts.height, 0),
|
gt(itemLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -331,7 +332,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||||
gte(sectionLayouts.xOffset, 0),
|
gte(sectionLayouts.xOffset, 0),
|
||||||
gte(sectionLayouts.yOffset, 0),
|
gte(sectionLayouts.yOffset, 0),
|
||||||
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(sectionLayouts.width, 0),
|
gt(sectionLayouts.width, 0),
|
||||||
gt(sectionLayouts.height, 0),
|
gt(sectionLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -366,7 +367,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
||||||
gte(itemLayouts.xOffset, 0),
|
gte(itemLayouts.xOffset, 0),
|
||||||
gte(itemLayouts.yOffset, 0),
|
gte(itemLayouts.yOffset, 0),
|
||||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(itemLayouts.width, 0),
|
gt(itemLayouts.width, 0),
|
||||||
gt(itemLayouts.height, 0),
|
gt(itemLayouts.height, 0),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ describe("getManageOverview", () => {
|
|||||||
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
|
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns one compact base-layout preview for each accessible board", async () => {
|
test("returns compact previews by default and full previews on request", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
@@ -487,6 +487,10 @@ describe("getManageOverview", () => {
|
|||||||
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
||||||
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
|
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
|
||||||
expect(result[0]?.preview?.sections).toHaveLength(2);
|
expect(result[0]?.preview?.sections).toHaveLength(2);
|
||||||
|
|
||||||
|
const fullResult = await caller.getManageOverview({ fullPreview: true });
|
||||||
|
|
||||||
|
expect(fullResult[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, offscreenItemId, nestedItemId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("includes app and widget icons in compact previews", async () => {
|
test("includes app and widget icons in compact previews", async () => {
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection";
|
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection";
|
||||||
import { updateBoardName } from "./updater";
|
import { updateBoardName } from "./updater";
|
||||||
|
|
||||||
const BoardContext = createContext<{
|
type Board = RouterOutputs["board"]["getBoardByName"];
|
||||||
board: RouterOutputs["board"]["getBoardByName"];
|
|
||||||
|
interface BoardContextValue {
|
||||||
|
board: Board;
|
||||||
layoutOverrideId: string | null;
|
layoutOverrideId: string | null;
|
||||||
currentLayout: string;
|
currentLayout: string;
|
||||||
initialViewportWidth: number;
|
initialViewportWidth: number;
|
||||||
} | null>(null);
|
}
|
||||||
|
|
||||||
|
const BoardContext = createContext<BoardContextValue | null>(null);
|
||||||
|
|
||||||
const subscribeToViewport = (onStoreChange: () => void) => {
|
const subscribeToViewport = (onStoreChange: () => void) => {
|
||||||
if (typeof window === "undefined") return () => undefined;
|
if (typeof window === "undefined") return () => undefined;
|
||||||
@@ -37,6 +41,23 @@ const subscribeToViewport = (onStoreChange: () => void) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const BoardPreviewProvider = ({ children, board }: PropsWithChildren<{ board: Board }>) => {
|
||||||
|
const currentLayout = board.layouts[0]?.id ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoardContext.Provider
|
||||||
|
value={{
|
||||||
|
board,
|
||||||
|
layoutOverrideId: null,
|
||||||
|
currentLayout,
|
||||||
|
initialViewportWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</BoardContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const BoardProvider = ({
|
export const BoardProvider = ({
|
||||||
children,
|
children,
|
||||||
initialBoard,
|
initialBoard,
|
||||||
|
|||||||
@@ -1904,8 +1904,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widgetQuery": {
|
"widgetQuery": {
|
||||||
"loading": "Loading widget data",
|
"loading": "Loading widget data"
|
||||||
"stale": "Could not refresh {widget}; showing saved data"
|
|
||||||
},
|
},
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}",
|
"filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}",
|
||||||
@@ -2083,10 +2082,12 @@
|
|||||||
"label": "Section or rail"
|
"label": "Section or rail"
|
||||||
},
|
},
|
||||||
"width": {
|
"width": {
|
||||||
"label": "Width"
|
"label": "Width",
|
||||||
|
"shortLabel": "W"
|
||||||
},
|
},
|
||||||
"height": {
|
"height": {
|
||||||
"label": "Height"
|
"label": "Height",
|
||||||
|
"shortLabel": "H"
|
||||||
},
|
},
|
||||||
"xOffset": {
|
"xOffset": {
|
||||||
"label": "X offset"
|
"label": "X offset"
|
||||||
@@ -2115,6 +2116,13 @@
|
|||||||
"loading": "Loading integration",
|
"loading": "Loading integration",
|
||||||
"loadError": "This integration could not be loaded. Try again before changing its connection settings."
|
"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": {
|
"advancedOptions": {
|
||||||
"label": "Advanced options",
|
"label": "Advanced options",
|
||||||
"title": "Advanced item options"
|
"title": "Advanced item options"
|
||||||
@@ -2192,8 +2200,51 @@
|
|||||||
"layout": {
|
"layout": {
|
||||||
"label": "Layout",
|
"label": "Layout",
|
||||||
"option": {
|
"option": {
|
||||||
|
"adaptive": {
|
||||||
|
"label": "Adaptive"
|
||||||
|
},
|
||||||
"gridHorizontal": {
|
"gridHorizontal": {
|
||||||
"label": "Grid horizontal"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2210,17 +2261,21 @@
|
|||||||
"label": "Show border"
|
"label": "Show border"
|
||||||
},
|
},
|
||||||
"customUrls": {
|
"customUrls": {
|
||||||
"label": "Custom links",
|
"label": "Custom links"
|
||||||
"description": "Add URLs directly without creating an App first"
|
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"label": "Bookmarks",
|
"label": "Bookmarks",
|
||||||
|
"remove": "Remove bookmark",
|
||||||
"apps": {
|
"apps": {
|
||||||
"label": "Apps",
|
"label": "Add bookmarks",
|
||||||
"placeholder": "Select one or more apps"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"empty": "Add a URL or App in the widget settings"
|
||||||
},
|
},
|
||||||
"archiveTeamWarrior": {
|
"archiveTeamWarrior": {
|
||||||
"name": "ArchiveTeam Warrior",
|
"name": "ArchiveTeam Warrior",
|
||||||
@@ -7716,6 +7771,8 @@
|
|||||||
"ready": "Widget configuration ready",
|
"ready": "Widget configuration ready",
|
||||||
"cancelled": "Widget was not added",
|
"cancelled": "Widget was not added",
|
||||||
"automaticContinue": "Accepting the proposed widget configuration automatically",
|
"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",
|
"loadErrorTitle": "Integrations could not be loaded",
|
||||||
"loadErrorDescription": "Homarr could not load the integrations available for this widget.",
|
"loadErrorDescription": "Homarr could not load the integrations available for this widget.",
|
||||||
"noIntegrationTitle": "A compatible integration is required",
|
"noIntegrationTitle": "A compatible integration is required",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { MantineProviderProps } from "@mantine/core";
|
|||||||
|
|
||||||
import { theme } from "./theme";
|
import { theme } from "./theme";
|
||||||
|
|
||||||
export { theme } from "./theme";
|
export { scrollAreaDefaultProps, theme } from "./theme";
|
||||||
export { modalSizeForm, modalSizeSelect } from "./theme/modal";
|
export { modalSizeForm, modalSizeSelect } from "./theme/modal";
|
||||||
export * from "./components";
|
export * from "./components";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { createTheme, rem } from "@mantine/core";
|
|||||||
|
|
||||||
import { modalComponent } from "./theme/modal";
|
import { modalComponent } from "./theme/modal";
|
||||||
|
|
||||||
|
export const scrollAreaDefaultProps = {
|
||||||
|
type: "hover",
|
||||||
|
scrollbarSize: 2,
|
||||||
|
scrollHideDelay: 500,
|
||||||
|
offsetScrollbars: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const theme = createTheme({
|
export const theme = createTheme({
|
||||||
primaryColor: "red",
|
primaryColor: "red",
|
||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
@@ -25,6 +32,7 @@ export const theme = createTheme({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
|
xxs: rem(11),
|
||||||
xs: rem(12),
|
xs: rem(12),
|
||||||
sm: rem(14),
|
sm: rem(14),
|
||||||
md: rem(16),
|
md: rem(16),
|
||||||
@@ -95,6 +103,12 @@ export const theme = createTheme({
|
|||||||
style: { borderRadius: 5 },
|
style: { borderRadius: 5 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ScrollArea: {
|
||||||
|
defaultProps: scrollAreaDefaultProps,
|
||||||
|
},
|
||||||
|
ScrollAreaAutosize: {
|
||||||
|
defaultProps: scrollAreaDefaultProps,
|
||||||
|
},
|
||||||
LoadingOverlay: {
|
LoadingOverlay: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
"./widget-integration-select": "./src/widget-integration-select.tsx",
|
"./widget-integration-select": "./src/widget-integration-select.tsx",
|
||||||
"./modals": "./src/modals/index.ts",
|
"./modals": "./src/modals/index.ts",
|
||||||
"./prefetch": "./src/prefetch.ts",
|
"./prefetch": "./src/prefetch.ts",
|
||||||
"./refetch-intervals": "./src/refetch-intervals.ts"
|
"./refetch-intervals": "./src/refetch-intervals.ts",
|
||||||
|
"./widget-card-shell": "./src/widget-card-shell.tsx"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -rf .turbo node_modules",
|
"clean": "rm -rf .turbo node_modules",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
|||||||
boardId?: string;
|
boardId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
|
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder" | "remove") => string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Short description why as and unknown convertions are used below:
|
* Short description why as and unknown convertions are used below:
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ export function JsonPathTreePicker({
|
|||||||
</Combobox.Header>
|
</Combobox.Header>
|
||||||
)}
|
)}
|
||||||
<Combobox.Options>
|
<Combobox.Options>
|
||||||
<ScrollArea.Autosize mah={420} type="scroll">
|
<ScrollArea.Autosize mah={420}>
|
||||||
{!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>}
|
{!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>}
|
||||||
{hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>}
|
{hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>}
|
||||||
{visibleNodes.map((node) => (
|
{visibleNodes.map((node) => (
|
||||||
|
|||||||
@@ -96,6 +96,28 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
|||||||
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
|
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(
|
const removeItem = useCallback(
|
||||||
(value: TOptionValue) => {
|
(value: TOptionValue) => {
|
||||||
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
|
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
|
||||||
@@ -112,7 +134,13 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
|||||||
return (
|
return (
|
||||||
<Fieldset legend={t("label")}>
|
<Fieldset legend={t("label")}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
|
<options.addButton
|
||||||
|
addItem={addItem}
|
||||||
|
migrateItems={migrateItems}
|
||||||
|
removeItem={removeItem}
|
||||||
|
values={values}
|
||||||
|
initialOptions={initialOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -153,6 +181,7 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
|||||||
index={index}
|
index={index}
|
||||||
item={item}
|
item={item}
|
||||||
removeItem={() => removeItem(value)}
|
removeItem={() => removeItem(value)}
|
||||||
|
removeLabel={t("remove")}
|
||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -177,6 +206,7 @@ interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
|
|||||||
item: TItem;
|
item: TItem;
|
||||||
index: number;
|
index: number;
|
||||||
removeItem: () => void;
|
removeItem: () => void;
|
||||||
|
removeLabel: string;
|
||||||
options: CommonWidgetInputProps<"sortableItemList">["options"];
|
options: CommonWidgetInputProps<"sortableItemList">["options"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +215,7 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
|||||||
index,
|
index,
|
||||||
item,
|
item,
|
||||||
removeItem,
|
removeItem,
|
||||||
|
removeLabel,
|
||||||
options,
|
options,
|
||||||
}: ItemProps<TItem, TOptionValue>) => {
|
}: ItemProps<TItem, TOptionValue>) => {
|
||||||
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
|
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
|
||||||
@@ -220,6 +251,7 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
|||||||
key={index}
|
key={index}
|
||||||
item={item}
|
item={item}
|
||||||
removeItem={removeItem}
|
removeItem={removeItem}
|
||||||
|
removeLabel={removeLabel}
|
||||||
rootAttributes={attributes}
|
rootAttributes={attributes}
|
||||||
handle={
|
handle={
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
|
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
} from "./display";
|
} from "./display";
|
||||||
|
|
||||||
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
|
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
|
||||||
const t = useI18n("widget.airQuality");
|
|
||||||
const query = clientApi.widget.airQuality.atLocation.useQuery({
|
const query = clientApi.widget.airQuality.atLocation.useQuery({
|
||||||
latitude: options.location.latitude,
|
latitude: options.location.latitude,
|
||||||
longitude: options.location.longitude,
|
longitude: options.location.longitude,
|
||||||
@@ -45,11 +44,6 @@ export default function AirQualityWidget({ options, width, height, displayMode }
|
|||||||
) : (
|
) : (
|
||||||
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
|
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
|
||||||
)}
|
)}
|
||||||
{query.error && (
|
|
||||||
<Box pos="absolute" top={4} right={4}>
|
|
||||||
<WidgetQueryErrorIndicator error={query.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -177,7 +171,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
|
<ScrollArea h="100%" w="100%">
|
||||||
<Stack gap="md" p="md">
|
<Stack gap="md" p="md">
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import actionTargetClasses from "../common/action-target.module.css";
|
import actionTargetClasses from "../common/action-target.module.css";
|
||||||
import { storedContentToPlainText } from "./content";
|
import { storedContentToPlainText } from "./content";
|
||||||
|
|
||||||
@@ -239,7 +238,6 @@ const AnchorNoteWidgetContent = ({
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<WidgetQueryErrorIndicator error={noteQuery.error} label={t("name")} />
|
|
||||||
{(isEditing || canEdit) && (
|
{(isEditing || canEdit) && (
|
||||||
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
|
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import classes from "./app.module.css";
|
import classes from "./app.module.css";
|
||||||
import { PingDot } from "./ping/ping-dot";
|
import { PingDot } from "./ping/ping-dot";
|
||||||
@@ -24,7 +24,6 @@ import { PingIndicator } from "./ping/ping-indicator";
|
|||||||
|
|
||||||
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
|
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const tApp = useI18n("widget.app");
|
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
||||||
@@ -127,11 +126,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
</AppLink>
|
</AppLink>
|
||||||
{appQuery.error && (
|
|
||||||
<Box pos="absolute" top={4} right={4}>
|
|
||||||
<WidgetQueryErrorIndicator error={appQuery.error} label={tApp("name")} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { getIconUrl } from "@homarr/definitions";
|
|||||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
|
|
||||||
export default function ArchiveTeamWarriorWidget({
|
export default function ArchiveTeamWarriorWidget({
|
||||||
integrationIds,
|
integrationIds,
|
||||||
@@ -50,7 +49,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
|||||||
const t = useI18n("widget.archiveTeamWarrior");
|
const t = useI18n("widget.archiveTeamWarrior");
|
||||||
const tWidgetCommon = useI18n("widget.common");
|
const tWidgetCommon = useI18n("widget.common");
|
||||||
const locale = useCurrentIntlLocale();
|
const locale = useCurrentIntlLocale();
|
||||||
const [data, statusQuery] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
|
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
|
||||||
|
|
||||||
const status = data.status;
|
const status = data.status;
|
||||||
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
|
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
|
||||||
@@ -74,7 +73,6 @@ const ArchiveTeamWarriorWidgetContent = ({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<WidgetQueryErrorIndicator error={statusQuery.error} label={t("name")} />
|
|
||||||
<Badge size="sm" color={getStatusColor(statusKey)}>
|
<Badge size="sm" color={getStatusColor(statusKey)}>
|
||||||
{t(`status.${statusKey}`)}
|
{t(`status.${statusKey}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import { getQueryKey } from "@trpc/react-query";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { formatBytes, formatDuration } from "@homarr/common";
|
import { formatBytes, formatDuration } from "@homarr/common";
|
||||||
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||||
import { AudioStatsContent } from "./audio-stats-content";
|
import { AudioStatsContent } from "./audio-stats-content";
|
||||||
@@ -23,8 +21,6 @@ export default function AudioStatsWidget({
|
|||||||
displayMode = "compact",
|
displayMode = "compact",
|
||||||
widgetRuntimeRef,
|
widgetRuntimeRef,
|
||||||
}: WidgetComponentProps<"audioStats">) {
|
}: WidgetComponentProps<"audioStats">) {
|
||||||
const tAudio = useI18n("widget.audioStats");
|
|
||||||
const tMediaServer = useI18n("widget.mediaServer");
|
|
||||||
const statsInput = { integrationId: integrationIds[0] ?? "" };
|
const statsInput = { integrationId: integrationIds[0] ?? "" };
|
||||||
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
|
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
|
||||||
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
|
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
|
||||||
@@ -56,9 +52,6 @@ export default function AudioStatsWidget({
|
|||||||
if (displayMode === "compact") {
|
if (displayMode === "compact") {
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
|
||||||
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
|
|
||||||
</Box>
|
|
||||||
{summary}
|
{summary}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -73,10 +66,8 @@ export default function AudioStatsWidget({
|
|||||||
return (
|
return (
|
||||||
<Stack h="100%" gap="lg" p="md">
|
<Stack h="100%" gap="lg" p="md">
|
||||||
<div style={{ minHeight: 150 }}>{summary}</div>
|
<div style={{ minHeight: 150 }}>{summary}</div>
|
||||||
{(statsError || streamsError || hasStreamErrors) && (
|
{hasStreamErrors && (
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
|
|
||||||
<WidgetQueryErrorIndicator error={streamsError} label={tMediaServer("name")} />
|
|
||||||
<IntegrationErrorIndicator results={currentStreams} />
|
<IntegrationErrorIndicator results={currentStreams} />
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||||
import { getBeszelAlertsQueryInput } from "./display";
|
import { getBeszelAlertsQueryInput } from "./display";
|
||||||
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
|
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
|
||||||
@@ -126,7 +125,6 @@ export default function BeszelAlertsWidget({
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<WidgetQueryErrorIndicator error={alertsQuery.error} label={t("name")} />
|
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||||
import { isBeszelGridMetricVisible } from "./display";
|
import { isBeszelGridMetricVisible } from "./display";
|
||||||
@@ -460,7 +459,6 @@ export default function BeszelSystemGridWidget({
|
|||||||
displayMode,
|
displayMode,
|
||||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||||
const t = useI18n("widget.beszel");
|
const t = useI18n("widget.beszel");
|
||||||
const tFeature = useI18n("widget.beszelSystemGrid");
|
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||||
@@ -485,7 +483,6 @@ export default function BeszelSystemGridWidget({
|
|||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
|
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
</Group>
|
</Group>
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
@@ -514,7 +511,6 @@ export default function BeszelSystemGridWidget({
|
|||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
|
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
</Group>
|
</Group>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
|||||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||||
|
|
||||||
@@ -144,7 +143,6 @@ export default function BeszelSystemStatsWidget({
|
|||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
|
||||||
<IntegrationErrorIndicator results={systemsResult} />
|
<IntegrationErrorIndicator results={systemsResult} />
|
||||||
</Group>
|
</Group>
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
@@ -184,7 +182,6 @@ export default function BeszelSystemStatsWidget({
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
|
||||||
<IntegrationErrorIndicator results={systemsResult} />
|
<IntegrationErrorIndicator results={systemsResult} />
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
} from "../beszel/_shared/format";
|
} from "../beszel/_shared/format";
|
||||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||||
import { getBeszelTableVisibleMetricKeys } from "./display";
|
import { getBeszelTableVisibleMetricKeys } from "./display";
|
||||||
@@ -453,7 +452,6 @@ export default function BeszelSystemTableWidget({
|
|||||||
<div style={{ position: "relative", height: "100%" }}>
|
<div style={{ position: "relative", height: "100%" }}>
|
||||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,79 +1,198 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MultiSelectProps } from "@mantine/core";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Group, Loader, MultiSelect } from "@mantine/core";
|
import {
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
Avatar,
|
||||||
|
Combobox,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
PillsInput,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
useCombobox,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconCheck, IconLink, IconPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { SortableItemListInput } from "../options";
|
import type { SortableItemListInput } from "../options";
|
||||||
|
import { createDirectBookmark, getBookmarkFaviconUrl, splitBookmarkUrls } from "./bookmark-item";
|
||||||
|
import type { BookmarkItem } from "./bookmark-item";
|
||||||
|
|
||||||
type SelectableApp = RouterOutputs["app"]["selectable"][number];
|
type SelectableApp = RouterOutputs["app"]["selectable"][number];
|
||||||
|
export type BookmarkSelectionItem = SelectableApp | BookmarkItem;
|
||||||
|
|
||||||
export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["AddButton"] = ({
|
const createOptionValue = "$create-url";
|
||||||
|
|
||||||
|
export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, string>["AddButton"] = ({
|
||||||
addItem,
|
addItem,
|
||||||
removeItem,
|
migrateItems,
|
||||||
values,
|
values,
|
||||||
|
initialOptions,
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n("widget.bookmarks.option.items.apps");
|
const t = useI18n("widget.bookmarks.option.items.apps");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const tSelect = useI18n("app.action.select");
|
const tSelect = useI18n("app.action.select");
|
||||||
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
|
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
|
||||||
const appsById = new Map(apps.map((app) => [app.id, app]));
|
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 handleChange = (nextValues: string[]) => {
|
useEffect(() => {
|
||||||
const nextIds = new Set(nextValues);
|
if (migrationStartedRef.current || legacyUrls.length === 0) return;
|
||||||
for (const value of values) {
|
|
||||||
if (!nextIds.has(value)) removeItem(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIds = new Set(values);
|
migrationStartedRef.current = true;
|
||||||
for (const value of nextValues) {
|
const legacyItems = legacyUrls.flatMap((url) => createDirectBookmark(url) ?? []);
|
||||||
if (currentIds.has(value)) continue;
|
migrateItems(legacyItems, { customUrls: [] });
|
||||||
const app = appsById.get(value);
|
}, [legacyUrls, migrateItems]);
|
||||||
if (app) addItem(app);
|
|
||||||
|
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 app = apps.find((candidate) => candidate.id === value);
|
||||||
|
if (app) addItem(app);
|
||||||
|
setSearch("");
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<MultiSelect
|
<Combobox
|
||||||
label={t("label")}
|
store={combobox}
|
||||||
placeholder={t("placeholder")}
|
onOptionSubmit={handleOptionSubmit}
|
||||||
value={values}
|
position="bottom-start"
|
||||||
onChange={handleChange}
|
middlewares={{ flip: true, shift: true }}
|
||||||
searchable
|
styles={{
|
||||||
clearable
|
dropdown: { overflow: "hidden" },
|
||||||
clearSearchOnChange
|
option: { borderRadius: "var(--mantine-radius-sm)" },
|
||||||
hidePickedOptions
|
}}
|
||||||
disabled={isPending || Boolean(error)}
|
>
|
||||||
error={error ? tCommon("error") : undefined}
|
<Combobox.DropdownTarget>
|
||||||
rightSection={isPending ? <Loader size="xs" /> : undefined}
|
<PillsInput
|
||||||
nothingFoundMessage={tSelect("notFound")}
|
label={t("label")}
|
||||||
renderOption={renderAppOption}
|
description={t("description")}
|
||||||
data={apps.map((app) => ({
|
error={error ? tCommon("error") : undefined}
|
||||||
value: app.id,
|
onClick={() => combobox.openDropdown()}
|
||||||
label: app.name,
|
leftSection={
|
||||||
iconUrl: app.iconUrl,
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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];
|
||||||
|
});
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
.card {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
|
|
||||||
transition:
|
|
||||||
background-color 120ms ease,
|
|
||||||
border-color 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover,
|
|
||||||
.card:focus-within {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62));
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmarkButton:focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.card {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactScrollViewport > div {
|
|
||||||
display: flex !important;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactScrollContent {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"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,532 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
export { default } from "./bookmarks-widget";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
|
||||||
import { useElementSize } from "@mantine/hooks";
|
|
||||||
import { IconLink } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
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"];
|
|
||||||
|
|
||||||
const COMPACT_GRID_GAP = 4;
|
|
||||||
|
|
||||||
export interface BookmarkDisplayItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
iconUrl?: string;
|
|
||||||
href: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompactBookmarkLayout {
|
|
||||||
columns: number;
|
|
||||||
hideHostname: boolean;
|
|
||||||
hideTitle: boolean;
|
|
||||||
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;
|
|
||||||
let columns = 1;
|
|
||||||
if (isGrid) {
|
|
||||||
columns = Math.max(1, Math.min(count, Math.floor((width + COMPACT_GRID_GAP) / (targetWidth + COMPACT_GRID_GAP))));
|
|
||||||
} else if (layout === "row") {
|
|
||||||
columns = count;
|
|
||||||
}
|
|
||||||
const rows = Math.max(1, Math.ceil(count / columns));
|
|
||||||
const cellWidth = (width - (columns - 1) * COMPACT_GRID_GAP) / 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,
|
|
||||||
minimumItemSize: usesHorizontalItems ? 48 : 96,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BookmarksWidget({
|
|
||||||
options,
|
|
||||||
itemId,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
displayMode,
|
|
||||||
}: WidgetComponentProps<"bookmarks">) {
|
|
||||||
const board = useRequiredBoard();
|
|
||||||
const { ref: compactContentRef, width: compactContentWidth } = useElementSize<HTMLDivElement>();
|
|
||||||
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(compactContentWidth || width, height, data.length, options.layout),
|
|
||||||
[compactContentWidth, data.length, height, options.layout, width],
|
|
||||||
);
|
|
||||||
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 ref={compactContentRef} className={classes.compactScrollContent}>
|
|
||||||
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
|
|
||||||
<GridLayout
|
|
||||||
data={data}
|
|
||||||
columns={compactLayout.columns}
|
|
||||||
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}
|
|
||||||
bg="transparent"
|
|
||||||
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}
|
|
||||||
bg="transparent"
|
|
||||||
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;
|
|
||||||
columns: number;
|
|
||||||
minimumItemHeight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GridLayout = ({
|
|
||||||
data,
|
|
||||||
hideTitle,
|
|
||||||
hideIcon,
|
|
||||||
hideHostname,
|
|
||||||
openNewTab,
|
|
||||||
withBorder,
|
|
||||||
itemDirection,
|
|
||||||
hasIconColor,
|
|
||||||
columns,
|
|
||||||
minimumItemHeight,
|
|
||||||
}: GridLayoutProps) => {
|
|
||||||
const board = useRequiredBoard();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex gap={COMPACT_GRID_GAP} 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((100% - calc(${COMPACT_GRID_GAP * (columns - 1)}px * var(--mantine-scale, 1))) / ${columns})`,
|
|
||||||
minWidth: 0,
|
|
||||||
minHeight: `calc(${minimumItemHeight}px * var(--mantine-scale, 1))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
h="100%"
|
|
||||||
className={classes.card}
|
|
||||||
bg="transparent"
|
|
||||||
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,14 +1,13 @@
|
|||||||
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconBookmark, IconX } from "@tabler/icons-react";
|
import { IconBookmark, IconLink, IconX } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
import { getSafeAppHref } from "../common/application-url";
|
|
||||||
import { createWidgetDefinition } from "../definition";
|
import { createWidgetDefinition } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
import { BookmarkAddButton } from "./add-button";
|
import { BookmarkAddButton } from "./add-button";
|
||||||
|
import type { BookmarkSelectionItem } from "./add-button";
|
||||||
|
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||||
icon: IconBookmark,
|
icon: IconBookmark,
|
||||||
@@ -18,42 +17,63 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
|||||||
return optionsBuilder.from((factory) => ({
|
return optionsBuilder.from((factory) => ({
|
||||||
title: factory.text(),
|
title: factory.text(),
|
||||||
layout: factory.select({
|
layout: factory.select({
|
||||||
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
|
options: (["adaptive", "column", "row", "grid", "gridHorizontal", "icons"] as const).map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: (t) => {
|
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 === "grid") return t("widget.common.layout.option.grid");
|
||||||
if (value === "row") return t("widget.common.layout.option.horizontal");
|
if (value === "row") return t("widget.common.layout.option.horizontal");
|
||||||
if (value === "column") return t("widget.common.layout.option.vertical");
|
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");
|
return t("widget.bookmarks.option.layout.option.gridHorizontal.label");
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
defaultValue: "column",
|
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",
|
||||||
}),
|
}),
|
||||||
hideTitle: factory.switch({ defaultValue: false }),
|
hideTitle: factory.switch({ defaultValue: false }),
|
||||||
hideIcon: factory.switch({ defaultValue: false }),
|
hideIcon: factory.switch({ defaultValue: false }),
|
||||||
hideHostname: factory.switch({ defaultValue: false }),
|
hideHostname: factory.switch({ defaultValue: false }),
|
||||||
openNewTab: factory.switch({ defaultValue: true }),
|
openNewTab: factory.switch({ defaultValue: true }),
|
||||||
withBorder: factory.switch({ defaultValue: false }),
|
withBorder: factory.switch({ defaultValue: false }),
|
||||||
customUrls: factory.multiText({
|
customUrls: factory.internal({ defaultValue: [] as string[] }),
|
||||||
validate: z.string().refine((value) => getSafeAppHref(value) !== undefined),
|
items: factory.sortableItemList<BookmarkSelectionItem, string>({
|
||||||
withDescription: true,
|
ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => {
|
||||||
}),
|
const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href);
|
||||||
items: factory.sortableItemList<RouterOutputs["app"]["selectable"][number], string>({
|
|
||||||
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
|
|
||||||
return (
|
return (
|
||||||
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap" miw={0}>
|
||||||
{handle}
|
{handle}
|
||||||
|
|
||||||
<Group>
|
<Group wrap="nowrap" miw={0}>
|
||||||
<Avatar src={item.iconUrl} alt={item.name} />
|
<Avatar src={iconUrl} name={item.name} color="gray" radius="sm">
|
||||||
<Stack gap={0}>
|
<IconLink size={20} />
|
||||||
<Text>{item.name}</Text>
|
</Avatar>
|
||||||
|
<Stack gap={0} miw={0}>
|
||||||
|
<Text fw={600} truncate>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{item.href}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
|
<ActionIcon variant="subtle" color="red" onClick={removeItem} aria-label={removeLabel}>
|
||||||
<IconX size="var(--mantine-font-size-xl)" />
|
<IconX size="var(--mantine-font-size-xl)" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -62,7 +82,14 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
|||||||
AddButton: BookmarkAddButton,
|
AddButton: BookmarkAddButton,
|
||||||
uniqueIdentifier: (item) => item.id,
|
uniqueIdentifier: (item) => item.id,
|
||||||
useData: (initialIds) => {
|
useData: (initialIds) => {
|
||||||
const { data, error, isLoading } = clientApi.app.byIds.useQuery(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) ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -1,54 +1,69 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import {
|
import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item";
|
||||||
createCustomBookmark,
|
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
|
||||||
getAdvancedBookmarkColumns,
|
|
||||||
getBookmarkHostname,
|
|
||||||
getCompactBookmarkLayout,
|
|
||||||
} from "./component";
|
|
||||||
|
|
||||||
describe("bookmark display helpers", () => {
|
describe("bookmark display helpers", () => {
|
||||||
test("uses bounded advanced columns", () => {
|
test("uses explicit width breakpoints for advanced columns", () => {
|
||||||
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
|
expect(
|
||||||
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
|
getBookmarkDisplayPlan({ advanced: true, width: 900, height: 500, itemCount: 8, layout: "adaptive" }),
|
||||||
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
|
).toMatchObject({
|
||||||
|
columns: 3,
|
||||||
|
orientation: "horizontal",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
getBookmarkDisplayPlan({ advanced: true, width: 120, height: 500, itemCount: 8, layout: "adaptive" }),
|
||||||
|
).toMatchObject({
|
||||||
|
columns: 1,
|
||||||
|
orientation: "horizontal",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses container geometry to keep compact cards legible", () => {
|
test("selects an adaptive plan from width and height breakpoints", () => {
|
||||||
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
|
expect(
|
||||||
|
getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }),
|
||||||
|
).toMatchObject({
|
||||||
columns: 2,
|
columns: 2,
|
||||||
hideHostname: false,
|
itemHeight: 128,
|
||||||
hideTitle: false,
|
orientation: "horizontal",
|
||||||
minimumItemSize: 48,
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
|
getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }),
|
||||||
columns: 4,
|
).toMatchObject({
|
||||||
hideHostname: true,
|
columns: 1,
|
||||||
hideTitle: true,
|
orientation: "icon",
|
||||||
minimumItemSize: 96,
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not crash on malformed bookmark URLs", () => {
|
test("keeps compact cards visible when icons are hidden", () => {
|
||||||
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
|
const plan = getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "icons" });
|
||||||
expect(getBookmarkHostname("not a URL")).toBeUndefined();
|
expect(
|
||||||
expect(getBookmarkHostname(null)).toBeUndefined();
|
getBookmarkCardDisplay({ advanced: false, hideHostname: false, hideIcon: true, hideTitle: false, plan }),
|
||||||
|
).toEqual({
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: false,
|
||||||
|
showIcon: false,
|
||||||
|
showTitle: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates standalone bookmarks without app records", () => {
|
test("creates standalone bookmarks without app records", () => {
|
||||||
expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({
|
expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({
|
||||||
id: "custom-link:https://homarr.dev/docs",
|
id: "url:https://homarr.dev/docs",
|
||||||
name: "homarr.dev",
|
name: "homarr.dev",
|
||||||
description: null,
|
description: null,
|
||||||
href: "https://homarr.dev/docs",
|
href: "https://homarr.dev/docs",
|
||||||
});
|
});
|
||||||
expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({
|
expect(normalizeBookmarkUrl("homarr.dev/docs")).toBe("https://homarr.dev/docs");
|
||||||
id: "custom-link:https://homarr.dev/docs",
|
expect(normalizeBookmarkUrl("javascript:alert(1)")).toBeUndefined();
|
||||||
name: "homarr.dev",
|
});
|
||||||
description: null,
|
|
||||||
href: "https://homarr.dev/docs",
|
test("splits URL lists without corrupting commas inside a URL", () => {
|
||||||
});
|
expect(splitBookmarkUrls("https://one.example, two.example")).toEqual(["https://one.example", "two.example"]);
|
||||||
expect(createCustomBookmark("javascript:alert(1)")).toBeNull();
|
expect(splitBookmarkUrls("https://maps.example/@52.5,13.4,15z")).toEqual(["https://maps.example/@52.5,13.4,15z"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
|
export type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
||||||
|
export type BookmarkOrientation = "horizontal" | "vertical" | "icon";
|
||||||
|
|
||||||
|
export interface BookmarkDisplayPlan {
|
||||||
|
columns: number;
|
||||||
|
horizontalScroll: boolean;
|
||||||
|
itemHeight: number;
|
||||||
|
itemWidth: number;
|
||||||
|
orientation: BookmarkOrientation;
|
||||||
|
showHostname: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookmarkCardDisplay {
|
||||||
|
orientation: BookmarkOrientation;
|
||||||
|
showHostname: boolean;
|
||||||
|
showIcon: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBookmarkCardDisplay = ({
|
||||||
|
advanced,
|
||||||
|
hideHostname,
|
||||||
|
hideIcon,
|
||||||
|
hideTitle,
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
advanced: boolean;
|
||||||
|
hideHostname: boolean;
|
||||||
|
hideIcon: boolean;
|
||||||
|
hideTitle: boolean;
|
||||||
|
plan: BookmarkDisplayPlan;
|
||||||
|
}): BookmarkCardDisplay => {
|
||||||
|
let orientation = plan.orientation;
|
||||||
|
let showTitle = advanced || (!hideTitle && plan.showTitle);
|
||||||
|
if (!advanced && hideIcon && !hideTitle && !showTitle) {
|
||||||
|
orientation = "horizontal";
|
||||||
|
showTitle = true;
|
||||||
|
}
|
||||||
|
const showHostname = advanced || (!hideHostname && plan.showHostname);
|
||||||
|
const showIcon = advanced || !hideIcon || (!showTitle && !showHostname);
|
||||||
|
|
||||||
|
return { orientation, showHostname, showIcon, showTitle };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BookmarkWidthBreakpoint {
|
||||||
|
adaptiveColumns: number;
|
||||||
|
adaptiveOrientation: BookmarkOrientation;
|
||||||
|
advancedColumns: number;
|
||||||
|
compactColumns: number;
|
||||||
|
gridColumns: number;
|
||||||
|
iconColumns: number;
|
||||||
|
minWidth: number;
|
||||||
|
rowItemWidth: number;
|
||||||
|
showHostname: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthBreakpoints = [
|
||||||
|
{
|
||||||
|
minWidth: 900,
|
||||||
|
adaptiveColumns: 4,
|
||||||
|
advancedColumns: 3,
|
||||||
|
compactColumns: 4,
|
||||||
|
gridColumns: 5,
|
||||||
|
iconColumns: 10,
|
||||||
|
rowItemWidth: 180,
|
||||||
|
adaptiveOrientation: "vertical",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 720,
|
||||||
|
adaptiveColumns: 3,
|
||||||
|
advancedColumns: 2,
|
||||||
|
compactColumns: 3,
|
||||||
|
gridColumns: 4,
|
||||||
|
iconColumns: 8,
|
||||||
|
rowItemWidth: 172,
|
||||||
|
adaptiveOrientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 480,
|
||||||
|
adaptiveColumns: 2,
|
||||||
|
advancedColumns: 2,
|
||||||
|
compactColumns: 2,
|
||||||
|
gridColumns: 3,
|
||||||
|
iconColumns: 6,
|
||||||
|
rowItemWidth: 160,
|
||||||
|
adaptiveOrientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 300,
|
||||||
|
adaptiveColumns: 2,
|
||||||
|
advancedColumns: 1,
|
||||||
|
compactColumns: 1,
|
||||||
|
gridColumns: 2,
|
||||||
|
iconColumns: 4,
|
||||||
|
rowItemWidth: 144,
|
||||||
|
adaptiveOrientation: "horizontal",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 0,
|
||||||
|
adaptiveColumns: 1,
|
||||||
|
advancedColumns: 1,
|
||||||
|
compactColumns: 1,
|
||||||
|
gridColumns: 1,
|
||||||
|
iconColumns: 2,
|
||||||
|
rowItemWidth: 112,
|
||||||
|
adaptiveOrientation: "icon",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
|
},
|
||||||
|
] as const satisfies readonly BookmarkWidthBreakpoint[];
|
||||||
|
|
||||||
|
interface BookmarkHeightBreakpoint {
|
||||||
|
columnItemHeight: number;
|
||||||
|
itemHeight: number;
|
||||||
|
minHeight: number;
|
||||||
|
orientation: BookmarkOrientation;
|
||||||
|
rowItemHeight: number;
|
||||||
|
showHostname: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightBreakpoints = [
|
||||||
|
{
|
||||||
|
minHeight: 300,
|
||||||
|
columnItemHeight: 72,
|
||||||
|
itemHeight: 128,
|
||||||
|
rowItemHeight: 128,
|
||||||
|
orientation: "vertical",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minHeight: 180,
|
||||||
|
columnItemHeight: 64,
|
||||||
|
itemHeight: 128,
|
||||||
|
rowItemHeight: 112,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minHeight: 110,
|
||||||
|
columnItemHeight: 56,
|
||||||
|
itemHeight: 80,
|
||||||
|
rowItemHeight: 80,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minHeight: 0,
|
||||||
|
columnItemHeight: 48,
|
||||||
|
itemHeight: 56,
|
||||||
|
rowItemHeight: 56,
|
||||||
|
orientation: "icon",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
|
},
|
||||||
|
] as const satisfies readonly BookmarkHeightBreakpoint[];
|
||||||
|
|
||||||
|
export const getBookmarkDisplayPlan = ({
|
||||||
|
advanced,
|
||||||
|
height,
|
||||||
|
itemCount,
|
||||||
|
layout,
|
||||||
|
width,
|
||||||
|
}: {
|
||||||
|
advanced: boolean;
|
||||||
|
height: number;
|
||||||
|
itemCount: number;
|
||||||
|
layout: BookmarkLayout;
|
||||||
|
width: number;
|
||||||
|
}): BookmarkDisplayPlan => {
|
||||||
|
const count = Math.max(1, itemCount);
|
||||||
|
const normalizedWidth = Math.max(0, width);
|
||||||
|
const normalizedHeight = Math.max(0, height);
|
||||||
|
const widthSettings = widthBreakpoints.find(({ minWidth }) => normalizedWidth >= minWidth);
|
||||||
|
const heightSettings = heightBreakpoints.find(({ minHeight }) => normalizedHeight >= minHeight);
|
||||||
|
if (!widthSettings || !heightSettings) throw new Error("Bookmark breakpoints must cover all widget sizes");
|
||||||
|
|
||||||
|
if (advanced) {
|
||||||
|
return {
|
||||||
|
columns: Math.min(count, widthSettings.advancedColumns),
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: 104,
|
||||||
|
itemWidth: 260,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "row") {
|
||||||
|
let orientation: BookmarkOrientation = "vertical";
|
||||||
|
if (!heightSettings.showTitle) orientation = "icon";
|
||||||
|
return {
|
||||||
|
columns: count,
|
||||||
|
horizontalScroll: true,
|
||||||
|
itemHeight: heightSettings.rowItemHeight,
|
||||||
|
itemWidth: widthSettings.rowItemWidth,
|
||||||
|
orientation,
|
||||||
|
showHostname: widthSettings.showHostname && heightSettings.showHostname,
|
||||||
|
showTitle: widthSettings.showTitle && heightSettings.showTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "column") {
|
||||||
|
return {
|
||||||
|
columns: 1,
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: heightSettings.columnItemHeight,
|
||||||
|
itemWidth: width,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: widthSettings.showHostname && heightSettings.showHostname,
|
||||||
|
showTitle: widthSettings.showTitle && heightSettings.showTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "icons") {
|
||||||
|
return {
|
||||||
|
columns: Math.min(count, widthSettings.iconColumns),
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: 56,
|
||||||
|
itemWidth: 56,
|
||||||
|
orientation: "icon",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let columns: number = widthSettings.compactColumns;
|
||||||
|
let orientation: BookmarkOrientation = "horizontal";
|
||||||
|
if (layout === "grid") {
|
||||||
|
columns = widthSettings.gridColumns;
|
||||||
|
orientation = "vertical";
|
||||||
|
}
|
||||||
|
if (layout === "adaptive") {
|
||||||
|
columns = widthSettings.adaptiveColumns;
|
||||||
|
orientation = widthSettings.adaptiveOrientation;
|
||||||
|
if (heightSettings.orientation === "icon") orientation = "icon";
|
||||||
|
if (heightSettings.orientation === "horizontal" && orientation === "vertical") orientation = "horizontal";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns: Math.min(count, columns),
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: heightSettings.itemHeight,
|
||||||
|
itemWidth: widthSettings.rowItemWidth,
|
||||||
|
orientation,
|
||||||
|
showHostname: orientation !== "icon" && widthSettings.showHostname && heightSettings.showHostname,
|
||||||
|
showTitle: orientation !== "icon" && widthSettings.showTitle && heightSettings.showTitle,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,11 +4,12 @@ import { apps } from "@homarr/db/schema";
|
|||||||
|
|
||||||
import type { Prefetch } from "../definition";
|
import type { Prefetch } from "../definition";
|
||||||
import { createTrpcQueryKey } from "../trpc-query-key";
|
import { createTrpcQueryKey } from "../trpc-query-key";
|
||||||
|
import { getDirectBookmarkUrl } from "./bookmark-item";
|
||||||
|
|
||||||
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
|
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
|
||||||
|
|
||||||
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
||||||
const appIds = items.flatMap((item) => item.options.items);
|
const appIds = items.flatMap((item) => item.options.items.filter((value) => !getDirectBookmarkUrl(value)));
|
||||||
const distinctAppIds = [...new Set(appIds)];
|
const distinctAppIds = [...new Set(appIds)];
|
||||||
|
|
||||||
const dbApps = await db.query.apps.findMany({
|
const dbApps = await db.query.apps.findMany({
|
||||||
@@ -16,13 +17,14 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.options.items.length === 0) {
|
const itemAppIds = item.options.items.filter((value) => !getDirectBookmarkUrl(value));
|
||||||
|
if (itemAppIds.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
createTrpcQueryKey("app.byIds", item.options.items),
|
createTrpcQueryKey("app.byIds", itemAppIds),
|
||||||
dbApps.filter((app) => item.options.items.includes(app.id)),
|
dbApps.filter((app) => itemAppIds.includes(app.id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export const CalendarEventList = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
offsetScrollbars
|
|
||||||
pt={5}
|
pt={5}
|
||||||
w="100%"
|
w="100%"
|
||||||
h={fillHeight ? "100%" : undefined}
|
h={fillHeight ? "100%" : undefined}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import actionTargetClasses from "../common/action-target.module.css";
|
import actionTargetClasses from "../common/action-target.module.css";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { CalendarDay } from "./calender-day";
|
import { CalendarDay } from "./calender-day";
|
||||||
@@ -54,7 +54,6 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
|
|||||||
events={[]}
|
events={[]}
|
||||||
failedIntegrations={[]}
|
failedIntegrations={[]}
|
||||||
isPending={false}
|
isPending={false}
|
||||||
queryError={undefined}
|
|
||||||
month={month}
|
month={month}
|
||||||
setMonth={setMonth}
|
setMonth={setMonth}
|
||||||
/>
|
/>
|
||||||
@@ -100,13 +99,14 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
|
|||||||
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
|
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(calendarQuery)) return <WidgetQueryLoadingState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CalendarBase
|
<CalendarBase
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
events={events}
|
events={events}
|
||||||
failedIntegrations={failedIntegrations}
|
failedIntegrations={failedIntegrations}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
queryError={calendarQuery.error}
|
|
||||||
month={month}
|
month={month}
|
||||||
setMonth={setMonth}
|
setMonth={setMonth}
|
||||||
options={options}
|
options={options}
|
||||||
@@ -120,7 +120,6 @@ interface CalendarBaseProps {
|
|||||||
events: CalendarEventWithSource[];
|
events: CalendarEventWithSource[];
|
||||||
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
|
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
queryError: unknown;
|
|
||||||
month: Date;
|
month: Date;
|
||||||
setMonth: (date: Date) => void;
|
setMonth: (date: Date) => void;
|
||||||
options: WidgetComponentProps<"calendar">["options"];
|
options: WidgetComponentProps<"calendar">["options"];
|
||||||
@@ -132,13 +131,11 @@ const CalendarBase = ({
|
|||||||
events,
|
events,
|
||||||
failedIntegrations,
|
failedIntegrations,
|
||||||
isPending,
|
isPending,
|
||||||
queryError,
|
|
||||||
month,
|
month,
|
||||||
setMonth,
|
setMonth,
|
||||||
options,
|
options,
|
||||||
displayMode,
|
displayMode,
|
||||||
}: CalendarBaseProps) => {
|
}: CalendarBaseProps) => {
|
||||||
const t = useI18n("widget.calendar");
|
|
||||||
const locale = useCurrentIntlLocale();
|
const locale = useCurrentIntlLocale();
|
||||||
const { firstDayOfWeek } = useSettings();
|
const { firstDayOfWeek } = useSettings();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
@@ -170,8 +167,6 @@ const CalendarBase = ({
|
|||||||
events={agendaEvents}
|
events={agendaEvents}
|
||||||
failedIntegrations={failedIntegrations}
|
failedIntegrations={failedIntegrations}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
queryError={queryError}
|
|
||||||
queryErrorLabel={t("name")}
|
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
month={month}
|
month={month}
|
||||||
@@ -180,8 +175,6 @@ const CalendarBase = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasErrors = failedIntegrations.length > 0 || Boolean(queryError);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
|
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
|
||||||
<Calendar
|
<Calendar
|
||||||
@@ -194,13 +187,11 @@ const CalendarBase = ({
|
|||||||
date={month}
|
date={month}
|
||||||
maxLevel="month"
|
maxLevel="month"
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
static={isEditMode}
|
|
||||||
className={classes.calendar}
|
className={classes.calendar}
|
||||||
w="100%"
|
w="100%"
|
||||||
h="100%"
|
h="100%"
|
||||||
styles={{
|
styles={{
|
||||||
calendarHeaderControl: {
|
calendarHeaderControl: {
|
||||||
pointerEvents: isEditMode ? "none" : undefined,
|
|
||||||
borderRadius: "md",
|
borderRadius: "md",
|
||||||
height: isSmall ? "1.5rem" : undefined,
|
height: isSmall ? "1.5rem" : undefined,
|
||||||
width: isSmall ? "1.5rem" : undefined,
|
width: isSmall ? "1.5rem" : undefined,
|
||||||
@@ -249,17 +240,16 @@ const CalendarBase = ({
|
|||||||
<CalendarDay
|
<CalendarDay
|
||||||
date={dayjs(tileDate).toDate()}
|
date={dayjs(tileDate).toDate()}
|
||||||
events={eventsForDate}
|
events={eventsForDate}
|
||||||
disabled={isEditMode || eventsForDate.length === 0}
|
disabled={eventsForDate.length === 0}
|
||||||
rootWidth={width}
|
rootWidth={width}
|
||||||
rootHeight={height}
|
rootHeight={height}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{hasErrors && (
|
{failedIntegrations.length > 0 && (
|
||||||
<Group className={classes.errorIndicator} gap={0} pos="absolute">
|
<Group className={classes.errorIndicator} gap={0} pos="absolute">
|
||||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||||
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -270,8 +260,6 @@ interface CalendarAgendaProps {
|
|||||||
events: CalendarEventWithSource[];
|
events: CalendarEventWithSource[];
|
||||||
failedIntegrations: CalendarBaseProps["failedIntegrations"];
|
failedIntegrations: CalendarBaseProps["failedIntegrations"];
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
queryError: unknown;
|
|
||||||
queryErrorLabel: string;
|
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
month: Date;
|
month: Date;
|
||||||
@@ -282,8 +270,6 @@ const CalendarAgenda = ({
|
|||||||
events,
|
events,
|
||||||
failedIntegrations,
|
failedIntegrations,
|
||||||
isPending,
|
isPending,
|
||||||
queryError,
|
|
||||||
queryErrorLabel,
|
|
||||||
isEditMode,
|
isEditMode,
|
||||||
locale,
|
locale,
|
||||||
month,
|
month,
|
||||||
@@ -331,7 +317,6 @@ const CalendarAgenda = ({
|
|||||||
</Group>
|
</Group>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||||
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,6 @@ export const AdvancedClockView = ({
|
|||||||
<ScrollArea
|
<ScrollArea
|
||||||
h="100%"
|
h="100%"
|
||||||
w="100%"
|
w="100%"
|
||||||
type="auto"
|
|
||||||
offsetScrollbars
|
|
||||||
scrollbarSize={6}
|
|
||||||
aria-label={t("worldClock.advancedLabel")}
|
aria-label={t("worldClock.advancedLabel")}
|
||||||
>
|
>
|
||||||
<Stack gap="md" p={{ base: "sm", sm: "md" }}>
|
<Stack gap="md" p={{ base: "sm", sm: "md" }}>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||||
import { WeatherDescription } from "../weather/icon";
|
import { WeatherDescription } from "../weather/icon";
|
||||||
|
|
||||||
@@ -25,17 +24,16 @@ export const ClockWeatherSummary = ({
|
|||||||
animateIcon,
|
animateIcon,
|
||||||
detailed,
|
detailed,
|
||||||
}: ClockWeatherSummaryProps) => {
|
}: ClockWeatherSummaryProps) => {
|
||||||
const t = useI18n();
|
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||||
const weather = weatherQuery.data;
|
const weather = weatherQuery.data;
|
||||||
|
|
||||||
if (!weather) {
|
if (!weather) {
|
||||||
return isInitialWidgetQueryPending(weatherQuery) ? (
|
if (isInitialWidgetQueryPending(weatherQuery)) {
|
||||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
return <Loader size="xs" aria-label={tCommon("action.loading")} />;
|
||||||
) : (
|
}
|
||||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
|
||||||
);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||||
@@ -72,7 +70,6 @@ export const ClockWeatherSummary = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -22,6 +22,16 @@
|
|||||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.homarr-data-table .mantine-datatable-scroll-area-scrollbar[data-state="visible"]:hover {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.homarr-data-table th {
|
.homarr-data-table th {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
|
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
|
||||||
import { DataTable } from "mantine-datatable";
|
import { DataTable } from "mantine-datatable";
|
||||||
|
|
||||||
|
import { scrollAreaDefaultProps } from "@homarr/ui";
|
||||||
|
|
||||||
const fallbackColumnWidth = 60;
|
const fallbackColumnWidth = 60;
|
||||||
|
|
||||||
const getColumnWidth = (width: string | number | undefined): number => {
|
const getColumnWidth = (width: string | number | undefined): number => {
|
||||||
@@ -149,11 +151,11 @@ export function HomarrDataTable<T>({
|
|||||||
defaultColumnProps={{
|
defaultColumnProps={{
|
||||||
...defaultColumnProps,
|
...defaultColumnProps,
|
||||||
noWrap: defaultColumnProps?.noWrap ?? true,
|
noWrap: defaultColumnProps?.noWrap ?? true,
|
||||||
draggable: !isEditMode,
|
draggable: true,
|
||||||
resizable: !isEditMode,
|
resizable: true,
|
||||||
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
|
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
|
||||||
}}
|
}}
|
||||||
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
|
scrollAreaProps={{ ...scrollAreaDefaultProps, ...scrollAreaProps }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,10 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator";
|
import { WidgetQueryLoadingState } from "./query-state-indicator";
|
||||||
|
|
||||||
vi.mock("@homarr/translation/client", () => ({
|
vi.mock("@homarr/translation/client", () => ({
|
||||||
useI18n: () => (key: string, values?: { widget?: string }) => {
|
useI18n: () => (key: string) => {
|
||||||
if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`;
|
|
||||||
if (key === "loading") return "Loading widget data";
|
if (key === "loading") return "Loading widget data";
|
||||||
return key;
|
return key;
|
||||||
},
|
},
|
||||||
@@ -26,7 +25,7 @@ const matchMedia = (query: string) => ({
|
|||||||
dispatchEvent: () => false,
|
dispatchEvent: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WidgetQueryErrorIndicator", () => {
|
describe("WidgetQueryLoadingState", () => {
|
||||||
let host: HTMLDivElement | undefined;
|
let host: HTMLDivElement | undefined;
|
||||||
let root: ReturnType<typeof createRoot> | undefined;
|
let root: ReturnType<typeof createRoot> | undefined;
|
||||||
|
|
||||||
@@ -36,28 +35,6 @@ describe("WidgetQueryErrorIndicator", () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses a localized label without exposing the raw failure", async () => {
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal("matchMedia", matchMedia);
|
|
||||||
host = document.createElement("div");
|
|
||||||
document.body.append(host);
|
|
||||||
root = createRoot(host);
|
|
||||||
const secret = "http://admin:password@private.local/path?token=secret";
|
|
||||||
|
|
||||||
await act(async () =>
|
|
||||||
root?.render(
|
|
||||||
createElement(
|
|
||||||
MantineProvider,
|
|
||||||
null,
|
|
||||||
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull();
|
|
||||||
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("announces the loading state to assistive technology", async () => {
|
test("announces the loading state to assistive technology", async () => {
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
vi.stubGlobal("matchMedia", matchMedia);
|
vi.stubGlobal("matchMedia", matchMedia);
|
||||||
|
|||||||
@@ -1,39 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core";
|
import { Center, Loader, VisuallyHidden } from "@mantine/core";
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface WidgetQueryErrorIndicatorProps {
|
|
||||||
error: unknown;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
|
|
||||||
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
|
|
||||||
const t = useI18n("common.widgetQuery");
|
|
||||||
if (!error) return null;
|
|
||||||
|
|
||||||
const accessibleLabel = t("stale", { widget: label });
|
|
||||||
return (
|
|
||||||
<Tooltip label={accessibleLabel} position="left" withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
component="span"
|
|
||||||
color="orange"
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
role="note"
|
|
||||||
aria-label={accessibleLabel}
|
|
||||||
tabIndex={0}
|
|
||||||
style={{ cursor: "help" }}
|
|
||||||
>
|
|
||||||
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WidgetQueryLoadingState() {
|
export function WidgetQueryLoadingState() {
|
||||||
const t = useI18n("common.widgetQuery");
|
const t = useI18n("common.widgetQuery");
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils";
|
import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils";
|
||||||
@@ -148,9 +146,6 @@ const FailedSourceBadges = ({
|
|||||||
{t("source.unavailable", { source: source.integrationName })}
|
{t("source.unavailable", { source: source.integrationName })}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
<Box ml="auto">
|
|
||||||
<WidgetQueryErrorIndicator error={queryError} label={getIntegrationName("coolify")} />
|
|
||||||
</Box>
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -184,7 +184,7 @@ export default function CountdownWidget({
|
|||||||
onNotificationsChange={setNotifications}
|
onNotificationsChange={setNotifications}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollArea type="auto" style={{ flex: 1, minHeight: 0 }}>
|
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||||
<Stack gap="md" pb="xs">
|
<Stack gap="md" pb="xs">
|
||||||
<EventSection
|
<EventSection
|
||||||
title={t("upcoming")}
|
title={t("upcoming")}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
|||||||
import type { widgetKind } from ".";
|
import type { widgetKind } from ".";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import actionTargetClasses from "../../common/action-target.module.css";
|
import actionTargetClasses from "../../common/action-target.module.css";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
import TimerModal from "./TimerModal";
|
import TimerModal from "./TimerModal";
|
||||||
@@ -68,7 +68,6 @@ export default function DnsHoleControlsWidget({
|
|||||||
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
|
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
|
||||||
const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? [];
|
const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||||
const summaries = summaryResults.filter(isAvailableDnsSummaryResult);
|
const summaries = summaryResults.filter(isAvailableDnsSummaryResult);
|
||||||
const { isPending: isSummaryPending } = summaryQuery;
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -123,7 +122,6 @@ export default function DnsHoleControlsWidget({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const t = useI18n("widget.dnsHoleControls");
|
const t = useI18n("widget.dnsHoleControls");
|
||||||
const tCommon = useI18n("common");
|
|
||||||
|
|
||||||
// Timer modal setup
|
// Timer modal setup
|
||||||
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
|
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
|
||||||
@@ -142,15 +140,7 @@ export default function DnsHoleControlsWidget({
|
|||||||
setBulkPending(false);
|
setBulkPending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isSummaryPending) {
|
if (isInitialWidgetQueryPending(summaryQuery)) return <WidgetQueryLoadingState />;
|
||||||
return (
|
|
||||||
<Stack h="100%" justify="center" align="center">
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{tCommon("action.loading")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
@@ -165,7 +155,6 @@ export default function DnsHoleControlsWidget({
|
|||||||
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={summaryResults} />
|
<IntegrationErrorIndicator results={summaryResults} />
|
||||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
{controlAllButtonsVisible && (
|
{controlAllButtonsVisible && (
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
import type { widgetKind } from ".";
|
import type { widgetKind } from ".";
|
||||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
|
|
||||||
export default function DnsHoleSummaryWidget({
|
export default function DnsHoleSummaryWidget({
|
||||||
options,
|
options,
|
||||||
@@ -44,10 +44,8 @@ export default function DnsHoleSummaryWidget({
|
|||||||
integrationIds,
|
integrationIds,
|
||||||
});
|
});
|
||||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||||
const { isPending } = summaryQuery;
|
|
||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const tDns = useI18n("widget.dnsHoleSummary");
|
const tDns = useI18n("widget.dnsHoleSummary");
|
||||||
const tWidgetCommon = useI18n("widget.common");
|
const tWidgetCommon = useI18n("widget.common");
|
||||||
|
|
||||||
@@ -56,22 +54,13 @@ export default function DnsHoleSummaryWidget({
|
|||||||
const layoutProps = boxPropsByLayout(options.layout);
|
const layoutProps = boxPropsByLayout(options.layout);
|
||||||
const showSourceStatuses = successfulSummaries.length > 1 && width >= 240 && height >= 220;
|
const showSourceStatuses = successfulSummaries.length > 1 && width >= 240 && height >= 220;
|
||||||
|
|
||||||
if (isPending) {
|
if (isInitialWidgetQueryPending(summaryQuery)) return <WidgetQueryLoadingState />;
|
||||||
return (
|
|
||||||
<Stack h="100%" justify="center" align="center">
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{tCommon("action.loading")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap={0} pos="relative">
|
<Stack h="100%" gap={0} pos="relative">
|
||||||
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={summaries} />
|
<IntegrationErrorIndicator results={summaries} />
|
||||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={tDns("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
|
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
|
||||||
@@ -104,7 +93,7 @@ export default function DnsHoleSummaryWidget({
|
|||||||
)}
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
{showSourceStatuses && (
|
{showSourceStatuses && (
|
||||||
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
|
<ScrollArea px="xs" pb="xs">
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
{successfulSummaries.map(({ integration, summary }) => (
|
{successfulSummaries.map(({ integration, summary }) => (
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||||
import { formatLocalizedDateTime } from "../common/locale";
|
import { formatLocalizedDateTime } from "../common/locale";
|
||||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||||
@@ -314,7 +314,6 @@ export default function DownloadClientsWidget({
|
|||||||
const queryIndicators = (
|
const queryIndicators = (
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={currentItems ?? []} />
|
<IntegrationErrorIndicator results={currentItems ?? []} />
|
||||||
<WidgetQueryErrorIndicator error={downloadsQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -743,6 +742,8 @@ export default function DownloadClientsWidget({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(downloadsQuery)) return <WidgetQueryLoadingState />;
|
||||||
|
|
||||||
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
|
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
|
||||||
if (isEditMode) rowContextMenuHandler = undefined;
|
if (isEditMode) rowContextMenuHandler = undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Accordion, Box, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
|
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
|
||||||
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
|
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -9,7 +9,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { formatUptime } from "../system-health";
|
import { formatUptime } from "../system-health";
|
||||||
import { getClusterAccordionDefault, getClusterVisibleSections } from "./accordion-state";
|
import { getClusterAccordionDefault, getClusterVisibleSections } from "./accordion-state";
|
||||||
@@ -71,9 +70,6 @@ export const ClusterHealthMonitoring = ({
|
|||||||
const isTiny = displayMode !== "advanced" && width < 256;
|
const isTiny = displayMode !== "advanced" && width < 256;
|
||||||
return (
|
return (
|
||||||
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
|
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
|
||||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
{(isAdvanced || options.showUptime) && !isTiny && (
|
{(isAdvanced || options.showUptime) && !isTiny && (
|
||||||
<Group justify="center" wrap="nowrap">
|
<Group justify="center" wrap="nowrap">
|
||||||
<Text fz="xs" fw={700} c="dimmed" ta="center">
|
<Text fz="xs" fw={700} c="dimmed" ta="center">
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-stor
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import actionTargetClasses from "../common/action-target.module.css";
|
import actionTargetClasses from "../common/action-target.module.css";
|
||||||
import { CpuRing } from "./rings/cpu-ring";
|
import { CpuRing } from "./rings/cpu-ring";
|
||||||
@@ -81,7 +81,6 @@ export const SystemHealthMonitoring = ({
|
|||||||
const queryIndicators = (
|
const queryIndicators = (
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={healthResults} />
|
<IntegrationErrorIndicator results={healthResults} />
|
||||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { WidgetEmptyState } from "../../common/empty-state";
|
|||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { useWidgetRuntimeActions } from "../../runtime-hooks";
|
import { useWidgetRuntimeActions } from "../../runtime-hooks";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ export default function ImmichAlbumCarouselWidget({
|
|||||||
}: WidgetComponentProps<"immich-albumCarousel">) {
|
}: WidgetComponentProps<"immich-albumCarousel">) {
|
||||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const t = useI18n("widget.immich-albumCarousel");
|
|
||||||
|
|
||||||
const albumQuery = clientApi.widget.immich.getAlbum.useQuery(
|
const albumQuery = clientApi.widget.immich.getAlbum.useQuery(
|
||||||
{
|
{
|
||||||
@@ -71,9 +69,6 @@ export default function ImmichAlbumCarouselWidget({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 3 }}>
|
|
||||||
<WidgetQueryErrorIndicator error={albumQuery.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
{album.assets.length === 0 || photoAssets.length === 0 ? (
|
{album.assets.length === 0 || photoAssets.length === 0 ? (
|
||||||
<NoPhotosInAlbum />
|
<NoPhotosInAlbum />
|
||||||
) : (
|
) : (
|
||||||
@@ -228,7 +223,7 @@ function Carousel({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{advanced && (
|
{advanced && (
|
||||||
<ScrollArea type="never" scrollbarSize={0} px="xs" py={6}>
|
<ScrollArea px="xs" py={6}>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
{assets.map((asset, index) => (
|
{assets.map((asset, index) => (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, Center, Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
||||||
import { getQueryKey } from "@trpc/react-query";
|
import { getQueryKey } from "@trpc/react-query";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import { useWidgetRuntimeQueries } from "../../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../../runtime-hooks";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
@@ -103,11 +103,6 @@ export default function ImmichServerStatsWidget({
|
|||||||
return (
|
return (
|
||||||
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
|
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
|
||||||
{statsContent}
|
{statsContent}
|
||||||
{statsQuery.error && (
|
|
||||||
<Box pos="absolute" top={4} right={4}>
|
|
||||||
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,15 +116,10 @@ export default function ImmichServerStatsWidget({
|
|||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{t("albumLimit", { count: MAX_ADVANCED_ALBUMS })}
|
{t("albumLimit", { count: MAX_ADVANCED_ALBUMS })}
|
||||||
</Text>
|
</Text>
|
||||||
<WidgetQueryErrorIndicator error={statsQuery.error ?? albumsQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||||
{isInitialWidgetQueryPending(albumsQuery) ? (
|
{isInitialWidgetQueryPending(albumsQuery) ? (
|
||||||
<WidgetQueryLoadingState />
|
<WidgetQueryLoadingState />
|
||||||
) : albumsQuery.error && albumsQuery.data === undefined ? (
|
|
||||||
<Center h="100%" p="md">
|
|
||||||
<WidgetQueryErrorIndicator error={albumsQuery.error} label={t("name")} />
|
|
||||||
</Center>
|
|
||||||
) : albums.length === 0 ? (
|
) : albums.length === 0 ? (
|
||||||
<WidgetEmptyState />
|
<WidgetEmptyState />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import type { Loader } from "next/dynamic";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Center, Loader as UiLoader } from "@mantine/core";
|
import { Center, Loader as UiLoader } from "@mantine/core";
|
||||||
|
|
||||||
import { objectEntries } from "@homarr/common";
|
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
|
||||||
|
|
||||||
import * as anchorNote from "./anchor-note";
|
import * as anchorNote from "./anchor-note";
|
||||||
import * as audioStats from "./audio-stats";
|
import * as audioStats from "./audio-stats";
|
||||||
@@ -41,7 +39,6 @@ import * as notebook from "./notebook";
|
|||||||
import * as paperlessNgx from "./paperless-ngx";
|
import * as paperlessNgx from "./paperless-ngx";
|
||||||
import * as patchmon from "./patchmon";
|
import * as patchmon from "./patchmon";
|
||||||
import * as notifications from "./notifications";
|
import * as notifications from "./notifications";
|
||||||
import type { WidgetOptionDefinition } from "./options";
|
|
||||||
import * as releases from "./releases";
|
import * as releases from "./releases";
|
||||||
import * as rssFeed from "./rssFeed";
|
import * as rssFeed from "./rssFeed";
|
||||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||||
@@ -191,19 +188,3 @@ export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (Widget
|
|||||||
}
|
}
|
||||||
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
||||||
: never[])[number];
|
: never[])[number];
|
||||||
|
|
||||||
export const reduceWidgetOptionsWithDefaultValues = (
|
|
||||||
kind: WidgetKind,
|
|
||||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
|
||||||
currentValue: Record<string, unknown> = {},
|
|
||||||
) => {
|
|
||||||
const definition = widgetImports[kind].definition;
|
|
||||||
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
|
|
||||||
return objectEntries(options).reduce(
|
|
||||||
(prev, [key, value]) => ({
|
|
||||||
...prev,
|
|
||||||
[key]: currentValue[key] ?? value.defaultValue,
|
|
||||||
}),
|
|
||||||
{} as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -29,7 +29,6 @@ import { useWidgetRuntimeActions } from "../runtime-hooks";
|
|||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import actionTargetClasses from "../common/action-target.module.css";
|
import actionTargetClasses from "../common/action-target.module.css";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
import { getIndexerDisplayStatus } from "./status";
|
import { getIndexerDisplayStatus } from "./status";
|
||||||
@@ -118,7 +117,6 @@ export default function IndexerManagerWidget({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
<WidgetQueryErrorIndicator error={indexersQuery.error} label={t("title")} />
|
|
||||||
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
|
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
|
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
@@ -126,7 +125,6 @@ export default function MediaMissingWidget({
|
|||||||
) => (
|
) => (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
h="100%"
|
h="100%"
|
||||||
scrollbarSize={4}
|
|
||||||
startScrollPosition={tabScrollPositionsRef.current[tab]}
|
startScrollPosition={tabScrollPositionsRef.current[tab]}
|
||||||
onScrollPositionChange={(position) => {
|
onScrollPositionChange={(position) => {
|
||||||
tabScrollPositionsRef.current[tab] = position;
|
tabScrollPositionsRef.current[tab] = position;
|
||||||
@@ -164,15 +162,10 @@ export default function MediaMissingWidget({
|
|||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
const queryFailure = <WidgetQueryErrorIndicator error={mediaQuery.error} label={t("name")} />;
|
|
||||||
|
|
||||||
if (isAdvanced) {
|
if (isAdvanced) {
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap={0}>
|
<Stack h="100%" gap={0}>
|
||||||
<Group justify="flex-end" px="xs">
|
{partialFailures}
|
||||||
{queryFailure}
|
|
||||||
{partialFailures}
|
|
||||||
</Group>
|
|
||||||
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
|
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||||
{showMissing && (
|
{showMissing && (
|
||||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||||
@@ -211,10 +204,7 @@ export default function MediaMissingWidget({
|
|||||||
h="100%"
|
h="100%"
|
||||||
style={{ display: "flex", flexDirection: "column" }}
|
style={{ display: "flex", flexDirection: "column" }}
|
||||||
>
|
>
|
||||||
<Group justify="flex-end" px="xs">
|
{partialFailures}
|
||||||
{queryFailure}
|
|
||||||
{partialFailures}
|
|
||||||
</Group>
|
|
||||||
<Tabs.List grow>
|
<Tabs.List grow>
|
||||||
{showMissing && (
|
{showMissing && (
|
||||||
<Tabs.Tab
|
<Tabs.Tab
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import type { WidgetComponentProps } from "../definition";
|
|||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
export default function MediaReleasesWidget({
|
export default function MediaReleasesWidget({
|
||||||
@@ -43,7 +43,6 @@ export default function MediaReleasesWidget({
|
|||||||
height,
|
height,
|
||||||
displayMode,
|
displayMode,
|
||||||
}: WidgetComponentProps<"mediaReleases">) {
|
}: WidgetComponentProps<"mediaReleases">) {
|
||||||
const t = useI18n("widget.mediaReleases");
|
|
||||||
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
|
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
|
||||||
const response = getUsableWidgetQueryData(releasesQuery);
|
const response = getUsableWidgetQueryData(releasesQuery);
|
||||||
|
|
||||||
@@ -70,10 +69,9 @@ export default function MediaReleasesWidget({
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
{(failedIntegrations.length > 0 || releasesQuery.error) && (
|
{failedIntegrations.length > 0 && (
|
||||||
<Group pos="absolute" top={4} right={4} gap={0}>
|
<Group pos="absolute" top={4} right={4} gap={0}>
|
||||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||||
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import actionTargetClasses from "../../common/action-target.module.css";
|
import actionTargetClasses from "../../common/action-target.module.css";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
@@ -35,17 +36,17 @@ export default function MediaServerWidget({
|
|||||||
.filter(({ id }) => integrationIds.includes(id))
|
.filter(({ id }) => integrationIds.includes(id))
|
||||||
.map(({ id }) => id),
|
.map(({ id }) => id),
|
||||||
);
|
);
|
||||||
const mediaRequestData = getUsableWidgetQueryData(
|
const mediaRequestQuery = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
||||||
clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
integrationIds,
|
||||||
integrationIds,
|
statuses:
|
||||||
statuses:
|
options.statusFilter.length > 0
|
||||||
options.statusFilter.length > 0
|
? options.statusFilter
|
||||||
? options.statusFilter
|
: ["pending", "approved", "declined", "failed", "completed"],
|
||||||
: ["pending", "approved", "declined", "failed", "completed"],
|
recentDays: options.recentDays,
|
||||||
recentDays: options.recentDays,
|
});
|
||||||
}),
|
const mediaRequestData = getUsableWidgetQueryData(mediaRequestQuery);
|
||||||
);
|
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />;
|
||||||
if (!mediaRequestData) return <WidgetEmptyState />;
|
if (!mediaRequestData) return <WidgetEmptyState />;
|
||||||
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
|
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
|
||||||
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
||||||
@@ -61,7 +62,6 @@ export default function MediaServerWidget({
|
|||||||
)}
|
)}
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
className="mediaRequests-list-scrollArea"
|
className="mediaRequests-list-scrollArea"
|
||||||
scrollbarSize="md"
|
|
||||||
style={{ flex: 1, minHeight: 0, pointerEvents: isEditMode ? "none" : undefined }}
|
style={{ flex: 1, minHeight: 0, pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
>
|
>
|
||||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import actionTargetClasses from "../../common/action-target.module.css";
|
import actionTargetClasses from "../../common/action-target.module.css";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
@@ -42,10 +43,12 @@ export default function MediaServerWidget({
|
|||||||
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
||||||
const t = useI18n("widget.mediaRequests-requestStats");
|
const t = useI18n("widget.mediaRequests-requestStats");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const requestStats = getUsableWidgetQueryData(clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds }));
|
const requestStatsQuery = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds });
|
||||||
|
const requestStats = getUsableWidgetQueryData(requestStatsQuery);
|
||||||
|
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(requestStatsQuery)) return <WidgetQueryLoadingState />;
|
||||||
if (!requestStats) return <WidgetEmptyState />;
|
if (!requestStats) return <WidgetEmptyState />;
|
||||||
if (
|
if (
|
||||||
requestStats.users.length === 0 &&
|
requestStats.users.length === 0 &&
|
||||||
|
|||||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user