Compare commits

...
Author SHA1 Message Date
ajnart beb238ed4f fix: show board settings in dashboard header 2026-08-23 09:22:34 +02:00
Thomas 449adc5b12 Merge pull request #6669 from homarr-labs/feat/instant-widget-reloads
perf: persist widget data across reloads
2026-08-23 09:06:00 +02:00
Thomas Camlong 240397e7ac refactor: drop server-seeded widget clock
The WidgetTimeProvider plumbed a server timestamp and the container's
timezone down to useWidgetNow so clock widgets would paint a real time in
the SSR HTML instead of "--:--". That traded a cosmetic first-paint win for
a wrong clock: the seeded timezone is the server's, so anyone whose timezone
differs from the container's saw an incorrect time for the whole hydration
window. Fixing that properly needs a client-set timezone cookie read during
RSC render, which is far more plumbing than the problem warrants.

Revert the clock to release/v2 behaviour ("--:--" until hydration) and drop
the provider, the @homarr/widgets/time export and their tests. The e2e clock
assertions now wait on hydration explicitly instead of the seeded paint.

Also restore the widget.beszel.getSystemStats persistence exclusion. #6289
added it deliberately to keep those large time-series payloads out of the
persisted cache, and this branch had removed it with no stated rationale.

Query-cache persistence and the background-refresh indicator are unchanged.
2026-08-23 09:00:34 +02:00
Thomas Camlong 3efb719eee refactor: simplify widget import boundary 2026-08-23 09:00:34 +02:00
Thomas Camlong 382cffc015 test: use configured DOM environment 2026-08-23 09:00:34 +02:00
Thomas Camlong 0073dfe044 refactor: trim widget reload plumbing 2026-08-23 09:00:33 +02:00
Thomas Camlong a22fb60647 fix: harden widget query persistence and refresh indicator
Review follow-ups on the session query cache:

- Cache the per-payload size verdict and reuse a single TextEncoder. The
  persist subscription re-dehydrates the whole cache on every cache event,
  so every widget payload was being superjson-serialized several times per
  refresh cycle just to measure it.
- Skip the size measurement on the restore path. It runs before any query is
  allowed to fetch and the payload was already budgeted when it was written.
- Reject stored queries whose dataUpdatedAt is not a positive finite number;
  `undefined <= 0` is false, so those were hydrating with a NaN timestamp.
- Add `stop()` and tear the persister down before `queryClient.clear()`, so
  the clear cannot overwrite the saved snapshot with an empty cache.
- Remove every other scope's cache when a persister is created: signing out
  navigates away before the scope guard can react, which left the previous
  account's dashboard data in the tab.
- Remove the entry instead of storing an empty cache.

Refresh indicator:

- Make it decorative instead of an aria-live region; every widget refreshes
  on a timer, so it made assistive technology repeat "loading" forever.

Also broadens the e2e hydration-error matcher to production React's minified
error codes, hoists the single WidgetTimeProvider in ClientBoard, memoizes the
resolved local time zone, and drops the exports this branch made dead.
2026-08-23 09:00:33 +02:00
Thomas Camlong 89e4cb843d refactor: simplify widget reload persistence 2026-08-23 09:00:33 +02:00
Thomas Camlong 65a96b6b14 fix: address widget reload review findings 2026-08-23 09:00:33 +02:00
Thomas Camlong 357355283c fix: harden widget query persistence 2026-08-23 09:00:33 +02:00
Thomas Camlong a2a408410a Persist widget data across reloads 2026-08-23 09:00:33 +02:00
Thomas 0d9eeb5752 Merge pull request #6678 from homarr-labs/feat/fix-responsive-layout-order
fix: keep responsive layout editors stable
2026-08-23 08:58:48 +02:00
Thomas bf0878c68b Merge pull request #6680 from homarr-labs/feat/fix-widget-edit-layout-shifts
fix: stabilize widgets in board edit mode
2026-08-23 08:56:07 +02:00
Thomas eb6d337dfb fix: unify widget scroll areas (#6679)
* fix: unify widget scroll areas

* fix: cover all widget scroll areas

* fix: align data table scrollbars
2026-08-23 01:58:06 +02:00
ajnart 978b3c7f33 fix: stabilize widgets in board edit mode 2026-08-23 01:56:31 +02:00
Thomas 07547320f1 Merge pull request #6667 from homarr-labs/feat/widget-settings-modal
feat: rework widget settings modal
2026-08-23 01:47:14 +02:00
ajnart 02870fc392 test: align grid footprint expectations 2026-08-23 01:42:16 +02:00
ajnart b6fc27e44d fix: preserve layout validation errors 2026-08-23 01:24:36 +02:00
ajnart d5cf1b82f2 fix: address widget editor review feedback 2026-08-23 01:20:39 +02:00
ajnart e022c3f735 fix: match legacy widget grid dimensions 2026-08-23 01:20:39 +02:00
ajnart 14cc3e8a44 fix: make widget toggle cards clickable 2026-08-23 01:19:09 +02:00
ajnart 93d160aebd chore: remove PR artifacts 2026-08-23 01:19:09 +02:00
ajnart 493e93ea42 feat: size widgets before adding 2026-08-23 01:19:08 +02:00
ajnart 85c20702cd docs: add widget settings demo evidence 2026-08-23 01:19:08 +02:00
ajnart 91b248d395 fix: harden widget editor previews 2026-08-23 01:19:08 +02:00
ajnart b7eadc7867 fix: load resolved widget settings 2026-08-23 01:19:08 +02:00
ajnart 1861d8415c fix: isolate widget editor forms 2026-08-23 01:19:08 +02:00
ajnart 7fe5f8f81c fix: tighten widget preview stage 2026-08-23 01:19:08 +02:00
ajnart b3a30e0cf6 fix: constrain widget editor previews 2026-08-23 01:19:08 +02:00
ajnart 763e1324e8 fix: preserve widget preview dimensions 2026-08-23 01:19:08 +02:00
ajnart 5188ed89d6 feat: rework widget settings modal 2026-08-23 01:19:08 +02:00
ajnart 9eafd4f7a7 fix: preserve custom layout role type 2026-08-23 01:13:00 +02:00
ajnart 71ffa5d1be fix: keep responsive layout editors stable 2026-08-23 00:59:02 +02:00
Thomas 1b843817a4 feat: polish board switcher (#6670) 2026-08-22 17:21:10 +02:00
Thomas Camlong 8ae9630b3e Revert "fix: position calendar error indicator top-left (#6668)"
This reverts commit 63f5c43b69.
2026-08-22 17:03:15 +02:00
Thomas 63f5c43b69 fix: position calendar error indicator top-left (#6668) 2026-08-22 16:57:34 +02:00
Thomas e97fe7861d feat: rework bookmarks widget (#6666) 2026-08-22 16:55:31 +02:00
132 changed files with 3331 additions and 1629 deletions

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."
+20
View File
@@ -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

+4 -3
View File
@@ -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
+7 -6
View File
@@ -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
+18 -12
View File
@@ -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",
},
], ],
}, },
}; };
+1
View File
@@ -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
View File
@@ -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);
}); });
+2
View File
@@ -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) => {
+5 -4
View File
@@ -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),
), ),
+5 -1
View File
@@ -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 () => {
+24 -3
View File
@@ -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,
+67 -10
View File
@@ -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",
+1 -1
View File
@@ -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";
+14
View File
@@ -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,
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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 ? (
+1 -7
View File
@@ -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>
+1 -10
View File
@@ -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>
+172 -53
View File
@@ -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 -530
View File
@@ -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} />
);
+46 -19
View File
@@ -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,
+49 -34
View File
@@ -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"]);
}); });
}); });
+265
View File
@@ -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,
};
};
+6 -4
View File
@@ -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}
+6 -21
View File
@@ -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>
); );
}; };
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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 />
) : ( ) : (
-19
View File
@@ -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