Compare commits

...
Author SHA1 Message Date
ajnart 65f0881504 fix(spotlight): rank results before dedupe 2026-08-24 13:01:53 +02:00
ajnart 2b9aa0fdf1 fix(spotlight): deduplicate registered results 2026-08-24 12:54:59 +02:00
ajnart 45368e11fc fix(spotlight): prevent pending layout shifts 2026-08-24 11:55:26 +02:00
ajnart 3f9b024020 test(api): approve spotlight MCP tools 2026-08-24 09:38:11 +02:00
ajnart 6197c8e505 style(api): order catalog metadata 2026-08-24 09:27:32 +02:00
ajnart 6316fa5407 fix(api): secure spotlight catalogs 2026-08-24 09:23:23 +02:00
ajnart 7acff56abb fix(spotlight): stabilize catalog search 2026-08-24 09:15:50 +02:00
ajnart 389a99a203 fix: guard command search queries 2026-08-24 02:48:04 +02:00
ajnart 91b916f015 fix: address command menu review 2026-08-24 02:36:55 +02:00
ajnart 769204ca3b feat: rebuild command menu 2026-08-24 02:23:12 +02:00
Thomas ec295fe26e Merge pull request #6684 from homarr-labs/feat/integration-card-header
fix(ui): prevent integration card title wrapping
2026-08-23 14:14:57 +02:00
ajnart e56112cfd7 fix(ui): prevent integration card title wrapping 2026-08-23 09:21:48 +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
166 changed files with 3676 additions and 1731 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",
@@ -8,13 +8,28 @@ Homarr has the following keyboard shortcuts:
| Shortcut | Description | | Shortcut | Description |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens the search bar | | `MOD` + `K` | Opens search, including local apps, boards, integrations, pages, commands, and preferences |
| `MOD` + `P` | Opens app search |
| `MOD` + `/` | Opens Homarr Assistant inside search |
| `MOD` + `Shift` + `/` (`MOD` + `?`) | Opens preference search |
| `Shift` + `A` | Opens the full Homarr Assistant |
| `MOD` + `J` | Toggles Dark Mode | | `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode | | `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | | Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed | | Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu | | `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
## Search and commands
Search is designed as the fastest route to Homarr. Start typing after `MOD` + `K` to find local apps, boards,
integrations, pages, commands, and preferences without entering a separate mode first. Local Homarr results appear
before configured search-engine actions and Assistant fallbacks. Commands include opening the board switcher without
leaving the keyboard. Type `!` to enter Web mode when you want to browse configured engine shortcuts or discover
DuckDuckGo bangs; bang discovery is excluded from ordinary Search results.
Use the dedicated shortcuts above to start in apps, Assistant, or preferences. Inside search, choose a mode to narrow
the same query when needed. Media request search is available only when a supported Seerr integration is configured.
## Widget advanced view ## Widget advanced view
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl` Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
@@ -198,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

+1 -1
View File
@@ -38,7 +38,7 @@ host gateway address.
## Use it ## Use it
- Select **Assistant** in the user menu or press `Shift + A`. - Select **Assistant** in the user menu or press `Shift + A`.
- Ask from `Cmd/Ctrl + K`. - Press `Cmd/Ctrl + /` to ask from search, or use `Cmd/Ctrl + K` and choose the Assistant fallback.
- Add the [Assistant widget](../widgets/assistant) to a board. - Add the [Assistant widget](../widgets/assistant) to a board.
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
@@ -7,9 +7,13 @@ tags:
# Search Engines # Search Engines
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`. Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `MOD + K`.
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine. The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
The default **Search** mode lists every configured search engine as a fallback action for the current query. To discover
engines by shortcut, switch to **Web** with `!`. DuckDuckGo bang discovery is intentionally limited to Web mode so an
ordinary search term never expands into similarly named bang providers.
You can add any search engine that has a search URL that accepts a search term as a parameter. You can add any search engine that has a search URL that accepts a search term as a parameter.
## Create search engine ## Create search engine
@@ -26,6 +30,8 @@ The name can be chosen freely. It will be displayed in the search box popover.
Prefix that is used to choose the search engine in the popover using the keyboard. Prefix that is used to choose the search engine in the popover using the keyboard.
It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia). It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia).
Type `!` in Spotlight to browse these shortcuts and, when DuckDuckGo bangs are enabled in preferences, discover
additional bang providers.
### Configuration ### Configuration
+1
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>
); );
}; };
@@ -11,12 +11,13 @@ import { useI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui"; import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
import { BoardEmptyState } from "~/components/board/board-empty-state"; import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist"; import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import { import {
getBoardLaneColumnCount, getBoardLaneColumnCount,
getInitialBoardLogicalHeight, getInitialBoardLogicalHeight,
getLogicalTrackSize, getLogicalGridSize,
LOGICAL_GRID_GAP, LOGICAL_GRID_GAP,
getRootSectionForLane, getRootSectionForLane,
} from "~/components/board/layout"; } from "~/components/board/layout";
@@ -61,16 +62,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(" ");
@@ -79,6 +80,7 @@ export const ClientBoard = () => {
<BoardSelectionGridProvider> <BoardSelectionGridProvider>
<BoardAdvancedFocusProvider> <BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board> <Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo /> <BoardBackgroundVideo />
<BoardEmptyState /> <BoardEmptyState />
<BoardSetupChecklist /> <BoardSetupChecklist />
@@ -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";
@@ -856,6 +856,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
: t("busy.description"), : t("busy.description"),
unavailable: !canSend, unavailable: !canSend,
alwaysVisible: true, alwaysVisible: true,
placement: "fallback" as const,
interaction: (query: string) => interaction: (query: string) =>
createAssistantPromptInteraction({ createAssistantPromptInteraction({
sendPrompt, sendPrompt,
@@ -19,6 +19,7 @@ export const useRegisterAssistantSpotlightPlaceholder = (description: string) =>
description, description,
unavailable: true, unavailable: true,
alwaysVisible: true, alwaysVisible: true,
placement: "fallback" as const,
interaction: (_query: string) => ({ type: "none" as const }), interaction: (_query: string) => ({ type: "none" as const }),
}), }),
[description, t], [description, t],
@@ -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">
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { getSafeAppHref } from "@homarr/common";
import type { ContextSpecificItem } from "@homarr/spotlight";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { hasPendingSpotlightOpen, spotlightOpenEvent } from "@homarr/spotlight/open";
export const BoardAppsSpotlightRegistrar = () => {
const board = useRequiredBoard();
const [isSpotlightActivated, setIsSpotlightActivated] = useState(() => hasPendingSpotlightOpen());
useEffect(() => {
if (isSpotlightActivated) return;
const activateSpotlightQueries = () => setIsSpotlightActivated(true);
window.addEventListener(spotlightOpenEvent, activateSpotlightQueries);
if (hasPendingSpotlightOpen()) activateSpotlightQueries();
return () => window.removeEventListener(spotlightOpenEvent, activateSpotlightQueries);
}, [isSpotlightActivated]);
const openInNewTabByAppId = useMemo(() => {
const result = new Map<string, boolean>();
for (const item of board.items) {
if (item.kind !== "app" || typeof item.options.appId !== "string" || item.options.appId.length === 0) continue;
const existingOpenInNewTab = result.get(item.options.appId) ?? false;
result.set(item.options.appId, existingOpenInNewTab || item.options.openInNewTab === true);
}
return result;
}, [board.items]);
const appIds = useMemo(() => Array.from(openInNewTabByAppId.keys()), [openInNewTabByAppId]);
const activeAppIds = isSpotlightActivated ? appIds : [];
const appQueries = clientApi.useQueries((trpc) => activeAppIds.map((id) => trpc.app.byId({ id })));
const apps = appQueries.flatMap((query) => query.data ?? []);
const spotlightItems = useMemo<ContextSpecificItem[]>(() => {
return apps.flatMap((app) => {
const href = getSafeAppHref(app.href);
if (!href) return [];
return [
{
id: app.id,
dedupeKey: `app:${app.id}`,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link" as const,
href,
// Opening a new tab wins if any widget instance requests it.
newTab: openInNewTabByAppId.get(app.id) ?? false,
};
},
},
];
});
}, [apps, openInNewTabByAppId]);
useRegisterSpotlightContextResults("board-apps", spotlightItems, [spotlightItems]);
return null;
};
@@ -26,6 +26,7 @@ import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context"; import { useOptionalBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
@@ -41,6 +42,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 tBoard = useI18n("board");
const manageBoardsT = useI18n("management.page.board"); const manageBoardsT = useI18n("management.page.board");
const currentBoard = useOptionalBoard(); const currentBoard = useOptionalBoard();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -85,6 +87,20 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
}, []); }, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true }); const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
const spotlightAction = useMemo(
() => ({
id: "open-board-switcher",
name: tBoard("action.switch"),
icon: IconLayoutBoard,
interaction: () => ({
type: "javaScript" as const,
onSelect: openSwitcher,
}),
}),
[openSwitcher, tBoard],
);
useRegisterSpotlightContextActions("board-switcher", [spotlightAction], [spotlightAction]);
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]); useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
useEffect(() => { useEffect(() => {
@@ -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);
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type { HotkeyItem } from "@mantine/hooks";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
@@ -13,23 +14,18 @@ const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component })
loading: () => null, loading: () => null,
}); });
const spotlightHotkeys: HotkeyItem[] = [
[hotkeys.openSpotlight, () => openSpotlight(), { preventDefault: true }],
[hotkeys.openSpotlightApps, () => openSpotlight({ mode: "apps" }), { preventDefault: true }],
[hotkeys.openSpotlightAssistant, () => openSpotlight({ mode: "assistant" }), { preventDefault: true }],
[hotkeys.openSpotlightPreferences, () => openSpotlight({ mode: "preferences" }), { preventDefault: true }],
];
export const LazySpotlight = () => { export const LazySpotlight = () => {
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
const [isPreloaded, setIsPreloaded] = useState(false); const [isPreloaded, setIsPreloaded] = useState(false);
useHotkeys( useHotkeys(spotlightHotkeys, [], true);
isMounted
? []
: [
[
hotkeys.openSpotlight,
() => {
setIsMounted(true);
openSpotlight();
},
],
],
);
useEffect(() => { useEffect(() => {
let isActive = true; let isActive = true;
@@ -61,12 +57,8 @@ export const LazySpotlight = () => {
return isMounted || isPreloaded ? ( return isMounted || isPreloaded ? (
<> <>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />} {isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && ( {isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
<> <Spotlight />
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
<Spotlight />
</>
)}
</> </>
) : null; ) : null;
}; };
@@ -20,7 +20,7 @@ export const DesktopSearchInput = () => {
w={400} w={400}
size="sm" size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />} leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight} onClick={() => openSpotlight()}
radius="xl" radius="xl"
> >
{`${t("search.placeholder")}...`} {`${t("search.placeholder")}...`}
@@ -32,7 +32,7 @@ export const MobileSearchButton = () => {
return ( return (
<HeaderButton <HeaderButton
data-homarr-dev-benchmark-interaction="search" data-homarr-dev-benchmark-interaction="search"
onClick={openSpotlight} onClick={() => openSpotlight()}
className={classes.mobileSearch} className={classes.mobileSearch}
> >
<IconSearch size={20} stroke={1.5} /> <IconSearch size={20} stroke={1.5} />
+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) => {
+83 -72
View File
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson"; import superjson from "superjson";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared"; import { constructBoardPermissions } from "@homarr/auth/shared";
import { createId, generateResponsiveGridFor } from "@homarr/common"; import { createId, generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common"; import type { GridAlgorithmItem } from "@homarr/common";
@@ -123,6 +124,78 @@ const findFirstAvailableBoardItemPosition = (
return null; return null;
}; };
const searchAccessibleBoardsAsync = async (
ctx: { db: Database; session: Session | null },
query: string,
limit?: number,
) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const foundBoards = await ctx.db.query.boards.findMany({
where: and(
like(boards.name, `%${query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
),
limit,
orderBy: asc(boards.name),
columns: {
id: true,
name: true,
creatorId: true,
isPublic: true,
logoImageUrl: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: getBoardGroupPermissionWhere(permissionsOfCurrentUserGroupsWhenPresent),
},
},
});
return foundBoards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
};
export const boardRouter = createTRPCRouter({ export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure exists: permissionRequiredProcedure
.requiresPermission("board-create") .requiresPermission("board-create")
@@ -482,80 +555,18 @@ export const boardRouter = createTRPCRouter({
}; };
}); });
}), }),
catalog: publicProcedure
.meta({ mcp: { enabled: true, description: "List every board the current user can access." } })
.query(async ({ ctx }) => searchAccessibleBoardsAsync(ctx, "")),
search: publicProcedure search: publicProcedure
.meta({
mcp: {
enabled: true,
description: "Search accessible boards by name. REQUIRED: query (string). OPTIONAL: limit (number).",
},
})
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => searchAccessibleBoardsAsync(ctx, input.query, input.limit)),
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const foundBoards = await ctx.db.query.boards.findMany({
where: and(
like(boards.name, `%${input.query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
),
limit: input.limit,
columns: {
id: true,
name: true,
creatorId: true,
isPublic: true,
logoImageUrl: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
},
});
return foundBoards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
createBoard: permissionRequiredProcedure createBoard: permissionRequiredProcedure
.requiresPermission("board-create") .requiresPermission("board-create")
.meta({ .meta({
@@ -130,6 +130,28 @@ export const searchEngineRouter = createTRPCRouter({
return null; return null;
}), }),
catalog: publicProcedure
.meta({
mcp: {
enabled: true,
description: "List search engines available to the current user.",
},
})
.query(async ({ ctx }) => {
return await ctx.db.query.searchEngines.findMany({
where: ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
orderBy: asc(searchEngines.name),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}),
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => { search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({ return await ctx.db.query.searchEngines.findMany({
// Public dashboards have no session: restrict anonymous users to generic // Public dashboards have no session: restrict anonymous users to generic
+2
View File
@@ -19,8 +19,10 @@ const MCP_TOOL_ALLOWLIST = {
"beszel_getAlerts", "beszel_getAlerts",
"beszel_getSystems", "beszel_getSystems",
"beszel_getSystemStats", "beszel_getSystemStats",
"board_catalog",
"board_getAllBoards", "board_getAllBoards",
"board_getBoardSettings", "board_getBoardSettings",
"board_search",
"calendar_findAllEvents", "calendar_findAllEvents",
"customWidget_get", "customWidget_get",
"customWidget_getAuthoringPrompt", "customWidget_getAuthoringPrompt",
+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,
+3
View File
@@ -3,5 +3,8 @@ export const hotkeys = {
toggleColorScheme: "mod+j", toggleColorScheme: "mod+j",
saveNotebook: "mod+s", saveNotebook: "mod+s",
openSpotlight: "mod+k", openSpotlight: "mod+k",
openSpotlightApps: "mod+p",
openSpotlightAssistant: "mod+/",
openSpotlightPreferences: "mod+shift+?",
openAssistant: "shift+a", openAssistant: "shift+a",
}; };
@@ -1,22 +1,32 @@
import { Center, Loader } from "@mantine/core"; import { Center } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { SearchGroup } from "../../lib/group"; import type { SearchGroup } from "../../lib/group";
import type { inferSearchInteractionOptions } from "../../lib/interaction"; import type { inferSearchInteractionOptions } from "../../lib/interaction";
import { SpotlightNoResults } from "../no-results"; import type { SpotlightMode } from "../../open";
import { SpotlightGroupActionItem } from "./items/group-action-item"; import { SpotlightGroupActionItem } from "./items/group-action-item";
interface GroupActionsProps<TOption extends Record<string, unknown>> { interface GroupActionsProps<TOption extends Record<string, unknown>> {
group: SearchGroup<TOption>; group: SearchGroup<TOption>;
query: string; query: string;
setQuery: (query: string) => void; setQuery: (query: string) => void;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void; setMode: (mode: SpotlightMode) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
} }
const getUniqueOptions = <TOption extends Record<string, unknown>>(options: TOption[], keyPath: keyof TOption) => {
const optionKeys = new Set<unknown>();
return options.filter((option) => {
const optionKey = option[keyPath];
if (optionKeys.has(optionKey)) return false;
optionKeys.add(optionKey);
return true;
});
};
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
group, group,
query, query,
@@ -30,28 +40,26 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
const options = useOptions(query); const options = useOptions(query);
const t = useI18n(); const t = useI18n();
useWindowEvent("keydown", (event) => {
const optionsArray = Array.isArray(options) ? options : (options.data ?? []);
group.onKeyDown?.(event, optionsArray, query, { setChildrenOptions });
});
if (Array.isArray(options)) { if (Array.isArray(options)) {
if (options.length === 0) { if (options.length === 0) {
return null; return null;
} }
const filteredOptions = options const filteredOptions = getUniqueOptions(
.filter((option) => ("filter" in group ? group.filter(query, option) : false)) options
.sort((optionA, optionB) => { .filter((option) => ("filter" in group ? group.filter(query, option) : false))
if ("sort" in group) { .sort((optionA, optionB) => {
return group.sort?.(query, [optionA, optionB]) ?? 0; if ("sort" in group) {
} return group.sort?.(query, [optionA, optionB]) ?? 0;
}
return 0; return 0;
}); }),
group.keyPath,
);
if (filteredOptions.length === 0) { if (filteredOptions.length === 0) {
return <SpotlightNoResults />; return null;
} }
return filteredOptions.map((option) => ( return filteredOptions.map((option) => (
@@ -68,15 +76,15 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
} }
if (options.isLoading) { if (options.isLoading) {
return ( return <span hidden data-search-pending />;
<Center w="100%" py="sm">
<Loader size="sm" />
</Center>
);
} }
if (options.isError) { if (options.isError) {
return <Center py="sm">{t("search.error.fetch")}</Center>; return (
<Center py="sm" data-search-status>
{t("search.error.fetch")}
</Center>
);
} }
if (!options.data) { if (!options.data) {
@@ -84,10 +92,10 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
} }
if (options.data.length === 0) { if (options.data.length === 0) {
return <SpotlightNoResults />; return null;
} }
return options.data.map((option) => ( return getUniqueOptions(options.data, group.keyPath).map((option) => (
<SpotlightGroupActionItem <SpotlightGroupActionItem
key={option[group.keyPath] as never} key={option[group.keyPath] as never}
option={option} option={option}
@@ -1,18 +1,18 @@
import { Spotlight } from "@mantine/spotlight"; import { Spotlight } from "@mantine/spotlight";
import type { TranslationObject } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { SearchGroup } from "../../../lib/group"; import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction"; import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import type { SpotlightMode } from "../../../open";
import { SpotlightGroupActions } from "../group-actions"; import { SpotlightGroupActions } from "../group-actions";
interface SpotlightActionGroupsProps { interface SpotlightActionGroupsProps {
groups: SearchGroup[]; groups: SearchGroup[];
query: string; query: string;
setQuery: (query: string) => void; setQuery: (query: string) => void;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void; setMode: (mode: SpotlightMode) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
} }
@@ -23,6 +23,8 @@ export const SpotlightActionGroups = ({ groups, ...others }: SpotlightActionGrou
<Spotlight.ActionsGroup <Spotlight.ActionsGroup
key={`${translateIfNecessary(t, group.title)}::${String(group.keyPath)}`} key={`${translateIfNecessary(t, group.title)}::${String(group.keyPath)}`}
label={translateIfNecessary(t, group.title)} label={translateIfNecessary(t, group.title)}
data-search-source={group.source?.kind ?? "local"}
data-remote-source={group.source?.kind === "remote" ? group.source.source : undefined}
> >
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<SpotlightGroupActions<any> group={group} {...others} /> <SpotlightGroupActions<any> group={group} {...others} />
@@ -1,11 +1,11 @@
import { Spotlight } from "@mantine/spotlight"; import { Spotlight } from "@mantine/spotlight";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common"; import { getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common";
import type { TranslationObject } from "@homarr/translation";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import type { SearchGroup } from "../../../lib/group"; import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction"; import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import type { SpotlightMode } from "../../../open";
import { selectAction, spotlightStore } from "../../../spotlight-store"; import { selectAction, spotlightStore } from "../../../spotlight-store";
import classes from "./action-item.module.css"; import classes from "./action-item.module.css";
@@ -13,7 +13,7 @@ interface SpotlightGroupActionItemProps<TOption extends Record<string, unknown>>
option: TOption; option: TOption;
query: string; query: string;
setQuery: (query: string) => void; setQuery: (query: string) => void;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void; setMode: (mode: SpotlightMode) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
group: SearchGroup<TOption>; group: SearchGroup<TOption>;
} }
@@ -34,7 +34,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
const safeHref = interaction.type === "link" ? getSafeAppHref(interaction.href) : undefined; const safeHref = interaction.type === "link" ? getSafeAppHref(interaction.href) : undefined;
const renderRoot = const renderRoot =
interaction.type === "link" && safeHref interaction.type === "link" && safeHref && !unavailable
? (props: Record<string, unknown>) => { ? (props: Record<string, unknown>) => {
return ( return (
<Link <Link
@@ -48,6 +48,8 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
: undefined; : undefined;
const handleClickAsync = async () => { const handleClickAsync = async () => {
if (unavailable) return;
if (interaction.type === "javaScript") { if (interaction.type === "javaScript") {
await interaction.onSelect(); await interaction.onSelect();
} else if (interaction.type === "setQuery") { } else if (interaction.type === "setQuery") {
@@ -76,6 +78,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
} }
className={classes.spotlightAction} className={classes.spotlightAction}
aria-disabled={unavailable || undefined} aria-disabled={unavailable || undefined}
disabled={unavailable}
> >
<group.Component {...(optionProps as TOption)} /> <group.Component {...(optionProps as TOption)} />
</Spotlight.Action> </Spotlight.Action>
@@ -0,0 +1,80 @@
import { Group, Kbd, ScrollArea, Text, UnstyledButton } from "@mantine/core";
import {
IconApps,
IconCommand,
IconMovie,
IconRobot,
IconSearch,
IconSettings,
IconUsers,
IconWorldSearch,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { SpotlightMode } from "../open";
import classes from "./spotlight.module.css";
interface ModeRailEntry {
ariaLabel: string;
character?: string;
label: string;
mode: SpotlightMode;
}
interface SpotlightModeRailProps {
activeMode: SpotlightMode;
entries: ModeRailEntry[];
navigationLabel: string;
onModeChange: (mode: SpotlightMode) => void;
}
const modeIcons: Record<SpotlightMode, typeof IconSearch> = {
search: IconSearch,
apps: IconApps,
command: IconCommand,
preferences: IconSettings,
assistant: IconRobot,
external: IconWorldSearch,
media: IconMovie,
userGroup: IconUsers,
};
export const SpotlightModeRail = ({ activeMode, entries, navigationLabel, onModeChange }: SpotlightModeRailProps) => {
const { data: session } = useSession();
const canManageUsers = session?.user.permissions.includes("admin") ?? false;
return (
<ScrollArea type="never" scrollbarSize={0} className={classes.modeRailViewport}>
<Group component="nav" aria-label={navigationLabel} wrap="nowrap" gap={4} className={classes.modeRail}>
{entries.map((entry) => {
if (entry.mode === "userGroup" && !canManageUsers) return null;
const Icon = modeIcons[entry.mode];
const active = entry.mode === activeMode;
return (
<UnstyledButton
type="button"
key={entry.mode}
className={classes.modeButton}
data-active={active || undefined}
aria-pressed={active}
aria-label={entry.ariaLabel}
onClick={() => onModeChange(entry.mode)}
>
<Icon size={15} stroke={1.7} />
<Text component="span" size="xs" fw={600} className={classes.modeLabel}>
{entry.label}
</Text>
{entry.character ? (
<Kbd size="xs" className={classes.modeKey}>
{entry.character}
</Kbd>
) : null}
</UnstyledButton>
);
})}
</Group>
</ScrollArea>
);
};
@@ -2,8 +2,8 @@ import { Spotlight } from "@mantine/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
export const SpotlightNoResults = () => { export const SpotlightNoResults = ({ className }: { className?: string }) => {
const t = useI18n(); const t = useI18n();
return <Spotlight.Empty>{t("search.nothingFound")}</Spotlight.Empty>; return <Spotlight.Empty className={className}>{t("search.nothingFound")}</Spotlight.Empty>;
}; };
@@ -0,0 +1,106 @@
.content {
overflow: hidden;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: var(--mantine-shadow-xl);
}
.search {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.modeRailViewport {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.modeRail {
min-width: max-content;
padding: var(--mantine-spacing-xs);
}
.modeButton {
display: flex;
align-items: center;
gap: 4px;
min-height: 30px;
padding-inline: 6px;
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-dimmed);
transition:
color 100ms ease,
background-color 100ms ease;
}
.modeKey {
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-size: 10px;
}
.modeButton:hover {
color: var(--mantine-color-text);
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.modeButton[data-active] {
color: var(--mantine-primary-color-filled);
background: var(--mantine-primary-color-light);
}
.modeButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
.actionsList {
padding-block: 4px;
}
.actionsList [data-search-source="fallback"] {
margin-top: var(--mantine-spacing-xs);
padding-top: var(--mantine-spacing-xs);
border-top: 1px dashed light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.actionsList > [data-search-source="fallback"]:first-child {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.actionsList:has([data-action]) .emptyState,
.actionsList:has([data-search-pending]) .emptyState,
.actionsList:has([data-search-status]) .emptyState {
display: none;
}
.actionsList [data-search-source]:has([data-search-pending]) {
display: none;
}
.footer {
min-height: 42px;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.footerHint {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4));
}
@media (max-width: 48em) {
.modeLabel {
display: none;
}
.footerSecondary {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.modeButton {
transition: none;
}
}
+173 -102
View File
@@ -2,13 +2,12 @@
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; import { ActionIcon, Center, Group, Kbd, Text } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react"; import { IconArrowDown, IconArrowLeft, IconArrowUp, IconCornerDownLeft, IconSearch, IconX } from "@tabler/icons-react";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { hotkeys } from "@homarr/definitions"; import { translateIfNecessary } from "@homarr/translation";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { import {
@@ -17,74 +16,142 @@ import {
mediaRequestSearchEvent, mediaRequestSearchEvent,
spotlightOpenEvent, spotlightOpenEvent,
} from "../open"; } from "../open";
import type { OpenMediaRequestSearchOptions } from "../open"; import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } from "../open";
import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode"; import type { SearchMode } from "../lib/mode";
import { useSpotlightCatalogs } from "../lib/catalog";
import { searchModes } from "../modes"; import { searchModes } from "../modes";
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
import { contextSpecificSearchGroups } from "../modes/home/context-specific-group";
import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store"; import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group"; import { SpotlightActionGroups } from "./actions/groups/action-group";
import { SpotlightModeRail } from "./mode-rail";
import { SpotlightNoResults } from "./no-results";
import classes from "./spotlight.module.css";
type SearchModeKey = keyof TranslationObject["search"]["mode"];
type ChildrenOptions = inferSearchInteractionOptions<"children">; type ChildrenOptions = inferSearchInteractionOptions<"children">;
const defaultMode = "home"; const defaultMode: SpotlightMode = "search";
export const Spotlight = () => {
const searchModeState = useState<SearchModeKey>(defaultMode);
const queryState = useState("");
const mode = searchModeState[0];
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
if (!activeMode) { const resetSelection = () => {
return null; requestAnimationFrame(() => selectAction(0, spotlightStore));
} };
export const Spotlight = () => {
useSpotlightCatalogs();
const [mode, setMode] = useState<SpotlightMode>(defaultMode);
const [query, setQuery] = useState("");
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.mode === mode), [mode]);
const applyOpenOptions = useCallback(
(options: SpotlightOpenIntent) => {
setMode(options.mode);
setQuery(options.query ?? "");
if (options.mode !== "media") setMediaRequestSearchScope({});
spotlightActions.open();
resetSelection();
},
[setMediaRequestSearchScope],
);
useEffect(() => {
const handleOpen = (event: Event) => {
const pendingOptions = consumePendingSpotlightOpen();
const eventOptions = (event as CustomEvent<SpotlightOpenIntent>).detail;
applyOpenOptions(eventOptions ?? pendingOptions ?? { mode: defaultMode });
};
window.addEventListener(spotlightOpenEvent, handleOpen);
const pendingOptions = consumePendingSpotlightOpen();
if (pendingOptions) applyOpenOptions(pendingOptions);
return () => window.removeEventListener(spotlightOpenEvent, handleOpen);
}, [applyOpenOptions]);
useEffect(() => {
const applyMediaRequestSearch = ({ integrationIds, query: mediaQuery }: OpenMediaRequestSearchOptions) => {
setMediaRequestSearchScope({ integrationIds });
applyOpenOptions({ mode: "media", query: mediaQuery });
};
const handleMediaRequestSearch = (event: Event) => {
const pendingOptions = consumePendingMediaRequestSearch();
const eventOptions = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail;
applyMediaRequestSearch(eventOptions ?? pendingOptions ?? {});
};
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
const pendingOptions = consumePendingMediaRequestSearch();
if (pendingOptions) applyMediaRequestSearch(pendingOptions);
return () => window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
}, [applyOpenOptions, setMediaRequestSearchScope]);
if (!activeMode) return null;
return ( return (
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} /> <SpotlightWithActiveMode
key={mode}
mode={mode}
setMode={setMode}
query={query}
setQuery={setQuery}
activeMode={activeMode}
/>
); );
}; };
interface SpotlightWithActiveModeProps { interface SpotlightWithActiveModeProps {
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>]; mode: SpotlightMode;
queryState: [string, Dispatch<SetStateAction<string>>]; setMode: Dispatch<SetStateAction<SpotlightMode>>;
query: string;
setQuery: Dispatch<SetStateAction<string>>;
activeMode: SearchMode; activeMode: SearchMode;
} }
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => { const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = queryState;
const [mode, setMode] = modeState;
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]); const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
const childrenOptions = childrenStack.at(-1) ?? null; const childrenOptions = childrenStack.at(-1) ?? null;
const t = useI18n(); const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups; const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
const groups = useGroups(); const groups = useGroups();
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences(); const hasModeCharacter = activeMode.character !== undefined;
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined; const activeModeLabel = translateIfNecessary(t, activeMode.label) ?? activeMode.mode;
const placeholder = translateIfNecessary(t, activeMode.placeholder) ?? t("search.placeholder");
const modeRailEntries = searchModes.map((searchMode) => {
const label = translateIfNecessary(t, searchMode.label) ?? searchMode.mode;
return {
mode: searchMode.mode,
character: searchMode.character,
label,
ariaLabel: t("search.modePicker.modeAriaLabel", { mode: label }),
};
});
useEffect(() => { useEffect(() => {
const handleOpen = () => { requestAnimationFrame(() => inputRef.current?.focus());
consumePendingSpotlightOpen();
spotlightActions.open();
};
window.addEventListener(spotlightOpenEvent, handleOpen);
if (consumePendingSpotlightOpen()) spotlightActions.open();
return () => window.removeEventListener(spotlightOpenEvent, handleOpen);
}, []); }, []);
const clearChildrenStack = useCallback(() => { const clearChildrenStack = useCallback(() => {
setChildrenStack([]); setChildrenStack([]);
}, []); }, []);
const changeMode = useCallback(
(nextMode: SpotlightMode, preserveQuery = true) => {
setMode(nextMode);
clearChildrenStack();
if (!preserveQuery) setQuery("");
resetSelection();
requestAnimationFrame(() => inputRef.current?.focus());
},
[clearChildrenStack, setMode, setQuery],
);
const pushChildrenOptions = useCallback( const pushChildrenOptions = useCallback(
(options: ChildrenOptions) => { (options: ChildrenOptions) => {
setChildrenStack((currentStack) => [...currentStack, options]); setChildrenStack((currentStack) => [...currentStack, options]);
setQuery(""); setQuery("");
setTimeout(() => selectAction(0, spotlightStore)); resetSelection();
}, },
[setQuery], [setQuery],
); );
@@ -92,44 +159,19 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const popChildrenOptions = useCallback(() => { const popChildrenOptions = useCallback(() => {
setChildrenStack((currentStack) => currentStack.slice(0, -1)); setChildrenStack((currentStack) => currentStack.slice(0, -1));
setQuery(""); setQuery("");
setTimeout(() => selectAction(0, spotlightStore)); resetSelection();
}, [setQuery]); }, [setQuery]);
useEffect(() => {
const applyMediaRequestSearch = ({ integrationIds, query: searchQuery }: OpenMediaRequestSearchOptions) => {
setMediaRequestSearchScope({ integrationIds });
setMode("media");
clearChildrenStack();
setQuery(searchQuery ?? "");
spotlightActions.open();
setTimeout(() => selectAction(0, spotlightStore));
};
const handleMediaRequestSearch = (event: Event) => {
consumePendingMediaRequestSearch();
const { integrationIds, query: searchQuery } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
applyMediaRequestSearch({ integrationIds, query: searchQuery });
};
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
const pendingSearch = consumePendingMediaRequestSearch();
if (pendingSearch) applyMediaRequestSearch(pendingSearch);
return () => {
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
};
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
useEffect(() => {
if (mode !== "media") {
setMediaRequestSearchScope({});
}
}, [mode, setMediaRequestSearchScope]);
return ( return (
<MantineSpotlight.Root <MantineSpotlight.Root
shortcut={hotkeys.openSpotlight} shortcut={null}
yOffset={8} size={760}
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }} yOffset={64}
scrollable
maxHeight="min(62vh, 560px)"
overlayProps={{ blur: 3, backgroundOpacity: 0.24 }}
attributes={{ content: { "aria-label": t("search.modePicker.dialogAriaLabel", { mode: activeModeLabel }) } }}
classNames={{ content: classes.content }}
onSpotlightOpen={() => { onSpotlightOpen={() => {
requestAnimationFrame(() => inputRef.current?.focus()); requestAnimationFrame(() => inputRef.current?.focus());
}} }}
@@ -143,22 +185,24 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery; const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
setQuery(sanitizedQuery); setQuery(sanitizedQuery);
if (sanitizedQuery.length !== 1) return; if (sanitizedQuery.length === 1) {
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
if (modeToActivate) {
changeMode(modeToActivate.mode, false);
return;
}
}
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery); resetSelection();
if (!modeToActivate) return;
setMode(modeToActivate.modeKey);
clearChildrenStack();
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
}} }}
store={spotlightStore} store={spotlightStore}
> >
<MantineSpotlight.Search <MantineSpotlight.Search
data-homarr-dev-benchmark-spotlight data-homarr-dev-benchmark-spotlight
placeholder={`${t("search.placeholder")}...`} aria-label={placeholder}
placeholder={placeholder}
ref={inputRef} ref={inputRef}
className={classes.search}
leftSectionWidth={hasModeCharacter ? 80 : 48} leftSectionWidth={hasModeCharacter ? 80 : 48}
leftSection={ leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%"> <Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
@@ -168,11 +212,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null} {hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group> </Group>
} }
styles={{ styles={{ section: { pointerEvents: "all" } }}
section: {
pointerEvents: "all",
},
}}
rightSection={ rightSection={
mode === defaultMode && childrenStack.length === 0 ? null : ( mode === defaultMode && childrenStack.length === 0 ? null : (
<ActionIcon <ActionIcon
@@ -188,9 +228,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
return; return;
} }
setMode(defaultMode); changeMode(defaultMode, true);
clearChildrenStack();
inputRef.current?.focus();
}} }}
variant="subtle" variant="subtle"
aria-label={ aria-label={
@@ -212,20 +250,28 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
return; return;
} }
if (mode !== defaultMode) { if (mode !== defaultMode) changeMode(defaultMode, true);
setMode(defaultMode);
clearChildrenStack();
}
}} }}
/> />
<SpotlightModeRail
activeMode={mode}
entries={modeRailEntries}
navigationLabel={t("search.modePicker.navigationLabel")}
onModeChange={changeMode}
/>
{childrenOptions ? ( {childrenOptions ? (
<Group key={childrenStack.length}> <Group key={childrenStack.length}>
<childrenOptions.DetailComponent options={childrenOptions.option as never} /> <childrenOptions.DetailComponent options={childrenOptions.option as never} />
</Group> </Group>
) : null} ) : null}
<MantineSpotlight.ActionsList> <MantineSpotlight.ActionsList
component="section"
className={classes.actionsList}
aria-label={t("search.modePicker.resultsAriaLabel", { mode: activeModeLabel })}
>
{childrenOptions ? ( {childrenOptions ? (
<SpotlightChildrenActions <SpotlightChildrenActions
key={childrenStack.length} key={childrenStack.length}
@@ -236,21 +282,46 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
) : ( ) : (
<SpotlightActionGroups <SpotlightActionGroups
setQuery={setQuery} setQuery={setQuery}
setMode={(nextMode) => { setMode={(nextMode) => changeMode(nextMode, true)}
setMode(nextMode); setChildrenOptions={pushChildrenOptions}
clearChildrenStack();
setTimeout(() => selectAction(0, spotlightStore));
}}
setChildrenOptions={(options) => {
pushChildrenOptions(options);
}}
query={query} query={query}
groups={ groups={groups}
mode === defaultMode && query.length === 0 ? [...homeEmptyGroups, contextSpecificSearchGroups] : groups
}
/> />
)} )}
<SpotlightNoResults className={classes.emptyState} />
</MantineSpotlight.ActionsList> </MantineSpotlight.ActionsList>
<MantineSpotlight.Footer className={classes.footer} px="md" py="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Group gap={4} wrap="nowrap">
<Kbd size="xs">
<IconArrowUp size={11} />
</Kbd>
<Kbd size="xs">
<IconArrowDown size={11} />
</Kbd>
<Text size="xs" className={classes.footerHint}>
{t("search.modePicker.footer.navigate")}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Kbd size="xs">
<IconCornerDownLeft size={11} />
</Kbd>
<Text size="xs" className={classes.footerHint}>
{t("search.modePicker.footer.open")}
</Text>
</Group>
</Group>
<Group gap={4} wrap="nowrap" className={classes.footerSecondary}>
<Kbd size="xs">esc</Kbd>
<Text size="xs" className={classes.footerHint}>
{t("search.modePicker.footer.close")}
</Text>
</Group>
</Group>
</MantineSpotlight.Footer>
</MantineSpotlight.Root> </MantineSpotlight.Root>
); );
}; };
+1
View File
@@ -6,3 +6,4 @@ export {
useRegisterSpotlightContextResults, useRegisterSpotlightContextResults,
useRegisterSpotlightContextActions, useRegisterSpotlightContextActions,
} from "./modes/home/context"; } from "./modes/home/context";
export type { ContextSpecificItem } from "./modes/home/context";
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect } from "react";
import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
import { useSession } from "@homarr/auth/client";
import { spotlightOpenEvent } from "../open";
const catalogQueryOptions = (enabled = true) => ({
enabled,
refetchOnWindowFocus: false,
staleTime: queryCacheDefaultStaleTimeMs,
});
export const useAppsCatalogQuery = (enabled = true) => {
const { status } = useSession();
return clientApi.app.selectable.useQuery(undefined, catalogQueryOptions(enabled && status === "authenticated"));
};
export const useBoardsCatalogQuery = () => {
const { status } = useSession();
return clientApi.board.catalog.useQuery(undefined, catalogQueryOptions(status !== "loading"));
};
export const useIntegrationsCatalogQuery = (enabled = true) => {
const { status } = useSession();
return clientApi.integration.all.useQuery(undefined, catalogQueryOptions(enabled && status === "authenticated"));
};
export const useSearchEnginesCatalogQuery = () => {
const { status } = useSession();
return clientApi.searchEngine.catalog.useQuery(undefined, catalogQueryOptions(status !== "loading"));
};
export const useSpotlightCatalogs = () => {
const { data: session, status } = useSession();
const isAuthenticated = status === "authenticated";
const isSessionResolved = status !== "loading";
const canBrowseApps =
session?.user.permissions.includes("board-modify-all") === true ||
session?.user.permissions.includes("app-modify-all") === true;
const appsQuery = useAppsCatalogQuery(isAuthenticated && canBrowseApps);
const boardsQuery = useBoardsCatalogQuery();
const integrationsQuery = useIntegrationsCatalogQuery(isAuthenticated);
const searchEnginesQuery = useSearchEnginesCatalogQuery();
useEffect(() => {
const refreshCatalogs = () => {
if (canBrowseApps) void appsQuery.refetch();
if (isSessionResolved) void boardsQuery.refetch();
if (isAuthenticated) void integrationsQuery.refetch();
if (isSessionResolved) void searchEnginesQuery.refetch();
};
window.addEventListener(spotlightOpenEvent, refreshCatalogs);
return () => window.removeEventListener(spotlightOpenEvent, refreshCatalogs);
}, [
appsQuery,
boardsQuery,
canBrowseApps,
integrationsQuery,
isAuthenticated,
isSessionResolved,
searchEnginesQuery,
]);
};
export const filterCatalog = <TOption>(
options: TOption[],
query: string,
getSearchableValues: (option: TOption) => string[],
limit = 8,
) => {
const normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.length === 0) return options.slice(0, limit);
return options
.map((option, index) => {
const values = getSearchableValues(option).map((value) => value.toLowerCase());
const score = values.reduce((currentScore, value) => {
if (value === normalizedQuery) return Math.min(currentScore, 0);
if (value.startsWith(normalizedQuery)) return Math.min(currentScore, 1);
if (value.includes(normalizedQuery)) return Math.min(currentScore, 2);
return currentScore;
}, Number.POSITIVE_INFINITY);
return { option, index, score };
})
.filter(({ score }) => Number.isFinite(score))
.toSorted((first, second) => first.score - second.score || first.index - second.index)
.slice(0, limit)
.map(({ option }) => option);
};
+20 -12
View File
@@ -2,26 +2,34 @@ import type { JSX } from "react";
import type { stringOrTranslation } from "@homarr/translation"; import type { stringOrTranslation } from "@homarr/translation";
import type { inferSearchInteractionDefinition, inferSearchInteractionOptions, SearchInteraction } from "./interaction"; import type { inferSearchInteractionDefinition, SearchInteraction } from "./interaction";
type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps extends Record<string, unknown>> = { export type RemoteSearchSource =
| "apps"
| "boards"
| "integrations"
| "users"
| "groups"
| "search-engines"
| "integration-search"
| "media";
export type SearchGroupSource =
| { kind: "local" }
| { kind: "remote"; source: RemoteSearchSource }
| { kind: "fallback" };
type CommonSearchGroup<TOption extends object, TOptionProps extends object> = {
// key path is used to define the path to a unique key in the option object // key path is used to define the path to a unique key in the option object
keyPath: keyof TOption; keyPath: keyof TOption;
title: stringOrTranslation; title: stringOrTranslation;
source?: SearchGroupSource;
Component: (option: TOption) => JSX.Element; Component: (option: TOption) => JSX.Element;
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>; useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
onKeyDown?: (
event: KeyboardEvent,
options: TOption[],
query: string,
actions: {
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
},
) => void;
} & TOptionProps; } & TOptionProps;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SearchGroup<TOption extends Record<string, unknown> = any> = export type SearchGroup<TOption extends object = any> =
| CommonSearchGroup<TOption, { filter: (query: string, option: TOption) => boolean; options: TOption[] }> | CommonSearchGroup<TOption, { filter: (query: string, option: TOption) => boolean; options: TOption[] }>
| CommonSearchGroup< | CommonSearchGroup<
TOption, TOption,
@@ -36,4 +44,4 @@ export type SearchGroup<TOption extends Record<string, unknown> = any> =
{ useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } } { useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } }
>; >;
export const createGroup = <TOption extends Record<string, unknown>>(group: SearchGroup<TOption>) => group; export const createGroup = <TOption extends object>(group: SearchGroup<TOption>) => group;
+2 -2
View File
@@ -1,7 +1,7 @@
import type { MaybePromise } from "@homarr/common/types"; import type { MaybePromise } from "@homarr/common/types";
import type { TranslationObject } from "@homarr/translation";
import type { CreateChildrenOptionsProps } from "./children"; import type { CreateChildrenOptionsProps } from "./children";
import type { SpotlightMode } from "../open";
const createSearchInteraction = <TType extends string>(type: TType) => ({ const createSearchInteraction = <TType extends string>(type: TType) => ({
optionsType: <TOption extends Record<string, unknown> | undefined>() => ({ type, _inferOptions: {} as TOption }), optionsType: <TOption extends Record<string, unknown> | undefined>() => ({ type, _inferOptions: {} as TOption }),
@@ -15,7 +15,7 @@ const searchInteractions = [
closeSpotlightOnTrigger?: boolean; closeSpotlightOnTrigger?: boolean;
}>(), }>(),
createSearchInteraction("setQuery").optionsType<{ query: string }>(), createSearchInteraction("setQuery").optionsType<{ query: string }>(),
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(), createSearchInteraction("mode").optionsType<{ mode: SpotlightMode; query?: string }>(),
createSearchInteraction("children").optionsType<{ createSearchInteraction("children").optionsType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
useActions: CreateChildrenOptionsProps<any>["useActions"]; useActions: CreateChildrenOptionsProps<any>["useActions"];
+5 -2
View File
@@ -1,10 +1,13 @@
import type { TranslationObject } from "@homarr/translation"; import type { stringOrTranslation } from "@homarr/translation";
import type { SearchGroup } from "./group"; import type { SearchGroup } from "./group";
import type { SpotlightMode } from "../open";
export type SearchMode = { export type SearchMode = {
modeKey: keyof TranslationObject["search"]["mode"]; mode: SpotlightMode;
character: string | undefined; character: string | undefined;
label: stringOrTranslation;
placeholder: stringOrTranslation;
} & ( } & (
| { | {
groups: SearchGroup[]; groups: SearchGroup[];
@@ -0,0 +1,46 @@
"use client";
import { useDebouncedValue } from "@mantine/hooks";
import type { RemoteSearchSource } from "./group";
const remoteSearchPolicy: Record<RemoteSearchSource, { delay: number; minimumLength: number }> = {
apps: { delay: 140, minimumLength: 1 },
boards: { delay: 140, minimumLength: 1 },
integrations: { delay: 160, minimumLength: 1 },
users: { delay: 180, minimumLength: 1 },
groups: { delay: 180, minimumLength: 1 },
"search-engines": { delay: 160, minimumLength: 1 },
"integration-search": { delay: 220, minimumLength: 1 },
media: { delay: 220, minimumLength: 1 },
};
interface RemoteQueryOptions {
minimumLength?: number;
trim?: boolean;
}
export interface RemoteQuery {
enabled: boolean;
query: string;
}
/**
* Keeps every network-backed spotlight provider on the same query cadence. Local providers still
* receive the immediate query and therefore render before remote results can replace the selection.
*/
export const useRemoteQuery = (
query: string,
source: RemoteSearchSource,
options: RemoteQueryOptions = {},
): RemoteQuery => {
const policy = remoteSearchPolicy[source];
const minimumLength = options.minimumLength ?? policy.minimumLength;
const normalizedQuery = options.trim === false ? query : query.trim();
const [debouncedQuery] = useDebouncedValue(normalizedQuery, policy.delay);
return {
enabled: debouncedQuery.length >= minimumLength,
query: debouncedQuery,
};
};
@@ -1,12 +1,13 @@
import { Avatar, Group, Stack, Text } from "@mantine/core"; import { Avatar, Group, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconEye } from "@tabler/icons-react"; import { IconExternalLink, IconEye } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children"; import { createChildrenOptions } from "../../lib/children";
import { filterCatalog, useAppsCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
import { useSpotlightContextResults } from "../home/context";
// This has to be type so it can be interpreted as Record<string, unknown>. // This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -58,6 +59,7 @@ const appChildrenOptions = createChildrenOptions<App>({
<Avatar <Avatar
size="sm" size="sm"
src={options.iconUrl} src={options.iconUrl}
alt=""
radius={0} radius={0}
styles={{ styles={{
image: { image: {
@@ -75,11 +77,13 @@ const appChildrenOptions = createChildrenOptions<App>({
export const appsSearchGroup = createGroup<App>({ export const appsSearchGroup = createGroup<App>({
keyPath: "id", keyPath: "id",
title: (t) => t("common.entity.apps"), title: (t) => t("common.entity.apps"),
source: { kind: "remote", source: "apps" },
Component: (app) => ( Component: (app) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<Avatar <Avatar
size="sm" size="sm"
src={app.iconUrl} src={app.iconUrl}
alt=""
radius={0} radius={0}
styles={{ styles={{
image: { image: {
@@ -92,6 +96,16 @@ export const appsSearchGroup = createGroup<App>({
), ),
useInteraction: interaction.children(appChildrenOptions), useInteraction: interaction.children(appChildrenOptions),
useQueryOptions(query) { useQueryOptions(query) {
return clientApi.app.search.useQuery({ query, limit: 5 }); const catalogQuery = useAppsCatalogQuery();
const localAppKeys = new Set(
useSpotlightContextResults().flatMap((item) => (item.dedupeKey ? [item.dedupeKey] : [])),
);
const apps = (catalogQuery.data ?? []).filter((app) => !localAppKeys.has(`app:${app.id}`));
return {
data: filterCatalog(apps, query, (app) => [app.name]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
}, },
}); });
@@ -1,13 +1,14 @@
import { Group, Stack, Text } from "@mantine/core"; import { Group, Stack, Text } from "@mantine/core";
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react"; import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { ChildrenAction } from "../../lib/children"; import type { ChildrenAction } from "../../lib/children";
import { filterCatalog, useBoardsCatalogQuery } from "../../lib/catalog";
import { createChildrenOptions } from "../../lib/children"; import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
import { clientApi } from "@homarr/api/client";
// This has to be type so it can be interpreted as Record<string, unknown>. // This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -126,6 +127,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
export const boardsSearchGroup = createGroup<Board>({ export const boardsSearchGroup = createGroup<Board>({
keyPath: "id", keyPath: "id",
title: (t) => t("common.entity.boards"), title: (t) => t("common.entity.boards"),
source: { kind: "remote", source: "boards" },
Component: (board) => ( Component: (board) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
{board.logoImageUrl ? ( {board.logoImageUrl ? (
@@ -139,6 +141,11 @@ export const boardsSearchGroup = createGroup<Board>({
), ),
useInteraction: interaction.children(boardChildrenOptions), useInteraction: interaction.children(boardChildrenOptions),
useQueryOptions(query) { useQueryOptions(query) {
return clientApi.board.search.useQuery({ query, limit: 5 }); const catalogQuery = useBoardsCatalogQuery();
return {
data: filterCatalog(catalogQuery.data ?? [], query, (board) => [board.name]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
}, },
}); });
@@ -2,19 +2,22 @@ import { useSession } from "@homarr/auth/client";
import type { SearchGroup } from "../../lib/group"; import type { SearchGroup } from "../../lib/group";
import type { SearchMode } from "../../lib/mode"; import type { SearchMode } from "../../lib/mode";
import { contextSpecificAppsSearchGroup } from "../home/context-specific-group";
import { appsSearchGroup } from "./apps-search-group"; import { appsSearchGroup } from "./apps-search-group";
import { boardsSearchGroup } from "./boards-search-group"; import { boardsSearchGroup } from "./boards-search-group";
import { integrationsSearchGroup } from "./integrations-search-group"; import { integrationsSearchGroup } from "./integrations-search-group";
export const appIntegrationBoardMode = { export const appIntegrationBoardMode = {
modeKey: "appIntegrationBoard", mode: "apps",
character: "#", character: "#",
label: (t) => t("search.modePicker.apps.label"),
placeholder: (t) => t("search.modePicker.apps.placeholder"),
useGroups() { useGroups() {
const { data: session } = useSession(); const { data: session } = useSession();
const groups: SearchGroup[] = [boardsSearchGroup]; const groups: SearchGroup[] = [contextSpecificAppsSearchGroup];
if (!session?.user) { if (!session?.user) {
return groups; return groups.concat(boardsSearchGroup);
} }
// Mirrors the gate on app.search: either permission may browse the app catalogue, so an app // Mirrors the gate on app.search: either permission may browse the app catalogue, so an app
@@ -22,6 +25,10 @@ export const appIntegrationBoardMode = {
const canSearchApps = const canSearchApps =
session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all"); session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all");
return groups.concat(canSearchApps ? [appsSearchGroup, integrationsSearchGroup] : [integrationsSearchGroup]); if (canSearchApps) {
return groups.concat(appsSearchGroup, boardsSearchGroup, integrationsSearchGroup);
}
return groups.concat(boardsSearchGroup, integrationsSearchGroup);
}, },
} satisfies SearchMode; } satisfies SearchMode;
@@ -1,15 +1,16 @@
import { Group, Text } from "@mantine/core"; import { Group, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import { filterCatalog, useIntegrationsCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({ export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
keyPath: "id", keyPath: "id",
title: (t) => t("common.entity.integrations"), title: (t) => t("common.entity.integrations"),
source: { kind: "remote", source: "integrations" },
Component: (integration) => ( Component: (integration) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<IntegrationAvatar size="sm" kind={integration.kind} /> <IntegrationAvatar size="sm" kind={integration.kind} />
@@ -19,6 +20,11 @@ export const integrationsSearchGroup = createGroup<{ id: string; kind: Integrati
), ),
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })), useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
useQueryOptions(query) { useQueryOptions(query) {
return clientApi.integration.search.useQuery({ query, limit: 5 }); const catalogQuery = useIntegrationsCatalogQuery();
return {
data: filterCatalog(catalogQuery.data ?? [], query, (integration) => [integration.name, integration.kind]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
}, },
}); });
@@ -0,0 +1,10 @@
import type { SearchMode } from "../../lib/mode";
import { contextSpecificFallbackSearchGroup } from "../home/context-specific-group";
export const assistantMode = {
mode: "assistant",
character: undefined,
label: (t) => t("search.modePicker.assistant.label"),
placeholder: (t) => t("search.modePicker.assistant.placeholder"),
groups: [contextSpecificFallbackSearchGroup],
} satisfies SearchMode;
@@ -2,10 +2,10 @@
import { Group, Stack, Switch, Text } from "@mantine/core"; import { Group, Stack, Switch, Text } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates"; import type { DayOfWeek } from "@mantine/dates";
import { IconChevronRight } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData"; import localeData from "dayjs/plugin/localeData";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
import { visiblePreferenceDefinitions } from "@homarr/settings"; import { visiblePreferenceDefinitions } from "@homarr/settings";
@@ -47,15 +47,19 @@ const BooleanRow = ({
</Group> </Group>
); );
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => ( const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel?: string }) => (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%"> <Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap" gap="sm"> <Group wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} /> <Icon stroke={1.5} size={20} />
<Text>{label}</Text> <Text>{label}</Text>
</Group> </Group>
<Text size="sm" c="dimmed"> {valueLabel ? (
{valueLabel} <Text size="sm" c="dimmed">
</Text> {valueLabel}
</Text>
) : (
<IconChevronRight aria-hidden size={16} />
)}
</Group> </Group>
); );
@@ -68,7 +72,7 @@ const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]); const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => { export const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
const t = useI18n("search.mode.command.group.preferences.option"); const t = useI18n("search.mode.command.group.preferences.option");
const tUserField = useI18n("user.field"); const tUserField = useI18n("user.field");
const tColorScheme = useI18n("common.colorScheme.options"); const tColorScheme = useI18n("common.colorScheme.options");
@@ -77,25 +81,13 @@ const useSettingsActions = (_: Record<string, unknown>, query: string): Settings
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const preferences = useUserPreferences(); const preferences = useUserPreferences();
const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated });
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated });
const valueLabelResolvers: Record<string, () => string> = { const valueLabelResolvers: Record<string, () => string> = {
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme), colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
locale: () => { locale: () => {
const val = preferences.getPreference("locale").value; const val = preferences.getPreference("locale").value;
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val); return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
}, },
defaultSearchEngineId: () =>
searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)
?.label ?? t("searchEngine.none" as never),
firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "", firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "",
homeBoardId: () =>
boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ??
t("board.none" as never),
mobileHomeBoardId: () =>
boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ??
t("board.none" as never),
}; };
return visiblePreferenceDefinitions(isAuthenticated) return visiblePreferenceDefinitions(isAuthenticated)
@@ -134,9 +126,7 @@ const useSettingsActions = (_: Record<string, unknown>, query: string): Settings
if (!children) return null; if (!children) return null;
return { return {
key: definition.key, key: definition.key,
Component: () => ( Component: () => <SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.()} />,
<SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.() ?? ""} />
),
useInteraction: interaction.children(children), useInteraction: interaction.children(children),
}; };
} }
@@ -7,6 +7,7 @@ import { useSpotlightContextActions } from "../home/context";
export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({ export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.command.group.localCommand.title"), title: (t) => t("search.mode.command.group.localCommand.title"),
keyPath: "id", keyPath: "id",
source: { kind: "local" },
Component(option) { Component(option) {
const icon = const icon =
typeof option.icon !== "string" ? ( typeof option.icon !== "string" ? (
@@ -36,7 +36,8 @@ type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> =
export const globalCommandGroup = createGroup<Command>({ export const globalCommandGroup = createGroup<Command>({
keyPath: "commandKey", keyPath: "commandKey",
title: "Global commands", title: (t) => t("search.mode.command.group.globalCommand.title"),
source: { kind: "local" },
useInteraction: (option, query) => option.useInteraction(option, query), useInteraction: (option, query) => option.useInteraction(option, query),
Component: ({ icon: Icon, name }) => ( Component: ({ icon: Icon, name }) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
@@ -4,7 +4,9 @@ import { globalCommandGroup } from "./global-group";
import { preferencesGroup } from "../preferences/groups"; import { preferencesGroup } from "../preferences/groups";
export const commandMode = { export const commandMode = {
modeKey: "command", mode: "command",
character: ">", character: ">",
groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup], label: (t) => t("search.modePicker.command.label"),
placeholder: (t) => t("search.modePicker.command.placeholder"),
groups: [contextSpecificActionsSearchGroups, globalCommandGroup, preferencesGroup],
} satisfies SearchMode; } satisfies SearchMode;
+3 -1
View File
@@ -2,7 +2,9 @@ import type { SearchMode } from "../../lib/mode";
import { searchEnginesSearchGroups } from "./search-engines-search-group"; import { searchEnginesSearchGroups } from "./search-engines-search-group";
export const externalMode = { export const externalMode = {
modeKey: "external", mode: "external",
character: "!", character: "!",
label: (t) => t("search.modePicker.external.label"),
placeholder: (t) => t("search.modePicker.external.placeholder"),
groups: [searchEnginesSearchGroups], groups: [searchEnginesSearchGroups],
} satisfies SearchMode; } satisfies SearchMode;
@@ -1,7 +1,5 @@
import { Group, Image, Kbd, Stack, Text } from "@mantine/core"; import { Group, Image, Kbd, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconDownload, IconSearch } from "@tabler/icons-react"; import { IconDownload, IconSearch } from "@tabler/icons-react";
import { keepPreviousData } from "@tanstack/react-query";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -13,11 +11,13 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children"; import { createChildrenOptions } from "../../lib/children";
import { filterCatalog, useSearchEnginesCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition } from "../../lib/interaction"; import type { inferSearchInteractionDefinition } from "../../lib/interaction";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
type SearchEngine = RouterOutputs["searchEngine"]["search"][number]; type SearchEngine = RouterOutputs["searchEngine"]["catalog"][number];
type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number]; type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number]; type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number];
@@ -187,7 +187,7 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
return ( return (
<Group mx="md" my="sm" wrap="nowrap"> <Group mx="md" my="sm" wrap="nowrap">
{options.result.image ? ( {options.result.image ? (
<Image src={options.result.image} w={35} h={50} fit="cover" radius={"md"} /> <Image src={options.result.image} alt="" w={35} h={50} fit="cover" radius={"md"} />
) : ( ) : (
<IconSearch stroke={1.5} size={35} /> <IconSearch stroke={1.5} size={35} />
)} )}
@@ -206,11 +206,12 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
useActions: (searchEngine, query) => { useActions: (searchEngine, query) => {
const remoteQuery = useRemoteQuery(query, "integration-search");
const { data } = clientApi.integration.searchInIntegration.useQuery( const { data } = clientApi.integration.searchInIntegration.useQuery(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
{ integrationId: searchEngine.integrationId!, query }, { integrationId: searchEngine.integrationId!, query: remoteQuery.query },
{ {
enabled: searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && query.length > 0, enabled: remoteQuery.enabled && searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null,
}, },
); );
const { openSearchInNewTab } = useSettings(); const { openSearchInNewTab } = useSettings();
@@ -244,7 +245,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
return ( return (
<Group mx="md" my="sm" wrap="nowrap"> <Group mx="md" my="sm" wrap="nowrap">
{searchResult.image ? ( {searchResult.image ? (
<Image src={searchResult.image} w={35} h={50} fit="cover" radius={"md"} /> <Image src={searchResult.image} alt="" w={35} h={50} fit="cover" radius={"md"} />
) : ( ) : (
<IconSearch stroke={1.5} size={35} /> <IconSearch stroke={1.5} size={35} />
)} )}
@@ -270,7 +271,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
<Stack mx="md" my="sm"> <Stack mx="md" my="sm">
<Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text> <Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text>
<Group> <Group>
<img height={24} width={24} src={options.iconUrl} alt={options.name} /> <img height={24} width={24} src={options.iconUrl} alt="" />
<Text>{options.name}</Text> <Text>{options.name}</Text>
</Group> </Group>
</Stack> </Stack>
@@ -287,7 +288,7 @@ const parseBangQuery = (query: string) => {
return { bangToken, searchText, locked }; return { bangToken, searchText, locked };
}; };
const buildSearchUrl = (template: string, query: string) => { export const buildSearchUrl = (template: string, query: string) => {
const encoded = encodeURIComponent(query); const encoded = encodeURIComponent(query);
if (template.includes("{{{s}}}")) { if (template.includes("{{{s}}}")) {
return template.replaceAll("{{{s}}}", encoded); return template.replaceAll("{{{s}}}", encoded);
@@ -296,36 +297,23 @@ const buildSearchUrl = (template: string, query: string) => {
return template.replaceAll("%s", encoded); return template.replaceAll("%s", encoded);
}; };
export const searchEnginesSearchGroups = createGroup<ExternalOption>({ interface SearchEngineGroupOptions {
keyPath: "key", minimumLength: number;
title: (t) => t("common.entity.searchEngines"), showEmptyHint: boolean;
Component: (option) => { source: "focused" | "fallback";
if (option.kind === "hint") { }
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
<IconSearch stroke={1.5} />
<Stack gap={0}>
<Text size="sm">{option.label}</Text>
{option.description ? (
<Text size="xs" c="gray.6">
{option.description}
</Text>
) : null}
</Stack>
</Group>
);
}
if (option.kind === "search") { const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }: SearchEngineGroupOptions) =>
return ( createGroup<ExternalOption>({
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs"> keyPath: "key",
<Group wrap="nowrap"> title: (t) => t("common.entity.searchEngines"),
{option.iconUrl ? ( source: source === "focused" ? { kind: "remote", source: "search-engines" } : { kind: "fallback" },
<img height={24} width={24} src={option.iconUrl} alt={option.label} /> Component: (option) => {
) : ( if (option.kind === "hint") {
<IconSearch stroke={1.5} /> return (
)} <Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
<Stack gap={0} justify="center"> <IconSearch stroke={1.5} />
<Stack gap={0}>
<Text size="sm">{option.label}</Text> <Text size="sm">{option.label}</Text>
{option.description ? ( {option.description ? (
<Text size="xs" c="gray.6"> <Text size="xs" c="gray.6">
@@ -334,22 +322,61 @@ export const searchEnginesSearchGroups = createGroup<ExternalOption>({
) : null} ) : null}
</Stack> </Stack>
</Group> </Group>
);
}
<Kbd size="sm">!{option.bang}</Kbd> if (option.kind === "search") {
</Group> return (
); <Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
} <Group wrap="nowrap">
{option.iconUrl ? (
<img height={24} width={24} src={option.iconUrl} alt="" />
) : (
<IconSearch stroke={1.5} />
)}
<Stack gap={0} justify="center">
<Text size="sm">{option.label}</Text>
{option.description ? (
<Text size="xs" c="gray.6">
{option.description}
</Text>
) : null}
</Stack>
</Group>
if (option.kind === "engine") { <Kbd size="sm">!{option.bang}</Kbd>
const { iconUrl, name, short, description } = option.engine; </Group>
);
}
if (option.kind === "engine") {
const { iconUrl, name, short, description } = option.engine;
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
<img height={24} width={24} src={iconUrl} alt="" />
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{description}
</Text>
</Stack>
</Group>
<Kbd size="sm">!{short}</Kbd>
</Group>
);
}
const { s: name, t: short, d: domain } = option.bang;
return ( return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs"> <Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap"> <Group wrap="nowrap">
<img height={24} width={24} src={iconUrl} alt={name} /> <IconSearch stroke={1.5} />
<Stack gap={0} justify="center"> <Stack gap={0} justify="center">
<Text size="sm">{name}</Text> <Text size="sm">{name}</Text>
<Text size="xs" c="gray.6"> <Text size="xs" c="gray.6">
{description} {domain ?? "DuckDuckGo bang"}
</Text> </Text>
</Stack> </Stack>
</Group> </Group>
@@ -357,138 +384,134 @@ export const searchEnginesSearchGroups = createGroup<ExternalOption>({
<Kbd size="sm">!{short}</Kbd> <Kbd size="sm">!{short}</Kbd>
</Group> </Group>
); );
} },
useInteraction(option, query) {
const { openSearchInNewTab } = useSettings();
const { bangToken, searchText } = parseBangQuery(query);
const { s: name, t: short, d: domain } = option.bang; if (option.kind === "search") {
return ( return {
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs"> type: "link",
<Group wrap="nowrap"> href: buildSearchUrl(option.urlTemplate, option.searchText),
<IconSearch stroke={1.5} /> newTab: openSearchInNewTab,
<Stack gap={0} justify="center"> };
<Text size="sm">{name}</Text> }
<Text size="xs" c="gray.6">
{domain ?? "DuckDuckGo bang"}
</Text>
</Stack>
</Group>
<Kbd size="sm">!{short}</Kbd> if (option.kind === "engine") {
</Group> const nextBang = option.engine.short;
); const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `;
}, return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery };
useInteraction(option, query) { }
const { openSearchInNewTab } = useSettings();
const { bangToken, searchText } = parseBangQuery(query);
if (option.kind === "search") { if (option.kind === "ddg") {
return { const nextBang = option.bang.t;
type: "link", const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `;
href: buildSearchUrl(option.urlTemplate, option.searchText), return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery };
newTab: openSearchInNewTab, }
};
}
if (option.kind === "engine") { return { type: "none" };
const nextBang = option.engine.short; },
const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `; useQueryOptions(query) {
return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery }; const tExternal = useI18n("search.mode.external.group.searchEngine");
} const { ddgBangs } = useSettings();
const { bangToken, searchText, locked } = parseBangQuery(query);
const remoteQuery = useRemoteQuery(bangToken, "search-engines", { minimumLength });
const ddgQueryEnabled = remoteQuery.enabled && ddgBangs && remoteQuery.query.length >= 1;
const enginesQuery = useSearchEnginesCatalogQuery();
if (option.kind === "ddg") { const ddgQuery = clientApi.bangs.search.useQuery(
const nextBang = option.bang.t; { query: remoteQuery.query, limit: 10 },
const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `; {
return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery }; enabled: ddgQueryEnabled,
} placeholderData: (previousData) => previousData,
},
);
return { type: "none" }; const engineCatalog = enginesQuery.data ?? [];
}, const engineData = filterCatalog(engineCatalog, bangToken, (engine) => [engine.short, engine.name], 10);
useQueryOptions(query) { const ddgData = ddgQueryEnabled && bangToken.length > 0
const tExternal = useI18n("search.mode.external.group.searchEngine"); ? (ddgQuery.data ?? []).filter((bang) => bang.t.toLowerCase().startsWith(bangToken.toLowerCase()))
const { ddgBangs } = useSettings(); : [];
const { bangToken, searchText, locked } = parseBangQuery(query); const isWaitingForDdgResults =
const [debouncedBangToken] = useDebouncedValue(bangToken, 150); ddgBangs &&
bangToken.length > 0 &&
engineData.length === 0 &&
(remoteQuery.query !== bangToken || (ddgQueryEnabled && ddgQuery.isFetching && ddgData.length === 0));
const isLoading = enginesQuery.isLoading || isWaitingForDdgResults;
const isError = enginesQuery.isError || (ddgQuery.isError && ddgQueryEnabled && bangToken.length > 0);
const enginesQuery = clientApi.searchEngine.search.useQuery( const engineOptions = engineData.map(
{ query: debouncedBangToken, limit: 10 }, (engine): ExternalOption => ({
{ placeholderData: keepPreviousData }, key: `engine-${engine.short}`,
); kind: "engine",
engine,
const ddgQuery = clientApi.bangs.search.useQuery(
{ query: debouncedBangToken, limit: 10 },
{
enabled: ddgBangs && debouncedBangToken.length >= 1,
placeholderData: keepPreviousData,
},
);
const isLoading = enginesQuery.isLoading || ddgQuery.isLoading;
const isError = enginesQuery.isError || ddgQuery.isError;
const engineOptions = (enginesQuery.data ?? []).map(
(engine): ExternalOption => ({
key: `engine-${engine.short}`,
kind: "engine",
engine,
}),
);
const ddgOptions = (ddgQuery.data ?? [])
.filter((bang) => !engineOptions.some((option) => option.kind === "engine" && option.engine.short === bang.t))
.map(
(bang): ExternalOption => ({
key: `ddg-${bang.t}`,
kind: "ddg",
bang,
}), }),
); );
const searchActions: ExternalOption[] = []; const ddgOptions = ddgData
if (locked && bangToken.length > 0) { .filter((bang) => !engineOptions.some((option) => option.kind === "engine" && option.engine.short === bang.t))
const matchedEngine = (enginesQuery.data ?? []).find((engine) => engine.short === bangToken); .map(
const matchedDdg = (ddgQuery.data ?? []).find((bang) => bang.t === bangToken); (bang): ExternalOption => ({
key: `ddg-${bang.t}`,
kind: "ddg",
bang,
}),
);
const label = matchedEngine?.name ?? matchedDdg?.s; const searchActions: ExternalOption[] = [];
const iconUrl = matchedEngine?.iconUrl; if (locked && bangToken.length > 0) {
const urlTemplate = matchedEngine?.type === "generic" ? matchedEngine.urlTemplate : matchedDdg?.u; const matchedEngine = engineCatalog.find((engine) => engine.short === bangToken);
const matchedDdg = ddgData.find((bang) => bang.t === bangToken);
if (label && urlTemplate) { const genericEngine = matchedEngine?.type === "generic" ? matchedEngine : undefined;
if (searchText.trim().length > 0) { const label = genericEngine?.name ?? matchedDdg?.s;
searchActions.push({ const iconUrl = genericEngine?.iconUrl;
key: "search-action", const urlTemplate = genericEngine?.urlTemplate ?? matchedDdg?.u;
kind: "search",
label: tExternal("bang.searchWithQuery", { if (label && urlTemplate) {
query: searchText.trim(), if (searchText.trim().length > 0) {
label, searchActions.push({
}), key: "search-action",
description: tExternal("bang.pressEnterToOpen"), kind: "search",
bang: bangToken, label: tExternal("bang.searchWithQuery", {
iconUrl, query: searchText.trim(),
urlTemplate, label,
searchText: searchText.trim(), }),
}); description: tExternal("bang.pressEnterToOpen"),
} else { bang: bangToken,
searchActions.push({ iconUrl,
key: "search-hint", urlTemplate,
kind: "hint", searchText: searchText.trim(),
label: tExternal("bang.engineSelected", { label, bang: bangToken }), });
description: tExternal("bang.typeQueryToContinue"), } else {
}); searchActions.push({
key: "search-hint",
kind: "hint",
label: tExternal("bang.engineSelected", { label, bang: bangToken }),
description: tExternal("bang.typeQueryToContinue"),
});
}
} }
} }
} if (showEmptyHint && bangToken.length === 0) {
if (query.length === 0) { searchActions.push({
searchActions.push({ key: "hint",
key: "hint", kind: "hint",
kind: "hint", label: tExternal("bang.emptyBangHint"),
label: tExternal("bang.emptyBangHint"), description: ddgBangs ? tExternal("tipDdgBangs") : undefined,
description: ddgBangs ? tExternal("tipDdgBangs") : undefined, });
}); }
}
return { return {
isLoading, isLoading,
isError, isError,
data: [...searchActions, ...engineOptions, ...ddgOptions].slice(0, 10), data: [...searchActions, ...engineOptions, ...ddgOptions].slice(0, 10),
}; };
}, },
});
export const searchEnginesSearchGroups = createSearchEnginesSearchGroup({
minimumLength: 0,
showEmptyHint: true,
source: "focused",
}); });
@@ -1,138 +0,0 @@
import { Group, Kbd, Text } from "@mantine/core";
import {
IconBook2,
IconBrandDiscord,
IconBrandGithub,
IconLayoutDashboard,
IconSearch,
IconSettings,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board";
import { commandMode } from "../command";
import { externalMode } from "../external";
import { mediaMode } from "../media";
import { pageMode } from "../page";
import { preferencesGroup } from "../preferences/groups";
import { userGroupMode } from "../user-group";
type QuickLinkOption = {
key: string;
icon: TablerIcon;
name: string;
path: string;
};
export const useHomeEmptyGroups = () => {
const { data: session } = useSession();
const tPages = useI18n("search.mode.page.group.page.option");
const tHelp = useI18n("management.navbar.items.help.items");
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode];
if (session?.user.permissions.includes("admin")) {
visibleSearchModes.unshift(userGroupMode);
}
return [
createGroup({
keyPath: "key",
title: () => "Quick links",
options: (() => {
const quickLinks: QuickLinkOption[] = [];
quickLinks.push({
key: "manageBoards",
icon: IconLayoutDashboard,
path: "/manage/boards",
name: tPages("manageBoard.label"),
});
if (session?.user.permissions.includes("admin")) {
quickLinks.push({
key: "manageSettings",
icon: IconSettings,
path: "/manage/settings",
name: tPages("manageSettings.label"),
});
}
if (session) {
quickLinks.push({
key: "manageSearchEngines",
icon: IconSearch,
path: "/manage/search-engines",
name: tPages("manageSearchEngine.label"),
});
}
return quickLinks;
})(),
Component: ({ name, icon: Icon }) => (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
filter: () => true,
useInteraction: interaction.link(({ path }) => ({ href: path })),
}),
createGroup({
keyPath: "modeKey",
title: (t) => t("search.mode.help.group.mode.title"),
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
Component: ({ modeKey, character }) => {
const t = useI18n(`search.mode.${modeKey}`);
return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
<Text>{t("help")}</Text>
{character && <Kbd size="sm">{character}</Kbd>}
</Group>
);
},
filter: () => true,
useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })),
}),
createGroup({
keyPath: "href",
title: (t) => t("management.navbar.items.help.label"),
useOptions() {
return [
{
href: createDocumentationLink("/docs/getting-started"),
icon: IconBook2,
label: tHelp("documentation"),
},
{
href: "https://github.com/homarr-labs/homarr/issues/new/choose",
icon: IconBrandGithub,
label: tHelp("submitIssue"),
},
{
href: "https://discord.com/invite/aCsmEV5RgA",
icon: IconBrandDiscord,
label: tHelp("discord"),
},
];
},
Component: (props) => (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<props.icon stroke={1.5} />
<Text>{props.label}</Text>
</Group>
),
filter: () => true,
useInteraction: interaction.link(({ href }) => ({ href, newTab: true })),
}),
] as const;
};
export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()];
@@ -7,39 +7,89 @@ import { useSpotlightContextResults } from "./context";
export const shouldShowContextSpecificResult = (query: string, option: ContextSpecificItem) => { export const shouldShowContextSpecificResult = (query: string, option: ContextSpecificItem) => {
if (option.alwaysVisible) return true; if (option.alwaysVisible) return true;
if (query.length === 0) return false; if (query.length === 0) return false;
return option.name.toLowerCase().includes(query.toLowerCase()); const normalizedQuery = query.trim().toLowerCase();
const searchableValues = [option.name, option.description ?? "", ...(option.aliases ?? [])];
return searchableValues.some((value) => value.toLowerCase().includes(normalizedQuery));
};
const localResultSort = (query: string, [first, second]: [ContextSpecificItem, ContextSpecificItem]) => {
const normalizedQuery = query.trim().toLowerCase();
const score = (option: ContextSpecificItem) => {
const normalizedName = option.name.toLowerCase();
if (normalizedName === normalizedQuery) return 0;
if (normalizedName.startsWith(normalizedQuery)) return 1;
return 2;
};
return score(first) - score(second);
};
const renderContextSpecificItem = (option: ContextSpecificItem) => {
const icon =
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt="" />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.55 : 1}>
{icon}
<Stack gap={0}>
<Text>{option.name}</Text>
{option.description ? (
<Text size="xs" c="dimmed">
{option.description}
</Text>
) : null}
</Stack>
</Group>
);
}; };
export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({ export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.home.group.local.title"), title: (t) => t("search.mode.home.group.local.title"),
keyPath: "id", keyPath: "id",
Component(option) { source: { kind: "local" },
const icon = Component: renderContextSpecificItem,
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt={option.name} />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.55 : 1}>
{icon}
<Stack gap={0}>
<Text>{option.name}</Text>
{option.description && (
<Text size="xs" c="dimmed">
{option.description}
</Text>
)}
</Stack>
</Group>
);
},
useInteraction(option, query) { useInteraction(option, query) {
return option.interaction(query); return option.interaction(query);
}, },
filter: shouldShowContextSpecificResult, filter: shouldShowContextSpecificResult,
sort: localResultSort,
useOptions() { useOptions() {
return useSpotlightContextResults(); return useSpotlightContextResults().filter((item) => item.placement !== "fallback");
},
});
export const contextSpecificAppsSearchGroup = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.home.group.local.title"),
keyPath: "id",
source: { kind: "local" },
Component: renderContextSpecificItem,
useInteraction(option, query) {
return option.interaction(query);
},
filter(query, option) {
return query.trim().length === 0 || shouldShowContextSpecificResult(query, option);
},
sort: localResultSort,
useOptions() {
return useSpotlightContextResults().filter((item) => item.placement !== "fallback");
},
});
export const contextSpecificFallbackSearchGroup = createGroup<ContextSpecificItem>({
title: (t) => t("search.modePicker.continueWith"),
keyPath: "id",
source: { kind: "fallback" },
Component: renderContextSpecificItem,
useInteraction(option, query) {
return option.interaction(query);
},
filter: shouldShowContextSpecificResult,
sort: localResultSort,
useOptions() {
return useSpotlightContextResults().filter((item) => item.placement === "fallback");
}, },
}); });
+19 -1
View File
@@ -11,10 +11,13 @@ export type ContextSpecificItem = {
name: string; name: string;
icon: TablerIcon | string; icon: TablerIcon | string;
interaction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>; interaction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
aliases?: string[];
placement?: "primary" | "fallback";
disabled?: boolean; disabled?: boolean;
description?: string; description?: string;
unavailable?: boolean; unavailable?: boolean;
alwaysVisible?: boolean; alwaysVisible?: boolean;
dedupeKey?: string;
}; };
interface SpotlightContextProps { interface SpotlightContextProps {
@@ -60,7 +63,22 @@ const createSpotlightContext = (displayName: string) => {
}); });
}, []); }, []);
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]); const items = useMemo(() => {
const uniqueItems: ContextSpecificItem[] = [];
const itemKeys = new Set<string>();
for (const registration of itemsMap.values()) {
for (const item of registration.items) {
const itemKey = item.dedupeKey ?? item.id;
if (itemKeys.has(itemKey)) continue;
itemKeys.add(itemKey);
uniqueItems.push(item);
}
}
return uniqueItems;
}, [itemsMap]);
const itemsContext = useMemo(() => ({ items }), [items]); const itemsContext = useMemo(() => ({ items }), [items]);
const registration = useMemo(() => ({ registerItems, unregisterItems }), [registerItems, unregisterItems]); const registration = useMemo(() => ({ registerItems, unregisterItems }), [registerItems, unregisterItems]);
@@ -1,19 +1,21 @@
import { Box, Group, Stack, Text } from "@mantine/core"; import { Box, Group, Stack, Text } from "@mantine/core";
import { IconSearch, IconSearchOff } from "@tabler/icons-react"; import { IconSearchOff } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { Session } from "@homarr/auth";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { useSearchEnginesCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { useFromIntegrationSearchInteraction } from "../external/search-engines-search-group"; import { buildSearchUrl } from "../external/search-engines-search-group";
type SearchEngine = RouterOutputs["searchEngine"]["catalog"][number];
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type GroupItem = { type GroupItem = {
id: string; id: string;
@@ -21,19 +23,20 @@ type GroupItem = {
description?: string; description?: string;
icon: TablerIcon | string; icon: TablerIcon | string;
disabled?: boolean; disabled?: boolean;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>; interaction: inferSearchInteractionDefinition<SearchInteraction>;
}; };
export const homeSearchEngineGroup = createGroup<GroupItem>({ export const homeSearchEngineGroup = createGroup<GroupItem>({
title: (t) => t("search.mode.home.group.search.title"), title: (t) => t("search.mode.home.group.search.title"),
keyPath: "id", keyPath: "id",
source: { kind: "fallback" },
Component(item) { Component(item) {
const icon = const icon =
typeof item.icon !== "string" ? ( typeof item.icon !== "string" ? (
<item.icon size={24} /> <item.icon size={24} />
) : ( ) : (
<Box w={24} h={24}> <Box w={24} h={24}>
<img src={item.icon} alt={item.name} style={{ maxWidth: 24 }} /> <img src={item.icon} alt="" style={{ maxWidth: 24 }} />
</Box> </Box>
); );
@@ -42,121 +45,95 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
{icon} {icon}
<Stack gap={0}> <Stack gap={0}>
<Text>{item.name}</Text> <Text>{item.name}</Text>
{item.description && ( {item.description ? (
<Text c="gray.6" size="sm"> <Text c="gray.6" size="sm">
{item.description} {item.description}
</Text> </Text>
)} ) : null}
</Stack> </Stack>
</Group> </Group>
); );
}, },
useInteraction(item, query) { useInteraction(item) {
return item.useInteraction(query); return item.interaction;
},
filter() {
return true;
}, },
useQueryOptions(query) { useQueryOptions(query) {
const t = useI18n(); const t = useI18n();
const { data: session, status } = useSession(); const { data: session } = useSession();
const { data: defaultSearchEngine, ...defaultSearchEngineQuery } = const { openSearchInNewTab } = useSettings();
clientApi.searchEngine.getDefaultSearchEngine.useQuery(undefined, { const catalogQuery = useSearchEnginesCatalogQuery();
enabled: status !== "loading",
});
const fromIntegrationEnabled = defaultSearchEngine?.type === "fromIntegration" && query.length > 0;
const { data: results, ...resultQuery } = clientApi.integration.searchInIntegration.useQuery(
{
query,
integrationId: defaultSearchEngine?.integrationId ?? "",
},
{
enabled: fromIntegrationEnabled,
select: (data) => data.slice(0, 5),
},
);
return { return {
isLoading: isLoading: catalogQuery.isLoading,
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading", isError: catalogQuery.isError,
isError: defaultSearchEngineQuery.isError || (resultQuery.isError && fromIntegrationEnabled), data: createSearchEntries(
data: createDefaultSearchEntries(defaultSearchEngine, results, session, query, t), catalogQuery.data ?? [],
session?.user.permissions.includes("search-engine-create") === true,
query,
openSearchInNewTab,
t,
),
}; };
}, },
}); });
const createDefaultSearchEntries = ( const createSearchEntries = (
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null, searchEngines: SearchEngine[],
results: RouterOutputs["integration"]["searchInIntegration"] | undefined, canCreateSearchEngine: boolean,
session: Session | null,
query: string, query: string,
openInNewTab: boolean,
t: TranslationFunction, t: TranslationFunction,
): GroupItem[] => { ): GroupItem[] => {
if (!session?.user && !defaultSearchEngine) return []; if (searchEngines.length === 0) {
if (!defaultSearchEngine) {
return [ return [
{ {
id: "no-default", id: "no-search-engines",
name: t("search.mode.home.group.search.option.no-default.label"), name: t("search.mode.home.group.search.option.no-engines.label"),
description: t("search.mode.home.group.search.option.no-default.description"), description: canCreateSearchEngine
? t("search.mode.home.group.search.option.no-engines.descriptionCreate")
: t("search.mode.home.group.search.option.no-engines.descriptionUnavailable"),
icon: IconSearchOff, icon: IconSearchOff,
useInteraction() { disabled: !canCreateSearchEngine,
return { interaction: canCreateSearchEngine ? { type: "link", href: "/manage/search-engines/new" } : { type: "none" },
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: `/manage/users/${session!.user.id}/general`,
};
},
}, },
]; ];
} }
if (defaultSearchEngine.type === "generic") { const normalizedQuery = query.trim();
return [ return searchEngines.map((searchEngine) => {
{ const name =
id: "search", normalizedQuery.length === 0
name: t("search.mode.home.group.search.option.search.label", { ? searchEngine.name
query, : t("search.mode.home.group.search.option.search.label", {
name: defaultSearchEngine.name, query: normalizedQuery,
}), name: searchEngine.name,
icon: defaultSearchEngine.iconUrl, });
useInteraction(query) {
const { openSearchInNewTab } = useSettings();
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: defaultSearchEngine.urlTemplate!.replace("%s", query),
newTab: openSearchInNewTab,
};
},
},
];
}
if (!results) { if (searchEngine.type === "generic") {
return [ return {
{ id: `search-${searchEngine.id}`,
id: "from-integration", name,
name: defaultSearchEngine.name, description: searchEngine.description ?? undefined,
icon: defaultSearchEngine.iconUrl, icon: searchEngine.iconUrl,
description: t("search.mode.home.group.search.option.from-integration.description"), interaction: {
useInteraction() { type: "link",
return { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
type: "none", href: buildSearchUrl(searchEngine.urlTemplate!, normalizedQuery),
}; newTab: openInNewTab,
}, },
}, };
]; }
}
return results.map((result) => ({ return {
id: `search-${result.id}`, id: `search-${searchEngine.id}`,
name: result.name, name,
description: result.text, description: searchEngine.description ?? undefined,
icon: result.image ?? IconSearch, icon: searchEngine.iconUrl,
useInteraction() { interaction: {
return useFromIntegrationSearchInteraction(defaultSearchEngine, result); type: "mode",
}, mode: "external",
})); query: `${searchEngine.short} ${normalizedQuery}`.trimEnd(),
},
};
});
}; };
+33 -5
View File
@@ -1,10 +1,38 @@
import type { SearchMode } from "../../lib/mode"; import type { SearchMode } from "../../lib/mode";
import { preferencesGroup } from "../preferences/groups"; import { appIntegrationBoardMode } from "../app-integration-board";
import { contextSpecificSearchGroups } from "./context-specific-group"; import { contextSpecificActionsSearchGroups } from "../command/context-specific-group";
import { globalCommandGroup } from "../command/global-group";
import { homeSearchEngineGroup } from "./home-search-engine-group"; import { homeSearchEngineGroup } from "./home-search-engine-group";
import {
contextSpecificAppsSearchGroup,
contextSpecificFallbackSearchGroup,
contextSpecificSearchGroups,
} from "./context-specific-group";
import { mediaFallbackGroup } from "../media/media-fallback-group";
import { pagesSearchGroup } from "../page/pages-search-group";
import { preferencesGroup } from "../preferences/groups";
import { userGroupMode } from "../user-group";
export const homeMode = { export const homeMode = {
character: undefined, mode: "search",
modeKey: "home", character: "/",
groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups], label: (t) => t("search.modePicker.search.label"),
placeholder: (t) => t("search.modePicker.search.placeholder"),
useGroups() {
const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificAppsSearchGroup);
const directoryGroups = userGroupMode.useGroups();
return [
contextSpecificSearchGroups,
pagesSearchGroup,
contextSpecificActionsSearchGroups,
globalCommandGroup,
preferencesGroup,
...appGroups,
...directoryGroups,
homeSearchEngineGroup,
contextSpecificFallbackSearchGroup,
mediaFallbackGroup,
];
},
} satisfies SearchMode; } satisfies SearchMode;
+9 -7
View File
@@ -1,17 +1,19 @@
import { appIntegrationBoardMode } from "./app-integration-board"; import { appIntegrationBoardMode } from "./app-integration-board";
import { assistantMode } from "./assistant";
import { commandMode } from "./command"; import { commandMode } from "./command";
import { externalMode } from "./external"; import { externalMode } from "./external";
import { homeMode } from "./home"; import { homeMode } from "./home";
import { mediaMode } from "./media"; import { mediaMode } from "./media";
import { pageMode } from "./page"; import { preferencesMode } from "./preferences";
import { userGroupMode } from "./user-group"; import { userGroupMode } from "./user-group";
export const searchModes = [ export const searchModes = [
userGroupMode,
appIntegrationBoardMode,
externalMode,
commandMode,
pageMode,
mediaMode,
homeMode, homeMode,
appIntegrationBoardMode,
commandMode,
preferencesMode,
assistantMode,
externalMode,
mediaMode,
userGroupMode,
] as const; ] as const;
+3 -1
View File
@@ -2,7 +2,9 @@ import type { SearchMode } from "../../lib/mode";
import { mediaRequestSearchGroup } from "./media-request-search-group"; import { mediaRequestSearchGroup } from "./media-request-search-group";
export const mediaMode = { export const mediaMode = {
modeKey: "media", mode: "media",
character: undefined, character: undefined,
label: (t) => t("search.modePicker.media.label"),
placeholder: (t) => t("search.modePicker.media.placeholder"),
groups: [mediaRequestSearchGroup], groups: [mediaRequestSearchGroup],
} satisfies SearchMode; } satisfies SearchMode;
@@ -0,0 +1,87 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconMovie } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { useRemoteQuery } from "../../lib/remote-query";
type MediaFallbackOption = {
key: string;
label: string;
description: string;
query: string;
unavailable?: boolean;
};
/** Discovers whether media search is usable, but intentionally never performs a media search. */
export const mediaFallbackGroup = createGroup<MediaFallbackOption>({
keyPath: "key",
title: (t) => t("search.modePicker.continueWith"),
source: { kind: "fallback" },
Component(option) {
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.5 : 1}>
<IconMovie stroke={1.5} />
<Stack gap={0}>
<Text>{option.label}</Text>
<Text size="xs" c="dimmed">
{option.description}
</Text>
</Stack>
</Group>
);
},
useInteraction(option) {
if (option.unavailable) return { type: "none" };
return { type: "mode", mode: "media", query: option.query };
},
useQueryOptions(query) {
const t = useI18n();
const remoteQuery = useRemoteQuery(query, "media");
const { data: session, status } = useSession();
const canDiscoverTargets = remoteQuery.enabled && Boolean(session?.user);
const targets = clientApi.integration.mediaRequestSearchTargets.useQuery(undefined, {
enabled: canDiscoverTargets,
staleTime: 60_000,
});
if (!remoteQuery.enabled) return { data: [], isLoading: false, isError: false };
if (status === "loading") return { data: [], isLoading: true, isError: false };
if (!session?.user) {
return {
data: [
{
key: "media-fallback",
label: t("search.modePicker.media.fallback", { query: remoteQuery.query }),
description: t("search.mode.media.action.search.disabled.signInRequired"),
query: remoteQuery.query,
unavailable: true,
},
],
isLoading: false,
isError: false,
};
}
if (!targets.data) return { data: [], isLoading: targets.isLoading, isError: targets.isError };
const unavailable = targets.data.length === 0;
return {
isLoading: targets.isLoading,
isError: targets.isError,
data: [
{
key: "media-fallback",
label: t("search.modePicker.media.fallback", { query: remoteQuery.query }),
description: unavailable
? t("search.mode.media.action.search.disabled.noIntegration")
: t("search.mode.media.action.search.description"),
query: remoteQuery.query,
unavailable,
},
],
};
},
});
@@ -1,17 +1,17 @@
import { Badge, Group, Image, Stack, Text } from "@mantine/core"; import { Badge, Group, Image, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconMovie, IconSearch } from "@tabler/icons-react"; import { IconMovie, IconSearch } from "@tabler/icons-react";
import { keepPreviousData } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { getIntegrationName } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions";
import type { MediaAvailability } from "@homarr/integrations/types"; import type { MediaAvailability } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types"; import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import { useRemoteQuery } from "../../lib/remote-query";
import { mediaRequestSearchScopeAtom } from "../../spotlight-store"; import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group"; import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
@@ -23,7 +23,14 @@ type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequest
type MediaRequestSearchOption = type MediaRequestSearchOption =
| { | {
key: string; key: string;
kind: "disabled" | "hint"; kind: "disabled";
label: string;
description: string;
unavailable: true;
}
| {
key: string;
kind: "hint";
label: string; label: string;
description: string; description: string;
} }
@@ -36,6 +43,7 @@ type MediaRequestSearchOption =
export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
keyPath: "key", keyPath: "key",
title: (t) => t("search.mode.media.group.title"), title: (t) => t("search.mode.media.group.title"),
source: { kind: "remote", source: "media" },
Component(option) { Component(option) {
const tMedia = useI18n("search.mode.media"); const tMedia = useI18n("search.mode.media");
@@ -66,7 +74,7 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
return ( return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs"> <Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{result.image ? ( {result.image ? (
<Image src={result.image} w={35} h={50} fit="cover" radius="md" /> <Image src={result.image} alt="" w={35} h={50} fit="cover" radius="md" />
) : ( ) : (
<IconSearch stroke={1.5} size={35} /> <IconSearch stroke={1.5} size={35} />
)} )}
@@ -98,21 +106,24 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
useQueryOptions(query) { useQueryOptions(query) {
const tMedia = useI18n("search.mode.media"); const tMedia = useI18n("search.mode.media");
const scope = useAtomValue(mediaRequestSearchScopeAtom); const scope = useAtomValue(mediaRequestSearchScopeAtom);
const [debouncedQuery] = useDebouncedValue(query.trim(), 150); const remoteQuery = useRemoteQuery(query, "media");
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery(); const { data: session, status } = useSession();
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery(undefined, {
enabled: status !== "loading" && Boolean(session?.user),
staleTime: 60_000,
});
const scopedTargets = (targetsQuery.data ?? []).filter((target) => const scopedTargets = (targetsQuery.data ?? []).filter((target) =>
scope.integrationIds ? scope.integrationIds.includes(target.id) : true, scope.integrationIds ? scope.integrationIds.includes(target.id) : true,
); );
const hasScopedTargets = scopedTargets.length > 0; const hasScopedTargets = scopedTargets.length > 0;
const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets; const shouldSearch = remoteQuery.enabled && hasScopedTargets;
const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined; const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined;
const resultsQuery = clientApi.integration.searchMediaRequests.useQuery( const resultsQuery = clientApi.integration.searchMediaRequests.useQuery(
{ query: debouncedQuery, integrationIds }, { query: remoteQuery.query, integrationIds },
{ {
enabled: shouldSearch, enabled: shouldSearch,
placeholderData: keepPreviousData,
select: (data) => data.slice(0, 10), select: (data) => data.slice(0, 10),
}, },
); );
@@ -120,6 +131,26 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading); const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading);
const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError); const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError);
if (status === "loading") {
return { data: [], isLoading: true, isError: false };
}
if (!session?.user) {
return {
data: [
{
key: "disabled",
kind: "disabled",
label: tMedia("action.search.label"),
description: tMedia("action.search.disabled.signInRequired"),
unavailable: true,
},
],
isLoading: false,
isError: false,
};
}
if (!targetsQuery.data) { if (!targetsQuery.data) {
return { return {
isLoading, isLoading,
@@ -140,12 +171,13 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
description: scope.integrationIds description: scope.integrationIds
? tMedia("action.search.disabled.scoped") ? tMedia("action.search.disabled.scoped")
: tMedia("action.search.disabled.noIntegration"), : tMedia("action.search.disabled.noIntegration"),
unavailable: true,
}, },
], ],
}; };
} }
if (debouncedQuery.length === 0) { if (!remoteQuery.enabled) {
return { return {
isLoading, isLoading,
isError, isError,
@@ -1,8 +0,0 @@
import type { SearchMode } from "../../lib/mode";
import { pagesSearchGroup } from "./pages-search-group";
export const pageMode = {
modeKey: "page",
character: "/",
groups: [pagesSearchGroup],
} satisfies SearchMode;
@@ -30,6 +30,7 @@ export const pagesSearchGroup = createGroup<{
}>({ }>({
keyPath: "path", keyPath: "path",
title: (t) => t("search.mode.page.group.page.title"), title: (t) => t("search.mode.page.group.page.title"),
source: { kind: "local" },
Component: ({ name, icon: Icon }) => ( Component: ({ name, icon: Icon }) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<Icon stroke={1.5} /> <Icon stroke={1.5} />
@@ -1,90 +1,22 @@
import { Group, Text } from "@mantine/core";
import { IconSettings } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { colorSchemes } from "@homarr/definitions";
import type { UserPreferenceKey } from "@homarr/settings";
import { visiblePreferenceDefinitions } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import { import type { ChildrenAction } from "../../lib/children";
preferenceChildrenOptionsByKey, import { useSettingsActions } from "../command/children/preferences/settings-children";
preferenceIcons,
userFieldPreferenceLabels,
} from "../../preferences/preference-registry";
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
type PreferencesGroupOption = type PreferenceAction = ChildrenAction<Record<string, unknown>>;
| { key: "settings"; preferenceKey: null }
| { key: UserPreferenceKey; preferenceKey: UserPreferenceKey };
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null }; /** Preferences are normal command-menu actions, not a gateway into another hidden list. */
export const preferencesGroup = createGroup<PreferenceAction>({
export const preferencesGroup = createGroup<PreferencesGroupOption>({
keyPath: "key", keyPath: "key",
title: (t) => t("search.mode.command.group.preferences.title"), title: (t) => t("search.mode.command.group.preferences.title"),
source: { kind: "local" },
useOptions(query) { useOptions(query) {
const t = useI18n(); return useSettingsActions({}, query);
const tColorScheme = useI18n("common.colorScheme.options");
const tUserField = useI18n("user.field");
const { data: session } = useSession();
const definitions = visiblePreferenceDefinitions(Boolean(session?.user));
const q = query.trim().toLowerCase();
if (q.length === 0) return [settingsOption];
const matching = definitions.flatMap((def): PreferencesGroupOption[] => {
if (!preferenceChildrenOptionsByKey[def.key]) return [];
const userFieldKey = userFieldPreferenceLabels[def.key];
const label = userFieldKey
? tUserField(`${userFieldKey}.label` as never)
: t(`search.mode.command.group.preferences.option.${def.key}.label` as never);
const searchValues = [
label,
...def.aliases,
...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []),
];
if (!searchValues.some((v) => v.toLowerCase().includes(q))) return [];
return [{ key: def.key, preferenceKey: def.key }];
});
if (matching.length > 0) return matching;
const allSearchValues = [
t("search.mode.command.group.preferences.title"),
...definitions.flatMap((d) => d.aliases),
];
return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [settingsOption] : [];
}, },
Component: (option) => { Component(option) {
const t = useI18n("search.mode.command.group.preferences"); return <option.Component />;
const tOption = useI18n("search.mode.command.group.preferences.option"); },
const tUserField = useI18n("user.field"); useInteraction(option, query) {
const Icon = option.preferenceKey return option.useInteraction({}, query);
? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons]
: IconSettings;
let label = t("title");
if (option.preferenceKey) {
const userFieldKey = userFieldPreferenceLabels[option.preferenceKey];
label = userFieldKey
? tUserField(`${userFieldKey}.label` as never)
: tOption(`${option.preferenceKey}.label` as never);
}
return (
<Group px="md" py="sm" wrap="nowrap" gap="sm">
<Icon stroke={1.5} />
<Text>{label}</Text>
</Group>
);
}, },
filter: () => true, filter: () => true,
useInteraction: (option) => ({
type: "children",
...(option.preferenceKey
? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ??
settingsChildrenOptions({}))
: settingsChildrenOptions({})),
}),
}); });
@@ -0,0 +1,10 @@
import type { SearchMode } from "../../lib/mode";
import { preferencesGroup } from "./groups";
export const preferencesMode = {
mode: "preferences",
character: "?",
label: (t) => t("search.modePicker.preferences.label"),
placeholder: (t) => t("search.modePicker.preferences.placeholder"),
groups: [preferencesGroup],
} satisfies SearchMode;
@@ -7,6 +7,7 @@ import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children"; import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
// This has to be type so it can be interpreted as Record<string, unknown>. // This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -71,6 +72,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
export const groupsSearchGroup = createGroup<Group>({ export const groupsSearchGroup = createGroup<Group>({
keyPath: "id", keyPath: "id",
title: (t) => t("common.entity.groups"), title: (t) => t("common.entity.groups"),
source: { kind: "remote", source: "groups" },
Component: ({ name }) => ( Component: ({ name }) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<Text>{name}</Text> <Text>{name}</Text>
@@ -78,6 +80,7 @@ export const groupsSearchGroup = createGroup<Group>({
), ),
useInteraction: interaction.children(groupChildrenOptions), useInteraction: interaction.children(groupChildrenOptions),
useQueryOptions(query) { useQueryOptions(query) {
return clientApi.group.search.useQuery({ query, limit: 5 }); const remoteQuery = useRemoteQuery(query, "groups");
return clientApi.group.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
}, },
}); });
@@ -1,9 +1,17 @@
import { useSession } from "@homarr/auth/client";
import type { SearchMode } from "../../lib/mode"; import type { SearchMode } from "../../lib/mode";
import { groupsSearchGroup } from "./groups-search-group"; import { groupsSearchGroup } from "./groups-search-group";
import { usersSearchGroup } from "./users-search-group"; import { usersSearchGroup } from "./users-search-group";
export const userGroupMode = { export const userGroupMode = {
modeKey: "userGroup", mode: "userGroup",
character: "@", character: "@",
groups: [usersSearchGroup, groupsSearchGroup], label: (t) => t("search.modePicker.userGroup.label"),
placeholder: (t) => t("search.modePicker.userGroup.placeholder"),
useGroups() {
const { data: session } = useSession();
if (!session?.user.permissions.includes("admin")) return [];
return [usersSearchGroup, groupsSearchGroup];
},
} satisfies SearchMode; } satisfies SearchMode;
@@ -8,6 +8,7 @@ import { UserAvatar } from "@homarr/ui";
import { createChildrenOptions } from "../../lib/children"; import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
// This has to be type so it can be interpreted as Record<string, unknown>. // This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -49,6 +50,7 @@ const userChildrenOptions = createChildrenOptions<User>({
export const usersSearchGroup = createGroup<User>({ export const usersSearchGroup = createGroup<User>({
keyPath: "id", keyPath: "id",
title: (t) => t("common.entity.users"), title: (t) => t("common.entity.users"),
source: { kind: "remote", source: "users" },
Component: (user) => ( Component: (user) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<UserAvatar user={user} size="sm" /> <UserAvatar user={user} size="sm" />
@@ -57,6 +59,7 @@ export const usersSearchGroup = createGroup<User>({
), ),
useInteraction: interaction.children(userChildrenOptions), useInteraction: interaction.children(userChildrenOptions),
useQueryOptions(query) { useQueryOptions(query) {
return clientApi.user.search.useQuery({ query, limit: 5 }); const remoteQuery = useRemoteQuery(query, "users");
return clientApi.user.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
}, },
}); });
+12 -6
View File
@@ -20,19 +20,22 @@ describe("lazy Spotlight open bridge", () => {
const listener = vi.fn(); const listener = vi.fn();
window.addEventListener(spotlightOpenEvent, listener); window.addEventListener(spotlightOpenEvent, listener);
openSpotlight(); openSpotlight({ mode: "apps", query: "rada" });
expect(listener).toHaveBeenCalledOnce(); expect(listener).toHaveBeenCalledOnce();
expect(consumePendingSpotlightOpen()).toBe(true); expect(listener.mock.calls[0]?.[0]).toMatchObject({
expect(consumePendingSpotlightOpen()).toBe(false); detail: { mode: "apps", query: "rada" },
});
expect(consumePendingSpotlightOpen()).toEqual({ mode: "apps", query: "rada" });
expect(consumePendingSpotlightOpen()).toBeNull();
window.removeEventListener(spotlightOpenEvent, listener); window.removeEventListener(spotlightOpenEvent, listener);
}); });
it("lets a listener installed after the request discover the pending open", () => { it("defaults to search and lets a listener installed after the request discover the pending intent", () => {
openSpotlight(); openSpotlight();
expect(hasPendingSpotlightOpen()).toBe(true); expect(hasPendingSpotlightOpen()).toBe(true);
expect(consumePendingSpotlightOpen()).toBe(true); expect(consumePendingSpotlightOpen()).toEqual({ mode: "search" });
expect(hasPendingSpotlightOpen()).toBe(false); expect(hasPendingSpotlightOpen()).toBe(false);
}); });
@@ -45,8 +48,11 @@ describe("lazy Spotlight open bridge", () => {
openMediaRequestSearch({ integrationIds: ["integration-a"], query: "movie" }); openMediaRequestSearch({ integrationIds: ["integration-a"], query: "movie" });
expect(openListener).toHaveBeenCalledOnce(); expect(openListener).toHaveBeenCalledOnce();
expect(openListener.mock.calls[0]?.[0]).toMatchObject({
detail: { mode: "media", query: "movie" },
});
expect(mediaListener).toHaveBeenCalledOnce(); expect(mediaListener).toHaveBeenCalledOnce();
expect(consumePendingSpotlightOpen()).toBe(true); expect(consumePendingSpotlightOpen()).toEqual({ mode: "media", query: "movie" });
expect(consumePendingMediaRequestSearch()).toEqual({ integrationIds: ["integration-a"], query: "movie" }); expect(consumePendingMediaRequestSearch()).toEqual({ integrationIds: ["integration-a"], query: "movie" });
window.removeEventListener(spotlightOpenEvent, openListener); window.removeEventListener(spotlightOpenEvent, openListener);
window.removeEventListener(mediaRequestSearchEvent, mediaListener); window.removeEventListener(mediaRequestSearchEvent, mediaListener);
+37 -9
View File
@@ -3,30 +3,58 @@
export const spotlightOpenEvent = "homarr:spotlight:open"; export const spotlightOpenEvent = "homarr:spotlight:open";
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search"; export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
export type SpotlightMode =
| "search"
| "apps"
| "command"
| "preferences"
| "assistant"
| "external"
| "media"
| "userGroup";
export interface OpenSpotlightOptions {
mode?: SpotlightMode;
query?: string;
}
export interface SpotlightOpenIntent {
mode: SpotlightMode;
query?: string;
}
export interface OpenMediaRequestSearchOptions { export interface OpenMediaRequestSearchOptions {
integrationIds?: string[]; integrationIds?: string[];
query?: string; query?: string;
} }
let pendingMediaRequestSearch: OpenMediaRequestSearchOptions | null = null; let pendingMediaRequestSearch: OpenMediaRequestSearchOptions | null = null;
let pendingSpotlightOpen = false; let pendingSpotlightOpen: SpotlightOpenIntent | null = null;
const requestSpotlightMount = () => { const requestSpotlightMount = (intent: SpotlightOpenIntent) => {
if (typeof window !== "undefined") window.dispatchEvent(new Event(spotlightOpenEvent)); if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<SpotlightOpenIntent>(spotlightOpenEvent, { detail: intent }));
}
}; };
export const openSpotlight = () => { export const openSpotlight = (options: OpenSpotlightOptions = {}) => {
pendingSpotlightOpen = true; const intent: SpotlightOpenIntent = {
requestSpotlightMount(); mode: options.mode ?? "search",
};
if (options.query !== undefined) intent.query = options.query;
if (intent.mode !== "media") pendingMediaRequestSearch = null;
pendingSpotlightOpen = intent;
requestSpotlightMount(intent);
}; };
export const consumePendingSpotlightOpen = () => { export const consumePendingSpotlightOpen = () => {
const pending = pendingSpotlightOpen; const pending = pendingSpotlightOpen;
pendingSpotlightOpen = false; pendingSpotlightOpen = null;
return pending; return pending;
}; };
export const hasPendingSpotlightOpen = () => pendingSpotlightOpen; export const hasPendingSpotlightOpen = () => pendingSpotlightOpen !== null;
export const consumePendingMediaRequestSearch = () => { export const consumePendingMediaRequestSearch = () => {
const pending = pendingMediaRequestSearch; const pending = pendingMediaRequestSearch;
@@ -36,7 +64,7 @@ export const consumePendingMediaRequestSearch = () => {
export const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => { export const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
pendingMediaRequestSearch = options; pendingMediaRequestSearch = options;
openSpotlight(); openSpotlight({ mode: "media", query: options.query });
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options })); window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
+67 -7
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"
@@ -6306,6 +6314,9 @@
"toggleColorScheme": "Toggle light/dark mode", "toggleColorScheme": "Toggle light/dark mode",
"saveNotebook": "Save notebook (only inside notebook widget)", "saveNotebook": "Save notebook (only inside notebook widget)",
"openSpotlight": "Open search", "openSpotlight": "Open search",
"openSpotlightApps": "Open app search",
"openSpotlightAssistant": "Open Assistant mode",
"openSpotlightPreferences": "Open preferences mode",
"openAssistant": "Open Homarr Assistant" "openAssistant": "Open Homarr Assistant"
}, },
"note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS" "note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS"
@@ -6771,6 +6782,51 @@
"search": { "search": {
"placeholder": "Search for anything", "placeholder": "Search for anything",
"nothingFound": "Nothing found", "nothingFound": "Nothing found",
"modePicker": {
"search": {
"label": "Search",
"placeholder": "Search apps, boards, pages, commands, and preferences"
},
"apps": {
"label": "Apps",
"placeholder": "Search apps, boards, and integrations"
},
"command": {
"label": "Commands",
"placeholder": "Search actions and commands"
},
"preferences": {
"label": "Preferences",
"placeholder": "Search and change preferences"
},
"assistant": {
"label": "Assistant",
"placeholder": "Ask Homarr Assistant"
},
"external": {
"label": "Web",
"placeholder": "Search with an external search engine"
},
"media": {
"label": "Media",
"placeholder": "Search for a movie or show",
"fallback": "Search media for \"{query}\""
},
"userGroup": {
"label": "People",
"placeholder": "Search users and groups"
},
"continueWith": "Continue with",
"dialogAriaLabel": "{mode} command menu",
"navigationLabel": "Search modes",
"modeAriaLabel": "{mode} mode",
"resultsAriaLabel": "{mode} results",
"footer": {
"navigate": "Navigate",
"open": "Open",
"close": "Close"
}
},
"error": { "error": {
"fetch": "An error occurred while fetching data" "fetch": "An error occurred while fetching data"
}, },
@@ -6992,6 +7048,7 @@
"description": "Start typing to search your media request integrations", "description": "Start typing to search your media request integrations",
"disabled": { "disabled": {
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.", "noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
"signInRequired": "Sign in to search media request integrations.",
"scoped": "This widget does not have an accessible media request integration." "scoped": "This widget does not have an accessible media request integration."
} }
} }
@@ -7069,9 +7126,10 @@
"other": { "other": {
"label": "Search with another search engine" "label": "Search with another search engine"
}, },
"no-default": { "no-engines": {
"label": "No default search engine", "label": "No search engines configured",
"description": "Set a default search engine in preferences" "descriptionCreate": "Add a search engine to search from the command menu",
"descriptionUnavailable": "Ask an administrator to configure a search engine"
}, },
"search": { "search": {
"label": "Search for \"{query}\" with {name}" "label": "Search for \"{query}\" with {name}"
@@ -7763,6 +7821,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";
@@ -49,7 +49,7 @@ import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar"; import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectableCard } from "../components/selectable-card"; import { SelectableCard } from "../components/selectable-card";
import { selectGridCols } from "../select-grid-layout"; import { selectGridCols } from "../select-grid-layout";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; import { buildSortedIntegrations, filterIntegrations } from "./integration-grid-shared";
import type { IntegrationGridItem } from "./integration-grid-shared"; import type { IntegrationGridItem } from "./integration-grid-shared";
export interface IntegrationSelectGridProps { export interface IntegrationSelectGridProps {
@@ -134,8 +134,11 @@ const IntegrationCard = ({
onClick={() => onSelect(integration.kind)} onClick={() => onSelect(integration.kind)}
aria-label={integration.name} aria-label={integration.name}
icon={<IntegrationAvatar kind={integration.kind} size="sm" />} icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
title={integration.name} title={
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />} <Text fw={700} size="md" style={{ whiteSpace: "nowrap" }}>
{integration.name}
</Text>
}
footerLeft={ footerLeft={
connectedCount > 0 ? ( connectedCount > 0 ? (
<Badge variant="light" color="teal" size="xs" radius="xs"> <Badge variant="light" color="teal" size="xs" radius="xs">
@@ -232,24 +235,3 @@ const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; lim
</Group> </Group>
); );
}; };
const IntegrationCategoryBadges = ({
categories,
limit,
}: {
categories: IntegrationGridItem["categories"];
limit?: number;
}) => {
const t = useI18n();
const items = limit ? categories.slice(0, limit) : categories;
return (
<Group gap={4} wrap="wrap">
{items.map((category) => (
<Badge key={category} variant="light" size="xs" color="gray" radius="xs">
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
</Badge>
))}
</Group>
);
};
+13
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,
@@ -96,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,
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more