Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65f0881504 | ||
|
|
2b9aa0fdf1 | ||
|
|
45368e11fc | ||
|
|
3f9b024020 | ||
|
|
6197c8e505 | ||
|
|
6316fa5407 | ||
|
|
7acff56abb | ||
|
|
389a99a203 | ||
|
|
91b916f015 | ||
|
|
769204ca3b | ||
|
|
ec295fe26e | ||
|
|
e56112cfd7 | ||
|
|
449adc5b12 | ||
|
|
240397e7ac | ||
|
|
3efb719eee | ||
|
|
382cffc015 | ||
|
|
0073dfe044 | ||
|
|
a22fb60647 | ||
|
|
89e4cb843d | ||
|
|
65a96b6b14 | ||
|
|
357355283c | ||
|
|
a2a408410a | ||
|
|
0d9eeb5752 | ||
|
|
bf0878c68b | ||
|
|
eb6d337dfb | ||
|
|
978b3c7f33 | ||
|
|
07547320f1 | ||
|
|
02870fc392 | ||
|
|
b6fc27e44d | ||
|
|
d5cf1b82f2 | ||
|
|
e022c3f735 | ||
|
|
14cc3e8a44 | ||
|
|
93d160aebd | ||
|
|
493e93ea42 | ||
|
|
85c20702cd | ||
|
|
91b248d395 | ||
|
|
b7eadc7867 | ||
|
|
1861d8415c | ||
|
|
7fe5f8f81c | ||
|
|
b3a30e0cf6 | ||
|
|
763e1324e8 | ||
|
|
5188ed89d6 | ||
|
|
9eafd4f7a7 | ||
|
|
71ffa5d1be |
No files matched your search
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
name: homarr-local-harness
|
|
||||||
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Homarr Local Harness
|
|
||||||
|
|
||||||
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
|
|
||||||
|
|
||||||
## Set up the harness
|
|
||||||
|
|
||||||
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness setup feature-board
|
|
||||||
```
|
|
||||||
|
|
||||||
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
|
|
||||||
|
|
||||||
- `DEMO_MODE=true`
|
|
||||||
- `DEMO_READ_ONLY=false`
|
|
||||||
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
|
|
||||||
|
|
||||||
Pass repeatable environment overrides when the feature needs them:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness setup feature-board \
|
|
||||||
--env WORKSHOP_WEB_URL=https://example.test \
|
|
||||||
--env FEATURE_FLAG=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
|
|
||||||
|
|
||||||
Always use the URL printed by the command. If the output is no longer available, query it directly:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness port feature-board
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
|
|
||||||
|
|
||||||
## Browser validation
|
|
||||||
|
|
||||||
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
agent-browser skills get core
|
|
||||||
```
|
|
||||||
|
|
||||||
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
|
|
||||||
Stop the named container when finished:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness stop feature-board
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Homarr Local Harness"
|
|
||||||
short_description: "Build and browse a writable local Homarr branch harness"
|
|
||||||
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
|
|
||||||
@@ -30,6 +30,26 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"reportUnusedDisableDirectives": "off"
|
"reportUnusedDisableDirectives": "off"
|
||||||
},
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["apps/nextjs/src/**/*.ts", "apps/nextjs/src/**/*.tsx"],
|
||||||
|
"excludeFiles": ["**/*.spec.ts", "**/*.spec.tsx"],
|
||||||
|
"rules": {
|
||||||
|
"eslint/no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"name": "@homarr/widgets",
|
||||||
|
"allowTypeImports": true,
|
||||||
|
"message": "Import a narrow @homarr/widgets export to preserve widget lazy loading."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"**/*.config.js",
|
"**/*.config.js",
|
||||||
"**/*.config.mjs",
|
"**/*.config.mjs",
|
||||||
|
|||||||
@@ -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 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,6 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { chromium, expect } from "@playwright/test";
|
import { chromium, expect } from "@playwright/test";
|
||||||
|
import type { Route } from "@playwright/test";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { stringify } from "superjson";
|
import { stringify } from "superjson";
|
||||||
import { describe, test } from "vitest";
|
import { describe, test } from "vitest";
|
||||||
@@ -21,7 +22,9 @@ describe("lazy widget application graph", () => {
|
|||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
const layoutId = createId();
|
const layoutId = createId();
|
||||||
const sectionId = createId();
|
const sectionId = createId();
|
||||||
const itemId = createId();
|
const clockItemId = createId();
|
||||||
|
const downloadsItemId = createId();
|
||||||
|
const mockIntegrationId = createId();
|
||||||
|
|
||||||
await db.insert(sqliteSchema.boards).values({
|
await db.insert(sqliteSchema.boards).values({
|
||||||
id: boardId,
|
id: boardId,
|
||||||
@@ -41,26 +44,56 @@ describe("lazy widget application graph", () => {
|
|||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
});
|
});
|
||||||
await db.insert(sqliteSchema.items).values({
|
await db.insert(sqliteSchema.integrations).values({
|
||||||
id: itemId,
|
id: mockIntegrationId,
|
||||||
boardId,
|
name: "Mock downloads",
|
||||||
kind: "clock",
|
url: "http://mock.local",
|
||||||
options: stringify({ showSeconds: true }),
|
kind: "mock",
|
||||||
});
|
});
|
||||||
await db.insert(sqliteSchema.itemLayouts).values({
|
await db.insert(sqliteSchema.items).values([
|
||||||
itemId,
|
{
|
||||||
sectionId,
|
id: clockItemId,
|
||||||
layoutId,
|
boardId,
|
||||||
xOffset: 0,
|
kind: "clock",
|
||||||
yOffset: 0,
|
options: stringify({ showSeconds: true }),
|
||||||
width: 4,
|
},
|
||||||
height: 2,
|
{
|
||||||
|
id: downloadsItemId,
|
||||||
|
boardId,
|
||||||
|
kind: "downloads",
|
||||||
|
options: stringify({}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(sqliteSchema.itemLayouts).values([
|
||||||
|
{
|
||||||
|
itemId: clockItemId,
|
||||||
|
sectionId,
|
||||||
|
layoutId,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 4,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: downloadsItemId,
|
||||||
|
sectionId,
|
||||||
|
layoutId,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 2,
|
||||||
|
width: 8,
|
||||||
|
height: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(sqliteSchema.integrationItems).values({
|
||||||
|
itemId: downloadsItemId,
|
||||||
|
integrationId: mockIntegrationId,
|
||||||
});
|
});
|
||||||
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
||||||
|
|
||||||
const homarrContainer = await createHomarrContainer({
|
const homarrContainer = await createHomarrContainer({
|
||||||
environment: {
|
environment: {
|
||||||
AUTH_PROVIDERS: "credentials",
|
AUTH_PROVIDERS: "credentials",
|
||||||
|
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
|
||||||
},
|
},
|
||||||
mounts: {
|
mounts: {
|
||||||
"/appdata": localMountPath,
|
"/appdata": localMountPath,
|
||||||
@@ -69,10 +102,18 @@ describe("lazy widget application graph", () => {
|
|||||||
|
|
||||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext({ serviceWorkers: "block" });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const pageErrors: Error[] = [];
|
const pageErrors: Error[] = [];
|
||||||
|
const hydrationErrors: string[] = [];
|
||||||
page.on("pageerror", (error) => pageErrors.push(error));
|
page.on("pageerror", (error) => pageErrors.push(error));
|
||||||
|
const hydrationErrorPattern = /hydrat|did not match|react\.dev\/errors\/4(18|2[1235])|react error #4(18|2[1235])/i;
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error" && hydrationErrorPattern.test(message.text())) {
|
||||||
|
hydrationErrors.push(message.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let releaseRefresh: (() => void) | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`${baseUrl}/auth/login`);
|
await page.goto(`${baseUrl}/auth/login`);
|
||||||
@@ -88,9 +129,18 @@ describe("lazy widget application graph", () => {
|
|||||||
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
||||||
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
||||||
const clockWidget = page.locator(
|
const clockWidget = page.locator(
|
||||||
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
`[data-id="${clockItemId}"] .clock-widget-container, [data-grid-item-id="${clockItemId}"] .clock-widget-container`,
|
||||||
);
|
);
|
||||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
const downloadsWidget = page
|
||||||
|
.locator(`[data-id="${downloadsItemId}"], [data-grid-item-id="${downloadsItemId}"]`)
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
const cachedDownload = downloadsWidget.getByText("Big.Buck.Bunny.2008.1080p.BluRay.x264");
|
||||||
|
await expect(cachedDownload).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
@@ -101,23 +151,59 @@ describe("lazy widget application graph", () => {
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
||||||
|
|
||||||
await expect
|
let refreshRequestStarted = false;
|
||||||
.poll(
|
const refreshGate = new Promise<void>((resolve) => {
|
||||||
async () =>
|
releaseRefresh = resolve;
|
||||||
await page.evaluate(async () => {
|
});
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
const downloadsRequestPattern = "**/*widget.downloads.getJobsAndStatuses*";
|
||||||
return registrations[0]?.scope ?? null;
|
const delayDownloadsRefresh = async (route: Route) => {
|
||||||
}),
|
refreshRequestStarted = true;
|
||||||
{ timeout: 15_000 },
|
await refreshGate;
|
||||||
)
|
await route.continue();
|
||||||
.toBe(`${baseUrl}/`);
|
};
|
||||||
|
await page.route(downloadsRequestPattern, delayDownloadsRefresh);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
||||||
|
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
|
||||||
|
|
||||||
|
const refreshedDownloadsResponse = page.waitForResponse(
|
||||||
|
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
||||||
|
);
|
||||||
|
releaseRefresh();
|
||||||
|
releaseRefresh = undefined;
|
||||||
|
await refreshedDownloadsResponse;
|
||||||
|
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
|
||||||
|
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
|
expect(hydrationErrors).toEqual([]);
|
||||||
|
|
||||||
|
const serviceWorkerContext = await browser.newContext();
|
||||||
|
const serviceWorkerPage = await serviceWorkerContext.newPage();
|
||||||
|
try {
|
||||||
|
await serviceWorkerPage.goto(`${baseUrl}/auth/login`);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
await serviceWorkerPage.evaluate(async () => {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
return registrations[0]?.scope ?? null;
|
||||||
|
}),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(`${baseUrl}/`);
|
||||||
|
} finally {
|
||||||
|
await serviceWorkerContext.close();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
releaseRefresh?.();
|
||||||
await browser.close();
|
await browser.close();
|
||||||
await homarrContainer.stop();
|
await homarrContainer.stop();
|
||||||
}
|
}
|
||||||
}, 120_000);
|
}, 180_000);
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,8 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
|||||||
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||||
|
|
||||||
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||||
|
// Beszel system stats are large time-series payloads and were deliberately kept out of
|
||||||
|
// the persisted cache in #6289; live stats come from an SSE subscription instead.
|
||||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||||
|
|
||||||
export const isWidgetDataTrpcPath = (path: string) => {
|
export const isWidgetDataTrpcPath = (path: string) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -6,3 +6,4 @@ export {
|
|||||||
useRegisterSpotlightContextResults,
|
useRegisterSpotlightContextResults,
|
||||||
useRegisterSpotlightContextActions,
|
useRegisterSpotlightContextActions,
|
||||||
} from "./modes/home/context";
|
} from "./modes/home/context";
|
||||||
|
export type { ContextSpecificItem } from "./modes/home/context";
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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"];
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
+188
-165
@@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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
Reference in new issue
Block a user