Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a714420f | ||
|
|
a56ec5d06a | ||
|
|
40e34181e7 | ||
|
|
6548da3772 | ||
|
|
3ee9b4f0c4 | ||
|
|
9303dbd284 | ||
|
|
97d8390b20 | ||
|
|
c9a48fa23f | ||
|
|
e7c8e2e01e | ||
|
|
fcca57ddf7 | ||
|
|
8b6387f321 | ||
|
|
b8bc304c5c | ||
|
|
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": {
|
||||
"reportUnusedDisableDirectives": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["apps/nextjs/src/**/*.ts", "apps/nextjs/src/**/*.tsx"],
|
||||
"excludeFiles": ["**/*.spec.ts", "**/*.spec.tsx"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "@homarr/widgets",
|
||||
"allowTypeImports": true,
|
||||
"message": "Import a narrow @homarr/widgets export to preserve widget lazy loading."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"**/*.config.js",
|
||||
"**/*.config.mjs",
|
||||
|
||||
@@ -36,6 +36,8 @@ pnpm cli harness setup feature-board
|
||||
|
||||
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
|
||||
|
||||
The demo board includes every widget kind. Integration-backed widgets use the bundled mock integration; media widgets use public stock assets.
|
||||
|
||||
```sh
|
||||
pnpm cli harness port feature-board
|
||||
```
|
||||
|
||||
@@ -8,13 +8,28 @@ Homarr has the following keyboard shortcuts:
|
||||
|
||||
| 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` + `E` | Toggles Edit Mode |
|
||||
| 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 |
|
||||
| `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
|
||||
|
||||
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
|
||||
|
||||
@@ -55,7 +55,7 @@ The studio keeps related choices together:
|
||||
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
|
||||
3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
|
||||
4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets.
|
||||
5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
||||
5. **Extend** explains and optionally configures Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
||||
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
|
||||
|
||||
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
|
||||
@@ -198,9 +198,13 @@ In the modal you can edit the app that you want to display:
|
||||
|
||||
<figure>
|
||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
||||
<figcaption>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>
|
||||
|
||||
When adding a widget, set its initial grid width and height directly in the preview. These size controls are only shown before the widget is added.
|
||||
|
||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
|
||||
Press and hold a handle, move the pointer, and release it when the tile has the size you want.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 45 KiB |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Configure and use Homarr Assistant.
|
||||
description: Configure and use Assistant.
|
||||
tags:
|
||||
- Assistant
|
||||
- AI
|
||||
@@ -8,10 +8,10 @@ tags:
|
||||
|
||||
# Assistant
|
||||
|
||||
Homarr Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
|
||||
Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
|
||||
approval before making changes.
|
||||
|
||||

|
||||

|
||||
|
||||
## Set it up
|
||||
|
||||
@@ -38,7 +38,7 @@ host gateway address.
|
||||
## Use it
|
||||
|
||||
- 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.
|
||||
|
||||
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
|
||||
|
||||
@@ -317,7 +317,7 @@ Change the radius of your items and Containers.
|
||||
This is the field where you can enter custom CSS.
|
||||
The styling will only be applied on the respective board page.
|
||||
|
||||
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
|
||||
When Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
|
||||
Appearance, Custom CSS, and app-status settings. The assistant reads the current stylesheet first and presents an
|
||||
editable review form before requesting approval to save the change. It does not edit responsive layouts, access control,
|
||||
or Danger zone actions.
|
||||
|
||||
@@ -125,7 +125,7 @@ model judges a result, the candidate must pass Homarr's real clipboard parser, c
|
||||
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
|
||||
runtime components.
|
||||
|
||||
To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run:
|
||||
To exercise the real Assistant lifecycle instead of accepting a returned JSON block, run:
|
||||
|
||||
```sh
|
||||
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
|
||||
|
||||
@@ -41,6 +41,10 @@ needs attention.
|
||||
4. Write responsive JSX using `data`, `status`, `options`, and temporary `inputs`.
|
||||
5. Test and preview, inspect Data and the redacted request journal, then save.
|
||||
|
||||
While focus is inside a code editor, use `Ctrl+Z`/`Cmd+Z` to undo and `Ctrl+Shift+Z`/`Cmd+Shift+Z` to redo. The toolbar exposes the same actions. This history is local to that editor; builder-wide undo is not yet available.
|
||||
|
||||
Changing the definition, instance options, or credentials invalidates the previous preview and its live-action session. Run **Test and preview** again before treating the data or result as current. Automatic load queries are queued with up to four running at once so larger previews stay within the request limit.
|
||||
|
||||
<CanonicalCustomWidgetExample
|
||||
id="named-widget-requests"
|
||||
label="Named queries and actions"
|
||||
|
||||
@@ -43,7 +43,7 @@ interface HomarrCustomWidgetV2 {
|
||||
### Design with AI
|
||||
|
||||
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and,
|
||||
when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
|
||||
when [Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
|
||||
widget schema and current non-secret configuration, but not the latest preview response. It proposes the matching create or
|
||||
update tool for your approval.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ remains available as a compatibility alias, but new clients should always use `/
|
||||
|
||||
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
|
||||
|
||||
The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
|
||||
The optional [Assistant](./assistant) uses these same tool definitions internally.
|
||||
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
|
||||
approval before running mutating procedures.
|
||||
|
||||
|
||||
@@ -7,9 +7,13 @@ tags:
|
||||
|
||||
# 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 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.
|
||||
|
||||
## 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.
|
||||
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
|
||||
|
||||
|
||||
+351
@@ -3453,6 +3453,9 @@
|
||||
"enableRightClickOnWidgets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"headerPreferences": {
|
||||
"type": "string"
|
||||
},
|
||||
"defaultSearchEngineId": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3488,6 +3491,7 @@
|
||||
"firstDayOfWeek",
|
||||
"pingIconsEnabled",
|
||||
"enableRightClickOnWidgets",
|
||||
"headerPreferences",
|
||||
"defaultSearchEngineId",
|
||||
"openSearchInNewTab",
|
||||
"ddgBangs",
|
||||
@@ -4346,6 +4350,353 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/header-preferences": {
|
||||
"patch": {
|
||||
"operationId": "userRouter-changeHeaderPreferences",
|
||||
"tags": ["users"],
|
||||
"security": [
|
||||
{
|
||||
"apikey": []
|
||||
}
|
||||
],
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"headerPreferences": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"const": 3
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"searchDisplay": {
|
||||
"type": "string",
|
||||
"enum": ["input", "icon"]
|
||||
},
|
||||
"logoDisplay": {
|
||||
"default": "logoAndText",
|
||||
"type": "string",
|
||||
"enum": ["logo", "logoAndText"]
|
||||
},
|
||||
"zones": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"left": {
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "builtin"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "board"
|
||||
},
|
||||
"boardId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
}
|
||||
},
|
||||
"required": ["type", "boardId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"center": {
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "builtin"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "board"
|
||||
},
|
||||
"boardId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
}
|
||||
},
|
||||
"required": ["type", "boardId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "builtin"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "board"
|
||||
},
|
||||
"boardId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
}
|
||||
},
|
||||
"required": ["type", "boardId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["left", "center", "right"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["version", "visible", "searchDisplay", "zones"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"const": 1
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"items": {
|
||||
"minItems": 1,
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["version", "visible", "items"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"const": 2
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"zones": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"left": {
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
},
|
||||
"center": {
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["left", "center", "right"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["version", "visible", "zones"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["id", "headerPreferences"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.BAD_REQUEST"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authorization not provided",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.UNAUTHORIZED"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Insufficient access",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.FORBIDDEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.NOT_FOUND"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/ddg-bangs": {
|
||||
"patch": {
|
||||
"operationId": "userRouter-changeDdgBangs",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "catalog:",
|
||||
"@tanstack/react-query-next-experimental": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/next": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
@@ -10,18 +11,23 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
import { theme } from "@homarr/ui";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
|
||||
import { createBrandTheme } from "~/theme/branding";
|
||||
|
||||
export const CustomMantineProvider = ({
|
||||
children,
|
||||
defaultColorScheme,
|
||||
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
|
||||
branding,
|
||||
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => {
|
||||
const manager = useColorSchemeManager();
|
||||
const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]);
|
||||
return (
|
||||
<DirectionProvider>
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
colorSchemeManager={manager}
|
||||
theme={theme}
|
||||
theme={configuredTheme}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
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 { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
@@ -35,6 +36,13 @@ import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import {
|
||||
createSessionQueryPersister,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxAgeMs,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
import type { SessionQueryPersister } from "./query-persistence";
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const DevelopmentTools =
|
||||
@@ -67,21 +75,29 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
||||
const handleScopeChange = useCallback(() => {
|
||||
void queryPersister.removeClient();
|
||||
reloadPage();
|
||||
}, [queryPersister]);
|
||||
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
initialScope={initialSessionQueryScope}
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={reloadPage}
|
||||
onScopeChange={handleScopeChange}
|
||||
>
|
||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
||||
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
const ScopedTRPCReactProvider = ({
|
||||
children,
|
||||
queryPersister,
|
||||
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
@@ -136,6 +152,15 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
return client;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const flush = () => queryPersister.flush();
|
||||
window.addEventListener("pagehide", flush);
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", flush);
|
||||
flush();
|
||||
queryPersister.stop();
|
||||
};
|
||||
}, [queryPersister]);
|
||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||
|
||||
const trpcClient = useMemo(() => {
|
||||
@@ -186,10 +211,18 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
return (
|
||||
<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>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</clientApi.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { IconUserPlus } from "@tabler/icons-react";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { and, db, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { RegistrationForm } from "./_registration-form";
|
||||
@@ -20,11 +22,9 @@ interface InviteUsagePageProps {
|
||||
}
|
||||
|
||||
export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
if (!isProviderEnabled("credentials")) notFound();
|
||||
|
||||
const session = await auth();
|
||||
const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]);
|
||||
if (session) notFound();
|
||||
|
||||
const invite = await db.query.invites.findFirst({
|
||||
@@ -45,13 +45,33 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
|
||||
if (!invite || invite.expirationDate < new Date()) notFound();
|
||||
|
||||
const t = await getI18n("user.page.invite");
|
||||
const [t, serverSettings] = await Promise.all([getI18n("user.page.invite"), getRscServerSettingsAsync()]);
|
||||
const branding = serverSettings.branding;
|
||||
const wordmarkColors = getBrandingColorOverrides(branding);
|
||||
const showCustomAppName = branding.authBranding.showAppName && branding.appName !== defaultBrandingSettings.appName;
|
||||
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
|
||||
const title = showCustomAppName ? t("titleWithAppName", { appName: branding.appName }) : t("title");
|
||||
const defaultDescription = showCustomAppName
|
||||
? t("subtitleWithAppName", { appName: branding.appName })
|
||||
: t("subtitle");
|
||||
const description = showCustomGreeting ? branding.greeting : defaultDescription;
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell
|
||||
title={t("title")}
|
||||
description={t("subtitle")}
|
||||
title={title}
|
||||
description={description}
|
||||
icon={<IconUserPlus size={24} />}
|
||||
appName={branding.appName}
|
||||
showAppName={branding.authBranding.showAppName}
|
||||
showAppLogo={branding.authBranding.showLogo}
|
||||
primaryColor={branding.primaryColor}
|
||||
secondaryColor={branding.secondaryColor}
|
||||
wordmarkPrimaryColor={wordmarkColors.primaryColor}
|
||||
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
|
||||
logoImageUrl={branding.logoImageUrl ?? undefined}
|
||||
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
|
||||
backgroundOverlay={branding.signInBackgroundOverlay}
|
||||
radius={branding.defaultRadius}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
footer={t("description", { username: invite.creator.name! })}
|
||||
>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { redirect } from "next/navigation";
|
||||
import { Alert, Code, Text } from "@mantine/core";
|
||||
import { IconLogin } from "@tabler/icons-react";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getBrandingColorOverrides } from "@homarr/server-settings";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
|
||||
@@ -19,17 +21,35 @@ interface LoginProps {
|
||||
}
|
||||
|
||||
export default async function Login(props: LoginProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
const [searchParams, session] = await Promise.all([props.searchParams, auth()]);
|
||||
|
||||
if (session) {
|
||||
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
|
||||
}
|
||||
|
||||
const t = await getI18n("user.page.login");
|
||||
const [t, serverSettings] = await Promise.all([getI18n("user.page.login"), getRscServerSettingsAsync()]);
|
||||
const branding = serverSettings.branding;
|
||||
const wordmarkColors = getBrandingColorOverrides(branding);
|
||||
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
|
||||
const description = showCustomGreeting ? branding.greeting : t("subtitle");
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
|
||||
<OnboardingAuthShell
|
||||
title={t("title")}
|
||||
description={description}
|
||||
icon={<IconLogin size={24} />}
|
||||
appName={branding.appName}
|
||||
showAppName={branding.authBranding.showAppName}
|
||||
showAppLogo={branding.authBranding.showLogo}
|
||||
primaryColor={branding.primaryColor}
|
||||
secondaryColor={branding.secondaryColor}
|
||||
wordmarkPrimaryColor={wordmarkColors.primaryColor}
|
||||
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
|
||||
logoImageUrl={branding.logoImageUrl ?? undefined}
|
||||
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
|
||||
backgroundOverlay={branding.signInBackgroundOverlay}
|
||||
radius={branding.defaultRadius}
|
||||
>
|
||||
{appEnv.DEMO_MODE ? (
|
||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
|
||||
<Text size="sm" fw={500}>
|
||||
|
||||
@@ -11,12 +11,13 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { FloatingTip } from "@homarr/ui";
|
||||
|
||||
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 { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalTrackSize,
|
||||
getLogicalGridSize,
|
||||
LOGICAL_GRID_GAP,
|
||||
getRootSectionForLane,
|
||||
} from "~/components/board/layout";
|
||||
@@ -61,16 +62,16 @@ export const ClientBoard = () => {
|
||||
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
||||
.filter((columnCount) => columnCount > 0)
|
||||
.map(getLogicalTrackSize);
|
||||
.map(getLogicalGridSize);
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||
const gridTemplateColumns = [
|
||||
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalTrackSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
|
||||
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalGridSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
|
||||
]
|
||||
.filter((value) => value !== null)
|
||||
.join(" ");
|
||||
@@ -79,6 +80,7 @@ export const ClientBoard = () => {
|
||||
<BoardSelectionGridProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardAppsSpotlightRegistrar />
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { makeQueryClient } from "@homarr/api/shared";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -15,12 +16,11 @@ import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { ClientBoard } from "./_client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
const getQueryClient = cache(makeQueryClient);
|
||||
@@ -36,7 +36,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}: Props<TParams>) => {
|
||||
return {
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
}),
|
||||
@@ -103,18 +104,24 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
try {
|
||||
const board = await getInitialBoard(await params);
|
||||
const t = await getI18n("board");
|
||||
const [board, t, serverSettings] = await Promise.all([
|
||||
getInitialBoard(await params),
|
||||
getI18n("board"),
|
||||
getRscServerSettingsAsync(),
|
||||
]);
|
||||
const brandLogo = serverSettings.branding.logoImageUrl ?? "/logo/logo.png";
|
||||
const brandFavicon = serverSettings.branding.faviconImageUrl ?? brandLogo;
|
||||
const favicon = !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : brandFavicon;
|
||||
|
||||
return {
|
||||
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
|
||||
title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }),
|
||||
icons: {
|
||||
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||
icon: favicon,
|
||||
apple: favicon,
|
||||
},
|
||||
appleWebApp: {
|
||||
startupImage: {
|
||||
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
|
||||
url: favicon,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,10 +39,9 @@ const loadBoardEditorAsync = async () =>
|
||||
]);
|
||||
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) return null;
|
||||
@@ -50,20 +49,27 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <BoardEditActions />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess || demoReadOnly) return null;
|
||||
|
||||
return (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
||||
import { generateColorScale } from "~/theme/branding";
|
||||
|
||||
export const BoardMantineProvider = ({
|
||||
children,
|
||||
defaultColorScheme,
|
||||
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||
const board = useRequiredBoard();
|
||||
const { branding } = useSettings();
|
||||
const colorSchemeManager = useColorSchemeManager();
|
||||
const primaryColor = branding.lockPrimaryColor ? branding.primaryColor : board.primaryColor;
|
||||
|
||||
const theme = createTheme({
|
||||
colors: {
|
||||
primaryColor: generateColors(board.primaryColor),
|
||||
secondaryColor: generateColors(board.secondaryColor),
|
||||
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
|
||||
primaryColor: generateColorScale(primaryColor),
|
||||
secondaryColor: generateColorScale(board.secondaryColor),
|
||||
iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"),
|
||||
},
|
||||
primaryColor: "primaryColor",
|
||||
autoContrast: true,
|
||||
defaultRadius: "sm",
|
||||
defaultRadius: branding.defaultRadius,
|
||||
fontSizes: {
|
||||
"2xl": rem(24),
|
||||
"3xl": rem(28),
|
||||
@@ -43,29 +46,3 @@ export const BoardMantineProvider = ({
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const generateColors = (hex: string) => {
|
||||
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
|
||||
const rgbaColors = lightnessForColors.map((lightness) => {
|
||||
if (lightness < 0) {
|
||||
return lighten(hex, -lightness);
|
||||
}
|
||||
return darken(hex, lightness);
|
||||
});
|
||||
|
||||
return rgbaColors.map((color) => {
|
||||
return (
|
||||
"#" +
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
color
|
||||
.split("(")[1]!
|
||||
.replaceAll(" ", "")
|
||||
.replace(")", "")
|
||||
.split(",")
|
||||
.map((color) => parseInt(color, 10))
|
||||
.slice(0, 3)
|
||||
.map((color) => color.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
);
|
||||
}) as unknown as MantineColorsTuple;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IconHomeOff } from "@tabler/icons-react";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
@@ -7,6 +8,7 @@ import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import type { BoardNotFoundProps } from "~/components/board/not-found";
|
||||
import { BoardNotFound } from "~/components/board/not-found";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
|
||||
export default async function NotFoundBoardHomePage() {
|
||||
const boardNotFoundProps = await getPropsAsync();
|
||||
@@ -19,8 +21,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
|
||||
const t = await getI18n("board");
|
||||
|
||||
if (boardCount === 0) {
|
||||
const { branding } = await getRscServerSettingsAsync();
|
||||
return {
|
||||
icon: { src: "/favicon.ico", alt: "Homarr logo" },
|
||||
icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` },
|
||||
title: t("error.noBoard.title"),
|
||||
description: t("error.noBoard.description"),
|
||||
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
|
||||
|
||||
@@ -22,9 +22,10 @@ import { IconX } from "@tabler/icons-react";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BoardColorInput } from "@homarr/ui";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { generateColors } from "../../(content)/_theme";
|
||||
import { generateColorScale } from "~/theme/branding";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
@@ -40,13 +41,19 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const theme = useMantineTheme();
|
||||
const { branding } = useSettings();
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.appearance.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
<BoardColorInput
|
||||
label={tBoard("field.primaryColor.label")}
|
||||
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
|
||||
disabled={branding.lockPrimaryColor}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
@@ -121,7 +128,8 @@ interface ColorsPreviewProps {
|
||||
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
|
||||
const colors =
|
||||
previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000");
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap">
|
||||
|
||||
@@ -8,9 +8,9 @@ import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { Board } from "../../_types";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
@@ -21,20 +21,26 @@ interface Props {
|
||||
|
||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
const t = useI18n("board");
|
||||
const { branding } = useSettings();
|
||||
const defaultMetaTitle = t("content.metaTitle", { boardName: board.name });
|
||||
|
||||
useLogoPreview(form.values.logoImageUrl);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle, defaultMetaTitle);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("setting.section.general.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
|
||||
<TextInput
|
||||
label={t("field.pageTitle.label")}
|
||||
placeholder={branding.appName}
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
|
||||
placeholder={defaultMetaTitle}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
@@ -43,7 +49,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
placeholder={branding.logoImageUrl ?? "/logo/logo.png"}
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -51,7 +57,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
placeholder={branding.faviconImageUrl ?? branding.logoImageUrl ?? "/logo/logo.png"}
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -72,9 +78,9 @@ const useLogoPreview = (url: string | null) => {
|
||||
}, [logoDebounced, updateBoard]);
|
||||
};
|
||||
|
||||
const useMetaTitlePreview = (title: string | null) => {
|
||||
const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => {
|
||||
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
|
||||
useDocumentTitle(metaTitleDebounced);
|
||||
useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle);
|
||||
|
||||
return {
|
||||
isPending: (title ?? "") !== metaTitleDebounced,
|
||||
|
||||
@@ -111,9 +111,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
|
||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||
const sortedLayouts = form.values.layouts
|
||||
.map((layout, index) => ({ layout, index }))
|
||||
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||
@@ -129,25 +126,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
disabled={nextBreakpoint === null || !baseLayout}
|
||||
onClick={() => {
|
||||
if (nextBreakpoint === null || !baseLayout) return;
|
||||
form.setFieldValue("layouts", [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: tBoard("setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
breakpoint: nextBreakpoint,
|
||||
role: "custom",
|
||||
},
|
||||
]);
|
||||
const newLayout: FormValues["layouts"][number] = {
|
||||
id: createId(),
|
||||
name: tBoard("setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
breakpoint: nextBreakpoint,
|
||||
role: "custom",
|
||||
};
|
||||
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
|
||||
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
|
||||
form.insertListItem("layouts", newLayout, insertionIndex);
|
||||
}}
|
||||
>
|
||||
{tBoard("setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{sortedLayouts.map(({ layout, index }) => {
|
||||
{form.values.layouts.map((layout, index) => {
|
||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||
const customBreakpoints = form.values.layouts
|
||||
|
||||
@@ -10,10 +10,11 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
|
||||
@@ -88,6 +89,7 @@ interface BoardSettingsFormProps {
|
||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||
const t = useI18n("common");
|
||||
const tSection = useI18n("board.setting.section");
|
||||
const { branding } = useSettings();
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||
const saveLayouts = useSaveLayoutsMutation(board);
|
||||
@@ -142,7 +144,9 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
const [, canonicalLayouts] = await Promise.all([
|
||||
partialSettingsChanged
|
||||
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
updateFavicon(
|
||||
values.faviconImageUrl ?? branding.faviconImageUrl ?? branding.logoImageUrl ?? homarrLogoPath,
|
||||
);
|
||||
})
|
||||
: Promise.resolve(),
|
||||
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
|
||||
|
||||
@@ -12,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { appShellLogoHeight } from "~/components/layout/constants";
|
||||
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
||||
import { env } from "~/env";
|
||||
@@ -27,13 +28,17 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
const logger = createLogger({ module: "createBoardLayout" });
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
headerActions?: JSX.Element;
|
||||
headerBoardEditAction?: JSX.Element;
|
||||
headerBoardSettingsAction?: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
withTour?: boolean;
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
headerBoardEditAction,
|
||||
headerBoardSettingsAction,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour = false,
|
||||
}: CreateBoardLayoutProps<TParams>) => {
|
||||
@@ -105,8 +110,11 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
logo={<BoardLogo size={appShellLogoHeight} />}
|
||||
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
boardEditAction={headerBoardEditAction}
|
||||
boardSettingsAction={headerBoardSettingsAction}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { SettingsProvider } from "@homarr/settings";
|
||||
import { SpotlightProvider } from "@homarr/spotlight";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
@@ -53,35 +54,45 @@ const fontSans = Inter({
|
||||
|
||||
const logger = createLogger({ module: "rootLayout" });
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
description:
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
openGraph: {
|
||||
title: "Homarr Dashboard",
|
||||
description:
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
url: env.HOMARR_WEBSITE_URL,
|
||||
siteName: "Homarr",
|
||||
},
|
||||
icons: {
|
||||
icon: "/logo/logo.png",
|
||||
apple: "/logo/logo.png",
|
||||
},
|
||||
appleWebApp: {
|
||||
title: "Homarr",
|
||||
capable: true,
|
||||
startupImage: { url: "/logo/logo.png" },
|
||||
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
|
||||
},
|
||||
});
|
||||
export const generateMetadata = async (): Promise<Metadata> => {
|
||||
const [serverSettings, colorScheme, t] = await Promise.all([
|
||||
getRscServerSettingsAsync(),
|
||||
getCurrentColorSchemeAsync(),
|
||||
getI18n("metadata"),
|
||||
]);
|
||||
const { appName, faviconImageUrl, logoImageUrl } = serverSettings.branding;
|
||||
const logo = logoImageUrl ?? "/logo/logo.png";
|
||||
const favicon = faviconImageUrl ?? logo;
|
||||
const description = t("description");
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||
],
|
||||
return {
|
||||
title: {
|
||||
default: appName,
|
||||
template: `%s • ${appName}`,
|
||||
},
|
||||
description,
|
||||
openGraph: {
|
||||
title: t("dashboardTitle", { appName }),
|
||||
description,
|
||||
url: env.HOMARR_WEBSITE_URL,
|
||||
siteName: appName,
|
||||
},
|
||||
icons: {
|
||||
icon: favicon,
|
||||
apple: logo,
|
||||
},
|
||||
appleWebApp: {
|
||||
title: appName,
|
||||
capable: true,
|
||||
startupImage: { url: logo },
|
||||
statusBarStyle: colorScheme === "dark" ? "black-translucent" : "default",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const generateViewport = async (): Promise<Viewport> => {
|
||||
const serverSettings = await getRscServerSettingsAsync();
|
||||
return { themeColor: serverSettings.branding.primaryColor };
|
||||
};
|
||||
|
||||
export default async function Layout(props: {
|
||||
@@ -94,6 +105,7 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
const sessionPromise = auth();
|
||||
const serverSettingsPromise = getRscServerSettingsAsync();
|
||||
const userPromise = sessionPromise.then((session) =>
|
||||
session
|
||||
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
||||
@@ -102,18 +114,17 @@ export default async function Layout(props: {
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
|
||||
!session
|
||||
? "unauthenticated"
|
||||
: api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error"),
|
||||
);
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
|
||||
if (!session) return "unauthenticated";
|
||||
return api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error");
|
||||
});
|
||||
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
|
||||
sessionPromise,
|
||||
userPromise,
|
||||
getRscServerSettingsAsync(),
|
||||
serverSettingsPromise,
|
||||
getCurrentColorSchemeAsync(),
|
||||
assistantAvailabilityPromise,
|
||||
]);
|
||||
@@ -148,6 +159,7 @@ export default async function Layout(props: {
|
||||
},
|
||||
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
||||
user: { enableGravatar: serverSettings.user.enableGravatar },
|
||||
branding: serverSettings.branding,
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
@@ -156,7 +168,9 @@ export default async function Layout(props: {
|
||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||
(innerProps) => <DayJsLoader {...innerProps} />,
|
||||
(innerProps) => <NextIntlClientProvider {...innerProps} />,
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||
(innerProps) => (
|
||||
<CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} branding={serverSettings.branding} />
|
||||
),
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Box, Group, Image, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { IntegrationMarquee } from "@homarr/ui";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
export const HeroBanner = async () => {
|
||||
const t = await getI18n("management.page.home");
|
||||
const [t, serverSettings] = await Promise.all([getI18n("management.page.home"), getRscServerSettingsAsync()]);
|
||||
const { appName, logoImageUrl } = serverSettings.branding;
|
||||
|
||||
return (
|
||||
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||
@@ -14,9 +16,17 @@ export const HeroBanner = async () => {
|
||||
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
|
||||
{t("heroBanner.title")}
|
||||
</Title>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
|
||||
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Image
|
||||
src={logoImageUrl ?? "/logo/logo.png"}
|
||||
alt={`${appName} logo`}
|
||||
w="auto"
|
||||
h={{ base: 48, md: 64 }}
|
||||
maw={{ base: 128, md: 192 }}
|
||||
fit="contain"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: appName })}</Title>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||
|
||||
@@ -37,9 +37,8 @@ import { capitalize, objectEntries } from "@homarr/common";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
|
||||
import githubContributors from "@static-data/contributors.json";
|
||||
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
||||
@@ -50,7 +49,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AssistantConfiguration } from "./_components/assistant-configuration";
|
||||
export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.settings.section.assistant");
|
||||
return {
|
||||
title: `${t("title")} • Homarr`,
|
||||
title: t("title"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
|
||||
@@ -12,39 +13,38 @@ type CodeEditorProps = Omit<SharedEditorProps, "messages">;
|
||||
export function CodeEditor(props: CodeEditorProps) {
|
||||
const t = useI18n("customWidget.editor");
|
||||
const tCommon = useI18n("common");
|
||||
return (
|
||||
<CustomWidgetCodeEditor
|
||||
{...props}
|
||||
messages={{
|
||||
languageJsx: invariantTechnicalLabels.jsx,
|
||||
languageJson: invariantTechnicalLabels.json,
|
||||
undo: tCommon("action.undo"),
|
||||
redo: t("action.redo"),
|
||||
components: t("action.components"),
|
||||
componentSearch: t("componentReference.search"),
|
||||
componentEmpty: t("componentReference.empty"),
|
||||
componentCount: (count) => t("componentReference.count", { count }),
|
||||
insertStarter: t("action.insertStarter"),
|
||||
format: t("action.format"),
|
||||
copy: tCommon("action.copy"),
|
||||
copied: t("action.copied"),
|
||||
schema: t("action.schema"),
|
||||
schemaTab: invariantTechnicalLabels.jsonSchema,
|
||||
minimalTab: t("reference.minimal"),
|
||||
fullTab: t("reference.full"),
|
||||
errors: (count) => t("status.errors", { count }),
|
||||
warnings: (count) => t("status.warnings", { count }),
|
||||
ready: t("status.ready"),
|
||||
position: (cursor) => t("status.position", cursor),
|
||||
characters: (count, limit) =>
|
||||
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
|
||||
diagnosticsTitle: t("diagnostics.title"),
|
||||
diagnostic: (diagnostic) =>
|
||||
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`,
|
||||
}}
|
||||
/>
|
||||
const messages = useMemo<SharedEditorProps["messages"]>(
|
||||
() => ({
|
||||
languageJsx: invariantTechnicalLabels.jsx,
|
||||
languageJson: invariantTechnicalLabels.json,
|
||||
undo: tCommon("action.undo"),
|
||||
redo: t("action.redo"),
|
||||
components: t("action.components"),
|
||||
componentSearch: t("componentReference.search"),
|
||||
componentEmpty: t("componentReference.empty"),
|
||||
componentCount: (count) => t("componentReference.count", { count }),
|
||||
insertStarter: t("action.insertStarter"),
|
||||
format: t("action.format"),
|
||||
copy: tCommon("action.copy"),
|
||||
copied: t("action.copied"),
|
||||
schema: t("action.schema"),
|
||||
schemaTab: invariantTechnicalLabels.jsonSchema,
|
||||
minimalTab: t("reference.minimal"),
|
||||
fullTab: t("reference.full"),
|
||||
errors: (count) => t("status.errors", { count }),
|
||||
warnings: (count) => t("status.warnings", { count }),
|
||||
ready: t("status.ready"),
|
||||
position: (cursor) => t("status.position", cursor),
|
||||
characters: (count, limit) =>
|
||||
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
|
||||
diagnosticsTitle: t("diagnostics.title"),
|
||||
diagnostic: (diagnostic) =>
|
||||
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`,
|
||||
}),
|
||||
[t, tCommon],
|
||||
);
|
||||
return <CustomWidgetCodeEditor {...props} messages={messages} />;
|
||||
}
|
||||
@@ -3,37 +3,70 @@
|
||||
import { ActionIcon, Group, Tooltip } from "@mantine/core";
|
||||
import { IconCopy, IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { buildCustomWidgetAiPrompt, buildCustomWidgetAssistantPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
interface CopyAiPromptButtonBaseProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
request?: string | null;
|
||||
documentationUrl?: string | null;
|
||||
}
|
||||
|
||||
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
|
||||
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
|
||||
(
|
||||
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
|
||||
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
|
||||
);
|
||||
|
||||
export const CopyAiPromptButton = ({
|
||||
rawResponse,
|
||||
currentConfig,
|
||||
draft,
|
||||
getDraft,
|
||||
diagnostics,
|
||||
getDiagnostics,
|
||||
request,
|
||||
documentationUrl,
|
||||
}: CopyAiPromptButtonProps) => {
|
||||
const t = useI18n("customWidget");
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
|
||||
|
||||
const readDraft = () => {
|
||||
if (getDraft) return getDraft();
|
||||
return draft;
|
||||
};
|
||||
const readDiagnostics = () => {
|
||||
if (getDiagnostics) return getDiagnostics();
|
||||
return diagnostics;
|
||||
};
|
||||
|
||||
const handleAssistant = () => {
|
||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return;
|
||||
const prompt = buildCustomWidgetAssistantPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
readDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
readDiagnostics(),
|
||||
);
|
||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
|
||||
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
const value = prompt();
|
||||
const value = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
readDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
readDiagnostics(),
|
||||
);
|
||||
await navigator.clipboard.writeText(value);
|
||||
showSuccessNotification({
|
||||
title: t("action.copyAiPrompt"),
|
||||
|
||||
+40
-36
@@ -1,27 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Accordion } from "@mantine/core";
|
||||
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { useDeferredCustomWidgetFormDocumentValues } from "./_custom-widget-form-state";
|
||||
import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { LazyOnceAccordion } from "./_lazy-once-accordion";
|
||||
|
||||
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
return (
|
||||
<LazyOnceAccordion label={t("advancedManifest")}>
|
||||
<CustomWidgetManifestEditor form={form} />
|
||||
</LazyOnceAccordion>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm }) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const values = useDeferredCustomWidgetFormDocumentValues();
|
||||
const serialized = JSON.stringify(
|
||||
{
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: form.values.name,
|
||||
...(form.values.description ? { description: form.values.description } : {}),
|
||||
...(form.values.iconUrl ? { iconUrl: form.values.iconUrl } : {}),
|
||||
sources: parseJson(form.values.sources),
|
||||
requests: parseJson(form.values.requests),
|
||||
options: parseJson(form.values.options),
|
||||
template: form.values.template,
|
||||
name: values.name,
|
||||
...(values.description ? { description: values.description } : {}),
|
||||
...(values.iconUrl ? { iconUrl: values.iconUrl } : {}),
|
||||
sources: parseJson(values.sources),
|
||||
requests: parseJson(values.requests),
|
||||
options: parseJson(values.options),
|
||||
template: values.template,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -30,33 +41,26 @@ export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkb
|
||||
const [error, setError] = useState<string>();
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
return (
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="manifest">
|
||||
<Accordion.Control>{t("advancedManifest")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<CodeEditor
|
||||
id="raw-widget-manifest"
|
||||
label={t("manifestFilename")}
|
||||
language="json"
|
||||
value={draft}
|
||||
error={error}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
applyDefinition(form, parsed.data);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("invalidJson"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<CodeEditor
|
||||
id="raw-widget-manifest"
|
||||
label={t("manifestFilename")}
|
||||
language="json"
|
||||
value={draft}
|
||||
error={error}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
applyDefinition(form, parsed.data);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("invalidJson"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { IconClipboard, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
|
||||
|
||||
interface AiCardProps {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
getDraft(): CustomWidgetAiDraft;
|
||||
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
|
||||
request: string;
|
||||
onRequestChange(value: string): void;
|
||||
documentationUrl: string;
|
||||
@@ -27,14 +28,15 @@ export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
{t("title")}
|
||||
</Accordion.Control>
|
||||
<CopyAiPromptButton
|
||||
currentConfig={props.candidate}
|
||||
getDraft={props.getDraft}
|
||||
getDiagnostics={props.getDiagnostics}
|
||||
request={props.request}
|
||||
documentationUrl={props.documentationUrl}
|
||||
/>
|
||||
</Group>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text size="sm" c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-wi
|
||||
|
||||
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
|
||||
|
||||
export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) {
|
||||
export function createCustomWidgetCompletions(
|
||||
values: Pick<CustomWidgetFormValues, "options" | "sources">,
|
||||
requestIds: string[],
|
||||
) {
|
||||
const options = parseJson(values.options);
|
||||
const optionKeys = Object.keys(isRecord(options) ? options : {});
|
||||
return [
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconApi,
|
||||
IconBraces,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconDatabase,
|
||||
IconEye,
|
||||
IconPlayerPlay,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useCustomWidgetFormDocumentDirty } from "./_custom-widget-form-state";
|
||||
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
|
||||
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
|
||||
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
const sectionLinks = [
|
||||
["general", "section.general", IconSettings],
|
||||
["sources", "section.sources", IconApi],
|
||||
["requests", "section.requests", IconDatabase],
|
||||
["options", "section.options", IconBraces],
|
||||
["jsx", "section.jsx", IconCode],
|
||||
["preview", "section.preview", IconEye],
|
||||
] as const;
|
||||
|
||||
export function EditorSection({
|
||||
id,
|
||||
title,
|
||||
@@ -84,3 +110,83 @@ export function SaveActions({
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetSectionNavigation({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect(section: (typeof sectionLinks)[number][0]): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench");
|
||||
const invalidSections = useCustomWidgetFormAnalysisField("invalidSections");
|
||||
return (
|
||||
<nav className={classes.sectionNav} aria-label={t("sectionNavigation")}>
|
||||
{sectionLinks.map(([id, key, SectionIcon]) => {
|
||||
const invalid = invalidSections.has(id);
|
||||
return (
|
||||
<Button
|
||||
key={id}
|
||||
component="a"
|
||||
href={`#${id}`}
|
||||
onClick={() => onSelect(id)}
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color={invalid ? "red" : undefined}
|
||||
leftSection={<SectionIcon size={14} />}
|
||||
rightSection={invalid ? <IconAlertCircle size={13} aria-label={t("status.invalid")} /> : undefined}
|
||||
>
|
||||
{t(key)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetSaveActions({
|
||||
mode,
|
||||
savePending,
|
||||
previewPending,
|
||||
onPreview,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
savePending: boolean;
|
||||
previewPending: boolean;
|
||||
onPreview(): void;
|
||||
}) {
|
||||
const dirty = useCustomWidgetFormDocumentDirty();
|
||||
const candidateValid = useCustomWidgetFormAnalysisField("candidateValid");
|
||||
const hasDiagnostics = useCustomWidgetFormAnalysisField("hasDiagnostics");
|
||||
return (
|
||||
<SaveActions
|
||||
dirty={dirty}
|
||||
savePending={savePending}
|
||||
previewPending={previewPending}
|
||||
invalid={hasDiagnostics || !candidateValid}
|
||||
mode={mode}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetUnsavedChangesGuard() {
|
||||
const dirty = useCustomWidgetFormDocumentDirty();
|
||||
useUnsavedChangesGuard(dirty);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CustomWidgetOptionsSnapshotSync({
|
||||
setOptionsSnapshot,
|
||||
}: {
|
||||
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
}) {
|
||||
const parsedOptions = useCustomWidgetFormAnalysisField("parsedOptions");
|
||||
const lastDefaults = useRef<Record<string, unknown> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!parsedOptions.success) return;
|
||||
const defaults = getCustomWidgetDefaultOptions(parsedOptions.data);
|
||||
if (lastDefaults.current && areCustomWidgetValuesEqual(lastDefaults.current, defaults)) return;
|
||||
lastDefaults.current = defaults;
|
||||
setOptionsSnapshot(defaults);
|
||||
}, [parsedOptions, setOptionsSnapshot]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { TextInput, Textarea } from "@mantine/core";
|
||||
import { IconApi, IconBraces, IconCode, IconDatabase, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
|
||||
import { EditorSection } from "./_custom-widget-form-layout";
|
||||
import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
|
||||
import { useCustomWidgetFormDocumentField } from "./_custom-widget-form-state";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor";
|
||||
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor";
|
||||
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
|
||||
import { LazyOnceAccordion } from "./_lazy-once-accordion";
|
||||
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
|
||||
|
||||
export function CustomWidgetAiSection({
|
||||
getDraft,
|
||||
getDiagnostics,
|
||||
onPaste,
|
||||
}: {
|
||||
getDraft(): CustomWidgetAiDraft;
|
||||
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
|
||||
onPaste(): void;
|
||||
}) {
|
||||
const [request, setRequest] = useState("");
|
||||
const [documentationUrl, setDocumentationUrl] = useState("");
|
||||
return (
|
||||
<CustomWidgetAiCard
|
||||
getDraft={getDraft}
|
||||
getDiagnostics={getDiagnostics}
|
||||
request={request}
|
||||
onRequestChange={setRequest}
|
||||
documentationUrl={documentationUrl}
|
||||
onDocumentationUrlChange={setDocumentationUrl}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetGeneralSection({
|
||||
form,
|
||||
formRevision,
|
||||
mode,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
formRevision: string;
|
||||
mode: "create" | "edit";
|
||||
}) {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const [suggestedIconSearch, setSuggestedIconSearch] = useState(() => form.getValues().name);
|
||||
const iconUrl = useCustomWidgetFormDocumentField("iconUrl");
|
||||
const nameInputProps = form.getInputProps("name");
|
||||
useEffect(() => setSuggestedIconSearch(form.getValues().name), [form, formRevision]);
|
||||
return (
|
||||
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
|
||||
<TextInput
|
||||
key={form.key("name")}
|
||||
label={tCommon("field.name")}
|
||||
required
|
||||
{...nameInputProps}
|
||||
onBlur={(event) => {
|
||||
nameInputProps.onBlur?.(event);
|
||||
setSuggestedIconSearch(form.getValues().name);
|
||||
}}
|
||||
/>
|
||||
<Textarea
|
||||
key={form.key("description")}
|
||||
label={t("field.description")}
|
||||
autosize
|
||||
minRows={2}
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
<IconPicker
|
||||
key={form.key("iconUrl")}
|
||||
withAsterisk={false}
|
||||
suggestedSearch={mode === "create" ? suggestedIconSearch : undefined}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
value={iconUrl}
|
||||
/>
|
||||
</EditorSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetSourcesSection({
|
||||
form,
|
||||
definitionId,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
}) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
useCustomWidgetFormDocumentField("sources");
|
||||
useCustomWidgetFormDocumentField("requests");
|
||||
useCustomWidgetFormDocumentField("secrets");
|
||||
return (
|
||||
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
|
||||
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
|
||||
</EditorSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetRequestsSection({
|
||||
form,
|
||||
onRename,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onRename(currentId: string, nextId: string): void;
|
||||
}) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const requests = useCustomWidgetFormDocumentField("requests");
|
||||
useCustomWidgetFormDocumentField("sources");
|
||||
useCustomWidgetFormDocumentField("options");
|
||||
const requestDiagnostics = useCustomWidgetFormAnalysisField("requestDiagnostics");
|
||||
return (
|
||||
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
|
||||
<CustomWidgetRequestsEditor form={form} onRename={onRename} />
|
||||
<LazyOnceAccordion label={w("builder.advancedJson")}>
|
||||
<CodeEditor
|
||||
id="requests-editor"
|
||||
label={w("requests.label")}
|
||||
description={w("requests.description")}
|
||||
language="json"
|
||||
value={requests}
|
||||
onChange={(value) => form.setFieldValue("requests", value, { forceUpdate: false })}
|
||||
diagnostics={requestDiagnostics}
|
||||
error={form.errors.requests}
|
||||
reference={customWidgetRequestReference}
|
||||
required
|
||||
/>
|
||||
</LazyOnceAccordion>
|
||||
</EditorSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetOptionsSection({
|
||||
form,
|
||||
onRename,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onRename(currentName: string, nextName: string): void;
|
||||
}) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const options = useCustomWidgetFormDocumentField("options");
|
||||
useCustomWidgetFormDocumentField("requests");
|
||||
return (
|
||||
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
|
||||
<CustomWidgetOptionsEditor form={form} onRename={onRename} />
|
||||
<LazyOnceAccordion label={w("builder.advancedJson")}>
|
||||
<CodeEditor
|
||||
id="options-editor"
|
||||
label={w("options.title")}
|
||||
language="json"
|
||||
value={options}
|
||||
onChange={(value) => form.setFieldValue("options", value, { forceUpdate: false })}
|
||||
error={form.errors.options}
|
||||
reference={customWidgetOptionsSchemaReference}
|
||||
/>
|
||||
</LazyOnceAccordion>
|
||||
</EditorSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetTemplateSection({ form }: { form: CustomWidgetWorkbenchForm }) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const template = useCustomWidgetFormDocumentField("template");
|
||||
const jsxCompletions = useCustomWidgetFormAnalysisField("jsxCompletions");
|
||||
const templateDiagnostics = useCustomWidgetFormAnalysisField("templateDiagnostics");
|
||||
return (
|
||||
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
|
||||
<CodeEditor
|
||||
id="jsx-editor"
|
||||
label={w("jsx.label")}
|
||||
language="jsx"
|
||||
value={template}
|
||||
onChange={(value) => form.setFieldValue("template", value, { forceUpdate: false })}
|
||||
diagnostics={templateDiagnostics}
|
||||
completions={jsxCompletions}
|
||||
error={form.errors.template}
|
||||
required
|
||||
maxLength={50_000}
|
||||
/>
|
||||
</EditorSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface CustomWidgetPreviewSectionProps {
|
||||
preview: PreviewState;
|
||||
size: string;
|
||||
onSizeChange(size: string): void;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
onOptionsChange: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
onLiveActionsChange(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetPreviewSection(props: CustomWidgetPreviewSectionProps) {
|
||||
const previewCandidate = useCustomWidgetFormAnalysisField("previewCandidate");
|
||||
const previewValidationIssues = useCustomWidgetFormAnalysisField("previewValidationIssues");
|
||||
return (
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={previewCandidate}
|
||||
validationIssues={previewValidationIssues}
|
||||
preview={props.preview}
|
||||
size={props.size}
|
||||
onSizeChange={props.onSizeChange}
|
||||
optionsSnapshot={props.optionsSnapshot}
|
||||
onOptionsChange={props.onOptionsChange}
|
||||
onLiveActionsChange={props.onLiveActionsChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
startTransition,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
|
||||
|
||||
export interface CustomWidgetFormDocumentStore {
|
||||
getValues(): CustomWidgetFormValues;
|
||||
setValues(values: CustomWidgetFormValues): void;
|
||||
getDirty(): boolean;
|
||||
subscribe(listener: () => void): () => void;
|
||||
markSaved(values: CustomWidgetFormValues): void;
|
||||
}
|
||||
|
||||
const documentValueKeys = customWidgetFormSchema.keyof().options;
|
||||
|
||||
function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) {
|
||||
if (Object.keys(left).length !== documentValueKeys.length) return false;
|
||||
if (Object.keys(right).length !== documentValueKeys.length) return false;
|
||||
return documentValueKeys.every(
|
||||
(key) => Object.hasOwn(left, key) && Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCustomWidgetFormDocumentStore(
|
||||
initialValues: CustomWidgetFormValues,
|
||||
): CustomWidgetFormDocumentStore {
|
||||
let values = initialValues;
|
||||
let persistedValues = initialValues;
|
||||
let dirty = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = () => listeners.forEach((listener) => listener());
|
||||
|
||||
return {
|
||||
getValues: () => values,
|
||||
setValues: (nextValues) => {
|
||||
if (Object.is(values, nextValues)) return;
|
||||
values = nextValues;
|
||||
dirty = !areDocumentValuesEqual(nextValues, persistedValues);
|
||||
notify();
|
||||
},
|
||||
getDirty: () => dirty,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
markSaved: (savedValues) => {
|
||||
const wasDirty = dirty;
|
||||
persistedValues = savedValues;
|
||||
dirty = !areDocumentValuesEqual(values, persistedValues);
|
||||
if (wasDirty !== dirty) notify();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const CustomWidgetFormDocumentContext = createContext<CustomWidgetFormDocumentStore | null>(null);
|
||||
|
||||
export function CustomWidgetFormDocumentProvider({
|
||||
store,
|
||||
children,
|
||||
}: {
|
||||
store: CustomWidgetFormDocumentStore;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <CustomWidgetFormDocumentContext.Provider value={store}>{children}</CustomWidgetFormDocumentContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormDocumentStore() {
|
||||
const store = useContext(CustomWidgetFormDocumentContext);
|
||||
if (!store) throw new Error("Custom widget form document provider is missing");
|
||||
return store;
|
||||
}
|
||||
|
||||
export function useDeferredCustomWidgetFormDocumentValues() {
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const [values, setValues] = useState(() => store.getValues());
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const scheduleUpdate = () => {
|
||||
const nextValues = store.getValues();
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
startTransition(() => {
|
||||
setValues((currentValues) => {
|
||||
if (!Object.is(store.getValues(), nextValues)) return currentValues;
|
||||
return nextValues;
|
||||
});
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
const unsubscribe = store.subscribe(scheduleUpdate);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
};
|
||||
}, [store]);
|
||||
return values;
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormDocumentField<Key extends keyof CustomWidgetFormValues>(field: Key) {
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const getSnapshot = () => store.getValues()[field];
|
||||
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormDocumentDirty() {
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
return useSyncExternalStore(store.subscribe, store.getDirty, store.getDirty);
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormDocumentBridge(
|
||||
form: UseFormReturnType<CustomWidgetFormValues>,
|
||||
store: CustomWidgetFormDocumentStore,
|
||||
) {
|
||||
const formRef = useRef(form);
|
||||
formRef.current = form;
|
||||
return useMemo(
|
||||
() =>
|
||||
new Proxy({} as UseFormReturnType<CustomWidgetFormValues>, {
|
||||
get: (_target, property) => {
|
||||
if (property === "values") return store.getValues();
|
||||
return Reflect.get(formRef.current, property);
|
||||
},
|
||||
}),
|
||||
[store],
|
||||
);
|
||||
}
|
||||
+2
-9
@@ -28,7 +28,7 @@ describe("Custom Widget workbench preview options", () => {
|
||||
const setValues = vi.fn();
|
||||
const result = applyCustomWidgetAiResponse(
|
||||
{
|
||||
values: {
|
||||
getValues: () => ({
|
||||
name: "",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
@@ -37,7 +37,7 @@ describe("Custom Widget workbench preview options", () => {
|
||||
options: "{}",
|
||||
template: "",
|
||||
secrets: [],
|
||||
},
|
||||
}),
|
||||
setValues,
|
||||
} as never,
|
||||
`\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``,
|
||||
@@ -84,13 +84,6 @@ describe("Custom Widget workbench preview options", () => {
|
||||
it("normalizes only changed secrets", () => {
|
||||
expect(
|
||||
getChangedSecrets({
|
||||
name: "Widget",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
sources: "{}",
|
||||
requests: "{}",
|
||||
options: "{}",
|
||||
template: "<Text />",
|
||||
secrets: [
|
||||
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
|
||||
{ sourceId: "other", kind: "password", value: "replacement" },
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>;
|
||||
|
||||
const PREVIEW_QUERY_CONCURRENCY = 4;
|
||||
|
||||
export function buildDefinition(values: CustomWidgetFormValues) {
|
||||
return customWidgetDefinitionSchema.safeParse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
@@ -25,7 +27,7 @@ export function buildDefinition(values: CustomWidgetFormValues) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getChangedSecrets(values: CustomWidgetFormValues) {
|
||||
export function getChangedSecrets(values: Pick<CustomWidgetFormValues, "secrets">) {
|
||||
return values.secrets
|
||||
.filter((secret) => secret.value.trim())
|
||||
.map(({ sourceId, kind, value }) => ({
|
||||
@@ -46,7 +48,7 @@ export function filterSecretsForSourceAuthentication(
|
||||
|
||||
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
|
||||
form.setValues({
|
||||
...form.values,
|
||||
...form.getValues(),
|
||||
name: widget.name,
|
||||
description: widget.description ?? "",
|
||||
iconUrl: widget.iconUrl ?? "",
|
||||
@@ -102,25 +104,43 @@ export function isRuntimeParams(
|
||||
}
|
||||
|
||||
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
|
||||
const data: Record<string, unknown> = {};
|
||||
const status: Record<string, unknown> = {};
|
||||
for (const [requestId] of Object.entries(definition.requests).filter(
|
||||
const requests = Object.entries(definition.requests).filter(
|
||||
([, entry]) => entry.kind === "query" && entry.trigger === "load",
|
||||
)) {
|
||||
try {
|
||||
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
|
||||
data[requestId] = result.data;
|
||||
status[requestId] = { loading: false, ...result };
|
||||
} catch (error) {
|
||||
data[requestId] = null;
|
||||
status[requestId] = {
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
};
|
||||
);
|
||||
const results: Array<{
|
||||
requestId: string;
|
||||
data: unknown;
|
||||
status: Record<string, unknown>;
|
||||
}> = [];
|
||||
let nextIndex = 0;
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
const request = requests[index];
|
||||
if (!request) return;
|
||||
const [requestId] = request;
|
||||
try {
|
||||
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
|
||||
results[index] = { requestId, data: result.data, status: { loading: false, ...result } };
|
||||
} catch (error) {
|
||||
results[index] = {
|
||||
requestId,
|
||||
data: null,
|
||||
status: {
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, status };
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(PREVIEW_QUERY_CONCURRENCY, requests.length) }, () => worker()));
|
||||
return {
|
||||
data: Object.fromEntries(results.map((result) => [result.requestId, result.data])),
|
||||
status: Object.fromEntries(results.map((result) => [result.requestId, result.status])),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconApi,
|
||||
IconBraces,
|
||||
IconCode,
|
||||
IconDatabase,
|
||||
IconEye,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Dispatch, FormEventHandler, SetStateAction } from "react";
|
||||
import { Box, Paper, SegmentedControl, Stack } from "@mantine/core";
|
||||
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
analyzeRequestManifest,
|
||||
customWidgetFormSchema,
|
||||
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { customWidgetOptionsSchema, getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { customWidgetFormSchema, DEFAULT_CUSTOM_WIDGET_FORM_VALUES } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
|
||||
import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest";
|
||||
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
|
||||
import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
|
||||
import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
|
||||
import { buildDefinition, getDefinitionDefaults, isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor";
|
||||
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
|
||||
import {
|
||||
CustomWidgetOptionsSnapshotSync,
|
||||
CustomWidgetSaveActions,
|
||||
CustomWidgetSectionNavigation,
|
||||
CustomWidgetUnsavedChangesGuard,
|
||||
} from "./_custom-widget-form-layout";
|
||||
import {
|
||||
CustomWidgetAiSection,
|
||||
CustomWidgetGeneralSection,
|
||||
CustomWidgetOptionsSection,
|
||||
CustomWidgetPreviewSection,
|
||||
CustomWidgetRequestsSection,
|
||||
CustomWidgetSourcesSection,
|
||||
CustomWidgetTemplateSection,
|
||||
} from "./_custom-widget-form-sections";
|
||||
import {
|
||||
createCustomWidgetFormDocumentStore,
|
||||
CustomWidgetFormDocumentProvider,
|
||||
useCustomWidgetFormDocumentBridge,
|
||||
} from "./_custom-widget-form-state";
|
||||
import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
|
||||
import { buildDefinition, getDefinitionDefaults } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor";
|
||||
import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handlers";
|
||||
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
|
||||
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
|
||||
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
|
||||
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
|
||||
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
|
||||
import { analyzeCustomWidgetAiDiagnostics, CustomWidgetFormAnalysisProvider } from "./_use-custom-widget-form-analysis";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
interface CustomWidgetFormProps {
|
||||
@@ -47,241 +47,204 @@ interface CustomWidgetFormProps {
|
||||
initialValues?: Partial<CustomWidgetFormValues>;
|
||||
definitionId?: string;
|
||||
}
|
||||
const sectionLinks = [
|
||||
["general", "section.general", IconSettings],
|
||||
["sources", "section.sources", IconApi],
|
||||
["requests", "section.requests", IconDatabase],
|
||||
["options", "section.options", IconBraces],
|
||||
["jsx", "section.jsx", IconCode],
|
||||
["preview", "section.preview", IconEye],
|
||||
] as const;
|
||||
|
||||
type MobilePane = "configure" | "preview";
|
||||
|
||||
interface CustomWidgetFormViewProps {
|
||||
mode: "create" | "edit";
|
||||
definitionId?: string;
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
formErrors: CustomWidgetWorkbenchForm["errors"];
|
||||
formRevision: string;
|
||||
documentStore: CustomWidgetFormDocumentStore;
|
||||
mobilePane: MobilePane;
|
||||
setMobilePane: Dispatch<SetStateAction<MobilePane>>;
|
||||
preview: PreviewState;
|
||||
setPreview: Dispatch<SetStateAction<PreviewState>>;
|
||||
previewSize: string;
|
||||
setPreviewSize: Dispatch<SetStateAction<string>>;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
saveIssues: CustomWidgetSaveIssue[];
|
||||
savePending: boolean;
|
||||
previewPending: boolean;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
onPreview(): void;
|
||||
onPasteAiResponse(): void;
|
||||
}
|
||||
|
||||
function getAiDraft(values: CustomWidgetFormValues): CustomWidgetAiDraft {
|
||||
return {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
iconUrl: values.iconUrl,
|
||||
sources: values.sources,
|
||||
requests: values.requests,
|
||||
options: values.options,
|
||||
template: values.template,
|
||||
};
|
||||
}
|
||||
|
||||
const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWidgetFormViewProps) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const documentStore = props.documentStore;
|
||||
const form = props.form;
|
||||
const setMobilePane = props.setMobilePane;
|
||||
const setPreview = props.setPreview;
|
||||
const { renameRequest, renameOption } = useMemo(
|
||||
() =>
|
||||
createCustomWidgetRenameHandlers({
|
||||
form,
|
||||
invalidWidgetMessage: w("invalidWidget"),
|
||||
}),
|
||||
[form, w],
|
||||
);
|
||||
const readAiDraft = useCallback(() => getAiDraft(documentStore.getValues()), [documentStore]);
|
||||
const readAiDiagnostics = useCallback(
|
||||
() => analyzeCustomWidgetAiDiagnostics(documentStore.getValues()),
|
||||
[documentStore],
|
||||
);
|
||||
const handleSectionSelect = useCallback(
|
||||
(section: string) => {
|
||||
setMobilePane(section === "preview" ? "preview" : "configure");
|
||||
},
|
||||
[setMobilePane],
|
||||
);
|
||||
const handleLiveActionsChange = useCallback(
|
||||
(enabled: boolean) =>
|
||||
setPreview((current) => ({
|
||||
...current,
|
||||
session: current.session ? { ...current.session, liveActions: enabled } : null,
|
||||
})),
|
||||
[setPreview],
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomWidgetFormDocumentProvider store={documentStore}>
|
||||
<CustomWidgetFormAnalysisProvider>
|
||||
<form onSubmit={props.onSubmit} className={classes.form}>
|
||||
<CustomWidgetUnsavedChangesGuard />
|
||||
<CustomWidgetOptionsSnapshotSync setOptionsSnapshot={props.setOptionsSnapshot} />
|
||||
<SegmentedControl
|
||||
className={classes.paneSwitcher}
|
||||
fullWidth
|
||||
value={props.mobilePane}
|
||||
onChange={(value) => props.setMobilePane(value as MobilePane)}
|
||||
data={[
|
||||
{ value: "configure", label: w("configure") },
|
||||
{ value: "preview", label: w("section.preview") },
|
||||
]}
|
||||
/>
|
||||
<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />
|
||||
<CustomWidgetSectionNavigation onSelect={handleSectionSelect} />
|
||||
<div className={classes.workbench} data-mobile-pane={props.mobilePane}>
|
||||
<Stack gap="lg" className={classes.configuration}>
|
||||
<CustomWidgetAiSection
|
||||
getDraft={readAiDraft}
|
||||
getDiagnostics={readAiDiagnostics}
|
||||
onPaste={props.onPasteAiResponse}
|
||||
/>
|
||||
<CustomWidgetAdvancedManifest form={form} />
|
||||
<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />
|
||||
<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />
|
||||
<CustomWidgetRequestsSection form={form} onRename={renameRequest} />
|
||||
<CustomWidgetOptionsSection form={form} onRename={renameOption} />
|
||||
<CustomWidgetTemplateSection form={form} />
|
||||
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
|
||||
<CustomWidgetSaveActions
|
||||
mode={props.mode}
|
||||
savePending={props.savePending}
|
||||
previewPending={props.previewPending}
|
||||
onPreview={props.onPreview}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
|
||||
<Paper p="md" className={classes.actionBar} shadow="sm">
|
||||
<CustomWidgetSaveActions
|
||||
mode={props.mode}
|
||||
savePending={props.savePending}
|
||||
previewPending={props.previewPending}
|
||||
onPreview={props.onPreview}
|
||||
/>
|
||||
</Paper>
|
||||
<CustomWidgetPreviewSection
|
||||
preview={props.preview}
|
||||
size={props.previewSize}
|
||||
onSizeChange={props.setPreviewSize}
|
||||
optionsSnapshot={props.optionsSnapshot}
|
||||
onOptionsChange={props.setOptionsSnapshot}
|
||||
onLiveActionsChange={handleLiveActionsChange}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</form>
|
||||
</CustomWidgetFormAnalysisProvider>
|
||||
</CustomWidgetFormDocumentProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
|
||||
const t = useI18n("customWidget");
|
||||
const tCommon = useI18n("common");
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
|
||||
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
|
||||
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
|
||||
const [request, setRequest] = useState("");
|
||||
const [documentationUrl, setDocumentationUrl] = useState("");
|
||||
const [formInitialValues] = useState<CustomWidgetFormValues>(() => ({
|
||||
...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
|
||||
...initialValues,
|
||||
}));
|
||||
const [documentStore] = useState(() => createCustomWidgetFormDocumentStore(formInitialValues));
|
||||
const mantineForm = useZodForm(customWidgetFormSchema, {
|
||||
initialValues: formInitialValues,
|
||||
mode: "uncontrolled",
|
||||
validateDebounce: 300,
|
||||
validateInputOnChange: false,
|
||||
onValuesChange: documentStore.setValues,
|
||||
});
|
||||
const form = useCustomWidgetFormDocumentBridge(mantineForm, documentStore);
|
||||
const [mobilePane, setMobilePane] = useState<MobilePane>("configure");
|
||||
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
const [previewSize, setPreviewSize] = useState("standard");
|
||||
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
|
||||
const initialDefinition = buildDefinition(formInitialValues);
|
||||
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
|
||||
});
|
||||
useUnsavedChangesGuard(form.isDirty());
|
||||
|
||||
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
|
||||
const parsedOptions = useMemo(
|
||||
() => customWidgetOptionsSchema.safeParse(parseJson(form.values.options)),
|
||||
[form.values.options],
|
||||
);
|
||||
const requestIds = useMemo(
|
||||
() =>
|
||||
Object.keys(
|
||||
isRecord(parseJson(form.values.requests)) ? (parseJson(form.values.requests) as Record<string, unknown>) : {},
|
||||
),
|
||||
[form.values.requests],
|
||||
);
|
||||
const jsxCompletions = useMemo(
|
||||
() => createCustomWidgetCompletions(form.values, requestIds),
|
||||
[form.values, requestIds],
|
||||
);
|
||||
const templateDiagnostics = useMemo(
|
||||
() => analyzeJsxTemplate(form.values.template, { requestIds }),
|
||||
[form.values.template, requestIds],
|
||||
);
|
||||
const requestDiagnostics = useMemo(() => analyzeRequestManifest(form.values.requests), [form.values.requests]);
|
||||
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
|
||||
const invalidSections = useMemo(
|
||||
() =>
|
||||
getInvalidCustomWidgetSections(
|
||||
candidate.success ? [] : candidate.error.issues,
|
||||
requestDiagnostics,
|
||||
templateDiagnostics,
|
||||
),
|
||||
[candidate, requestDiagnostics, templateDiagnostics],
|
||||
);
|
||||
|
||||
const { save, runPreview, pasteAiResponse, saveIssues, savePending, previewPending } = useCustomWidgetFormActions({
|
||||
const actions = useCustomWidgetFormActions({
|
||||
mode,
|
||||
definitionId,
|
||||
form,
|
||||
candidate,
|
||||
preview,
|
||||
documentStore,
|
||||
setPreview,
|
||||
setMobilePane,
|
||||
optionsSnapshot,
|
||||
setOptionsSnapshot,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
|
||||
}, [parsedOptions]);
|
||||
const latestActions = useRef(actions);
|
||||
useLayoutEffect(() => {
|
||||
latestActions.current = actions;
|
||||
}, [actions]);
|
||||
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>((event) => latestActions.current.save(event), []);
|
||||
const handlePreview = useCallback(() => void latestActions.current.runPreview(), []);
|
||||
const handlePasteAiResponse = useCallback(() => void latestActions.current.pasteAiResponse(), []);
|
||||
|
||||
const invalid = hasDiagnostics || !candidate.success;
|
||||
const { renameRequest, renameOption } = createCustomWidgetRenameHandlers({
|
||||
form,
|
||||
candidate,
|
||||
invalidWidgetMessage: w("invalidWidget"),
|
||||
});
|
||||
return (
|
||||
<form onSubmit={save} className={classes.form}>
|
||||
<SegmentedControl
|
||||
className={classes.paneSwitcher}
|
||||
fullWidth
|
||||
value={mobilePane}
|
||||
onChange={(value) => setMobilePane(value as typeof mobilePane)}
|
||||
data={[
|
||||
{ value: "configure", label: w("configure") },
|
||||
{ value: "preview", label: w("section.preview") },
|
||||
]}
|
||||
/>
|
||||
<CustomWidgetSaveIssuesAlert issues={saveIssues} />
|
||||
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
|
||||
{sectionLinks.map(([id, key, SectionIcon]) => (
|
||||
<Button
|
||||
key={id}
|
||||
component="a"
|
||||
href={`#${id}`}
|
||||
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color={invalidSections.has(id) ? "red" : undefined}
|
||||
leftSection={<SectionIcon size={14} />}
|
||||
rightSection={
|
||||
invalidSections.has(id) ? <IconAlertCircle size={13} aria-label={w("status.invalid")} /> : undefined
|
||||
}
|
||||
>
|
||||
{w(key)}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={classes.workbench} data-mobile-pane={mobilePane}>
|
||||
<Stack gap="lg" className={classes.configuration}>
|
||||
<CustomWidgetAiCard
|
||||
candidate={candidate.success ? candidate.data : null}
|
||||
request={request}
|
||||
onRequestChange={setRequest}
|
||||
documentationUrl={documentationUrl}
|
||||
onDocumentationUrlChange={setDocumentationUrl}
|
||||
onPaste={() => void pasteAiResponse()}
|
||||
/>
|
||||
<CustomWidgetAdvancedManifest form={form} />
|
||||
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
|
||||
<TextInput label={tCommon("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
suggestedSearch={mode === "create" ? form.values.name : undefined}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
/>
|
||||
</EditorSection>
|
||||
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
|
||||
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
|
||||
</EditorSection>
|
||||
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
|
||||
<CustomWidgetRequestsEditor form={form} onRename={renameRequest} />
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="raw">
|
||||
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<CodeEditor
|
||||
id="requests-editor"
|
||||
label={w("requests.label")}
|
||||
description={w("requests.description")}
|
||||
language="json"
|
||||
value={form.values.requests}
|
||||
onChange={(value) => form.setFieldValue("requests", value)}
|
||||
diagnostics={requestDiagnostics}
|
||||
error={form.errors.requests}
|
||||
reference={customWidgetRequestReference}
|
||||
required
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</EditorSection>
|
||||
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
|
||||
<CustomWidgetOptionsEditor form={form} onRename={renameOption} />
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="raw">
|
||||
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<CodeEditor
|
||||
id="options"
|
||||
label={w("options.title")}
|
||||
language="json"
|
||||
value={form.values.options}
|
||||
onChange={(value) => form.setFieldValue("options", value)}
|
||||
error={form.errors.options}
|
||||
reference={customWidgetOptionsSchemaReference}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</EditorSection>
|
||||
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
|
||||
<CodeEditor
|
||||
id="jsx-editor"
|
||||
label={w("jsx.label")}
|
||||
language="jsx"
|
||||
value={form.values.template}
|
||||
onChange={(value) => form.setFieldValue("template", value)}
|
||||
diagnostics={templateDiagnostics}
|
||||
completions={jsxCompletions}
|
||||
error={form.errors.template}
|
||||
required
|
||||
maxLength={50_000}
|
||||
/>
|
||||
</EditorSection>
|
||||
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
|
||||
<SaveActions
|
||||
dirty={form.isDirty()}
|
||||
savePending={savePending}
|
||||
previewPending={previewPending}
|
||||
invalid={invalid}
|
||||
mode={mode}
|
||||
onPreview={() => void runPreview()}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
|
||||
<Paper p="md" className={classes.actionBar} shadow="sm">
|
||||
<SaveActions
|
||||
dirty={form.isDirty()}
|
||||
savePending={savePending}
|
||||
previewPending={previewPending}
|
||||
invalid={invalid}
|
||||
mode={mode}
|
||||
onPreview={() => void runPreview()}
|
||||
/>
|
||||
</Paper>
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={candidate.success ? candidate.data : null}
|
||||
validationIssues={
|
||||
candidate.success
|
||||
? []
|
||||
: candidate.error.issues.map((issue) => ({
|
||||
path: issue.path.map(String).join(".") || undefined,
|
||||
message: issue.message,
|
||||
}))
|
||||
}
|
||||
preview={preview}
|
||||
size={previewSize}
|
||||
onSizeChange={setPreviewSize}
|
||||
optionsSnapshot={optionsSnapshot}
|
||||
onOptionsChange={setOptionsSnapshot}
|
||||
onLiveActionsChange={(enabled) =>
|
||||
setPreview((current) => ({
|
||||
...current,
|
||||
session: current.session ? { ...current.session, liveActions: enabled } : null,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</form>
|
||||
<CustomWidgetFormView
|
||||
mode={mode}
|
||||
definitionId={definitionId}
|
||||
form={form}
|
||||
formErrors={mantineForm.errors}
|
||||
formRevision={mantineForm.key("name")}
|
||||
documentStore={documentStore}
|
||||
mobilePane={mobilePane}
|
||||
setMobilePane={setMobilePane}
|
||||
preview={preview}
|
||||
setPreview={setPreview}
|
||||
previewSize={previewSize}
|
||||
setPreviewSize={setPreviewSize}
|
||||
optionsSnapshot={optionsSnapshot}
|
||||
setOptionsSnapshot={setOptionsSnapshot}
|
||||
saveIssues={actions.saveIssues}
|
||||
savePending={actions.savePending}
|
||||
previewPending={actions.previewPending}
|
||||
onSubmit={handleSubmit}
|
||||
onPreview={handlePreview}
|
||||
onPasteAiResponse={handlePasteAiResponse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -69,7 +69,7 @@ export function CustomWidgetOptionsEditor({
|
||||
)
|
||||
: [];
|
||||
return (
|
||||
<Fieldset key={index} legend={typeof option.label === "string" ? option.label : id}>
|
||||
<Fieldset key={id} legend={typeof option.label === "string" ? option.label : id}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<CustomWidgetIdentifierInput
|
||||
@@ -99,7 +99,7 @@ export function CustomWidgetOptionsEditor({
|
||||
}
|
||||
/>
|
||||
<DefaultValueEditor
|
||||
id={`${index}-default`}
|
||||
id={`${id}-default`}
|
||||
control={control}
|
||||
value={option.default}
|
||||
choices={choices}
|
||||
|
||||
@@ -6,6 +6,8 @@ interface PreviewSnapshot {
|
||||
session: { id: string; liveActions: boolean } | null;
|
||||
}
|
||||
|
||||
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template">;
|
||||
|
||||
export function createPreviewDisplayData({
|
||||
candidate,
|
||||
fixture,
|
||||
@@ -13,7 +15,7 @@ export function createPreviewDisplayData({
|
||||
options,
|
||||
fixtureError,
|
||||
}: {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
candidate: PreviewCandidate | null;
|
||||
fixture: "live" | "loading" | "empty" | "error";
|
||||
preview: PreviewSnapshot;
|
||||
options: Record<string, unknown>;
|
||||
|
||||
+64
-53
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -15,9 +15,12 @@ import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-wi
|
||||
import type { PreviewOutcome } from "./_custom-widget-preview-status";
|
||||
import { PreviewActionControl } from "./_custom-widget-preview-action";
|
||||
import { createPreviewDisplayData } from "./_custom-widget-preview-data";
|
||||
import { CustomWidgetPreviewSizeControl } from "./_custom-widget-preview-size-control";
|
||||
import { JsonPreviewEditor } from "./_json-preview-editor";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
const previewWidths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
|
||||
|
||||
export interface PreviewState {
|
||||
data: Record<string, unknown>;
|
||||
status: Record<string, unknown>;
|
||||
@@ -36,7 +39,7 @@ interface PreviewPanelProps {
|
||||
onLiveActionsChange(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
|
||||
const actionT = useI18n("common.action");
|
||||
@@ -46,26 +49,55 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
{ sessionId: props.preview.session?.id ?? "" },
|
||||
{ enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false },
|
||||
);
|
||||
const widths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
|
||||
const candidate = props.candidate;
|
||||
const accessibilityIssues = candidate ? analyzeCustomWidgetAccessibility(candidate.template) : [];
|
||||
const displayData = createPreviewDisplayData({
|
||||
candidate,
|
||||
fixture,
|
||||
preview: props.preview,
|
||||
options: props.optionsSnapshot,
|
||||
fixtureError: t("fixtureError"),
|
||||
});
|
||||
const previewSummary = getPreviewSummary(props.preview.status);
|
||||
const rendererResetKey = JSON.stringify({
|
||||
template: candidate?.template,
|
||||
requests: candidate?.requests,
|
||||
fixture,
|
||||
data: props.preview.data,
|
||||
status: props.preview.status,
|
||||
sessionId: props.preview.session?.id,
|
||||
options: props.optionsSnapshot,
|
||||
});
|
||||
const candidateTemplate = candidate?.template;
|
||||
const candidateRequests = candidate?.requests;
|
||||
const previewCandidate = useMemo(() => {
|
||||
if (candidateTemplate === undefined || candidateRequests === undefined) return null;
|
||||
return { template: candidateTemplate, requests: candidateRequests };
|
||||
}, [candidateRequests, candidateTemplate]);
|
||||
const previewSnapshot = useMemo(
|
||||
() => ({ data: props.preview.data, status: props.preview.status, session: props.preview.session }),
|
||||
[props.preview.data, props.preview.session, props.preview.status],
|
||||
);
|
||||
const fixtureError = t("fixtureError");
|
||||
const accessibilityIssues = useMemo(() => {
|
||||
if (candidateTemplate === undefined) return [];
|
||||
return analyzeCustomWidgetAccessibility(candidateTemplate);
|
||||
}, [candidateTemplate]);
|
||||
const displayData = useMemo(
|
||||
() =>
|
||||
createPreviewDisplayData({
|
||||
candidate: previewCandidate,
|
||||
fixture,
|
||||
preview: previewSnapshot,
|
||||
options: props.optionsSnapshot,
|
||||
fixtureError,
|
||||
}),
|
||||
[fixture, fixtureError, previewCandidate, previewSnapshot, props.optionsSnapshot],
|
||||
);
|
||||
const previewSummary = useMemo(() => getPreviewSummary(props.preview.status), [props.preview.status]);
|
||||
const rendererResetKey = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
template: candidateTemplate,
|
||||
requests: candidateRequests,
|
||||
fixture,
|
||||
data: props.preview.data,
|
||||
status: props.preview.status,
|
||||
sessionId: props.preview.session?.id,
|
||||
options: props.optionsSnapshot,
|
||||
}),
|
||||
[
|
||||
candidateRequests,
|
||||
candidateTemplate,
|
||||
fixture,
|
||||
props.optionsSnapshot,
|
||||
props.preview.data,
|
||||
props.preview.session?.id,
|
||||
props.preview.status,
|
||||
],
|
||||
);
|
||||
const previewResult =
|
||||
props.preview.outcome === "success"
|
||||
? {
|
||||
@@ -85,37 +117,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs" justify="space-between">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={props.size}
|
||||
onChange={props.onSizeChange}
|
||||
data={[
|
||||
{
|
||||
value: "compact",
|
||||
label: (
|
||||
<span aria-label={t("size.compact")} title={t("size.compact")}>
|
||||
S
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "standard",
|
||||
label: (
|
||||
<span aria-label={t("size.standard")} title={t("size.standard")}>
|
||||
M
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "wide",
|
||||
label: (
|
||||
<span aria-label={t("size.wide")} title={t("size.wide")}>
|
||||
L
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<CustomWidgetPreviewSizeControl value={props.size} onChange={props.onSizeChange} />
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={fixture}
|
||||
@@ -151,7 +153,14 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Box className={classes.previewCanvas}>
|
||||
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
h={360}
|
||||
w={previewWidths[props.size] ?? 480}
|
||||
maw="100%"
|
||||
style={{ overflow: "auto" }}
|
||||
>
|
||||
{displayData ? (
|
||||
<PreviewErrorBoundary
|
||||
title={errorBoundaryMessages("title")}
|
||||
@@ -264,3 +273,5 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomWidgetPreviewPanel = memo(CustomWidgetPreviewPanelContent);
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { SegmentedControl, VisuallyHidden } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface CustomWidgetPreviewSizeControlProps {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
}
|
||||
|
||||
const previewSizeShortLabels = { compact: "S", standard: "M", wide: "L" } as const;
|
||||
|
||||
function CustomWidgetPreviewSizeControlContent({ value, onChange }: CustomWidgetPreviewSizeControlProps) {
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data={[
|
||||
{
|
||||
value: "compact",
|
||||
label: (
|
||||
<span title={t("size.compact")}>
|
||||
<span aria-hidden>{previewSizeShortLabels.compact}</span>
|
||||
<VisuallyHidden>{t("size.compact")}</VisuallyHidden>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "standard",
|
||||
label: (
|
||||
<span title={t("size.standard")}>
|
||||
<span aria-hidden>{previewSizeShortLabels.standard}</span>
|
||||
<VisuallyHidden>{t("size.standard")}</VisuallyHidden>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "wide",
|
||||
label: (
|
||||
<span title={t("size.wide")}>
|
||||
<span aria-hidden>{previewSizeShortLabels.wide}</span>
|
||||
<VisuallyHidden>{t("size.wide")}</VisuallyHidden>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomWidgetPreviewSizeControl = memo(CustomWidgetPreviewSizeControlContent);
|
||||
+8
-7
@@ -1,34 +1,35 @@
|
||||
import { renameCustomWidgetOption, renameCustomWidgetRequest } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import { applyDefinition } from "./_custom-widget-form-utils";
|
||||
import type { buildDefinition, CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { applyDefinition, buildDefinition } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
|
||||
interface RenameHandlersInput {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
candidate: ReturnType<typeof buildDefinition>;
|
||||
invalidWidgetMessage: string;
|
||||
}
|
||||
|
||||
export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
|
||||
const renameRequest = (currentId: string, nextId: string) => {
|
||||
if (!input.candidate.success) {
|
||||
const candidate = buildDefinition(input.form.getValues());
|
||||
if (!candidate.success) {
|
||||
input.form.setFieldError("requests", input.invalidWidgetMessage);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyDefinition(input.form, renameCustomWidgetRequest(input.candidate.data, currentId, nextId));
|
||||
applyDefinition(input.form, renameCustomWidgetRequest(candidate.data, currentId, nextId));
|
||||
input.form.clearFieldError("requests");
|
||||
} catch (error) {
|
||||
input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage);
|
||||
}
|
||||
};
|
||||
const renameOption = (currentName: string, nextName: string) => {
|
||||
if (!input.candidate.success) {
|
||||
const candidate = buildDefinition(input.form.getValues());
|
||||
if (!candidate.success) {
|
||||
input.form.setFieldError("options", input.invalidWidgetMessage);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyDefinition(input.form, renameCustomWidgetOption(input.candidate.data, currentName, nextName));
|
||||
applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName));
|
||||
input.form.clearFieldError("options");
|
||||
} catch (error) {
|
||||
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage);
|
||||
|
||||
+5
-5
@@ -23,7 +23,7 @@ export function QueryValuesEditor({
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const entries = Object.entries(value);
|
||||
const [nameErrors, setNameErrors] = useState<Record<number, string>>({});
|
||||
const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
@@ -65,23 +65,23 @@ export function QueryValuesEditor({
|
||||
...(allowParams ? [{ value: "param", label: t("invocationParameter") }] : []),
|
||||
];
|
||||
return (
|
||||
<Group key={index} align="end" wrap="wrap">
|
||||
<Group key={name} align="end" wrap="wrap">
|
||||
<CustomWidgetIdentifierInput
|
||||
style={{ flex: "1 1 10rem" }}
|
||||
label={t("queryKey")}
|
||||
value={name}
|
||||
error={nameErrors[index]}
|
||||
error={nameErrors[name]}
|
||||
onCommit={(nextName) => {
|
||||
if (
|
||||
!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(nextName) ||
|
||||
entries.some(([entryName], entryIndex) => entryIndex !== index && entryName === nextName)
|
||||
) {
|
||||
setNameErrors((currentErrors) => ({ ...currentErrors, [index]: t("queryKeyInvalid") }));
|
||||
setNameErrors((currentErrors) => ({ ...currentErrors, [name]: t("queryKeyInvalid") }));
|
||||
return;
|
||||
}
|
||||
setNameErrors((currentErrors) => {
|
||||
const nextErrors = { ...currentErrors };
|
||||
delete nextErrors[index];
|
||||
delete nextErrors[name];
|
||||
return nextErrors;
|
||||
});
|
||||
commit(nextName, kind, current);
|
||||
|
||||
+57
-64
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import { ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -9,6 +9,7 @@ import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { QueryValuesEditor, RequestBodyEditor } from "./_custom-widget-request-value-editors";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
import { LazyOnceAccordion } from "./_lazy-once-accordion";
|
||||
|
||||
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
@@ -24,6 +25,8 @@ export function CustomWidgetRequestsEditor({
|
||||
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
|
||||
const sources = parseJson(form.values.sources);
|
||||
const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"];
|
||||
const options = parseJson(form.values.options);
|
||||
const optionNames = isRecord(options) ? Object.keys(options) : [];
|
||||
|
||||
const commit = (next: Array<[string, unknown]>) =>
|
||||
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
|
||||
@@ -50,7 +53,7 @@ export function CustomWidgetRequestsEditor({
|
||||
const kind = request.kind === "action" ? "action" : "query";
|
||||
const trigger = request.trigger === "manual" ? "manual" : "load";
|
||||
return (
|
||||
<Fieldset key={index} legend={id || t("request", { count: index + 1 })}>
|
||||
<Fieldset key={id} legend={id || t("request", { count: index + 1 })}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<CustomWidgetIdentifierInput
|
||||
@@ -119,71 +122,61 @@ export function CustomWidgetRequestsEditor({
|
||||
<TextInput
|
||||
label={t("path")}
|
||||
description={t("pathDescription")}
|
||||
descriptionProps={{
|
||||
style: { color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))" },
|
||||
}}
|
||||
value={typeof request.path === "string" ? request.path : ""}
|
||||
onChange={(event) => update(index, id, { ...request, path: event.currentTarget.value })}
|
||||
/>
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="values">
|
||||
<Accordion.Control>{t("requestDetails")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<QueryValuesEditor
|
||||
value={isRecord(request.query) ? request.query : {}}
|
||||
optionNames={Object.keys(
|
||||
isRecord(parseJson(form.values.options))
|
||||
? (parseJson(form.values.options) as Record<string, unknown>)
|
||||
: {},
|
||||
)}
|
||||
allowParams={trigger === "manual" || kind === "action"}
|
||||
onChange={(query) => update(index, id, { ...request, query })}
|
||||
/>
|
||||
<RequestBodyEditor
|
||||
id={id}
|
||||
value={request.body}
|
||||
optionNames={Object.keys(
|
||||
isRecord(parseJson(form.values.options))
|
||||
? (parseJson(form.values.options) as Record<string, unknown>)
|
||||
: {},
|
||||
)}
|
||||
allowParams={trigger === "manual" || kind === "action"}
|
||||
onChange={(body) => update(index, id, { ...request, body })}
|
||||
/>
|
||||
{kind === "action" && (
|
||||
<TextInput
|
||||
label={t("confirmation")}
|
||||
value={typeof request.confirmation === "string" ? request.confirmation : ""}
|
||||
onChange={(event) =>
|
||||
update(index, id, { ...request, confirmation: event.currentTarget.value || undefined })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{kind === "action" && (
|
||||
<TextInput
|
||||
label={t("invalidation")}
|
||||
description={t("invalidationDescription")}
|
||||
value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""}
|
||||
onChange={(event) =>
|
||||
update(index, id, {
|
||||
...request,
|
||||
invalidates: event.currentTarget.value
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
label={t("noAuth")}
|
||||
checked={request.auth === "none"}
|
||||
onChange={(event) =>
|
||||
update(index, id, { ...request, auth: event.currentTarget.checked ? "none" : "inherit" })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<LazyOnceAccordion label={t("requestDetails")}>
|
||||
<Stack gap="sm">
|
||||
<QueryValuesEditor
|
||||
value={isRecord(request.query) ? request.query : {}}
|
||||
optionNames={optionNames}
|
||||
allowParams={trigger === "manual" || kind === "action"}
|
||||
onChange={(query) => update(index, id, { ...request, query })}
|
||||
/>
|
||||
<RequestBodyEditor
|
||||
id={id}
|
||||
value={request.body}
|
||||
optionNames={optionNames}
|
||||
allowParams={trigger === "manual" || kind === "action"}
|
||||
onChange={(body) => update(index, id, { ...request, body })}
|
||||
/>
|
||||
{kind === "action" && (
|
||||
<TextInput
|
||||
label={t("confirmation")}
|
||||
value={typeof request.confirmation === "string" ? request.confirmation : ""}
|
||||
onChange={(event) =>
|
||||
update(index, id, { ...request, confirmation: event.currentTarget.value || undefined })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{kind === "action" && (
|
||||
<TextInput
|
||||
label={t("invalidation")}
|
||||
description={t("invalidationDescription")}
|
||||
value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""}
|
||||
onChange={(event) =>
|
||||
update(index, id, {
|
||||
...request,
|
||||
invalidates: event.currentTarget.value
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
label={t("noAuth")}
|
||||
checked={request.auth === "none"}
|
||||
onChange={(event) =>
|
||||
update(index, id, { ...request, auth: event.currentTarget.checked ? "none" : "inherit" })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</LazyOnceAccordion>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
|
||||
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
|
||||
|
||||
export function reportSaveIssues(
|
||||
form: UseFormReturnType<CustomWidgetFormValues>,
|
||||
issues: CustomWidgetSaveIssue[],
|
||||
setIssues: Dispatch<SetStateAction<CustomWidgetSaveIssue[]>>,
|
||||
title: string,
|
||||
getRemainingLabel: (count: number) => string,
|
||||
) {
|
||||
setIssues(issues);
|
||||
const messagesByPath = new Map<string, string[]>();
|
||||
for (const issue of issues) {
|
||||
if (!issue.path) continue;
|
||||
const field = issue.path.split(".")[0] ?? issue.path;
|
||||
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
|
||||
}
|
||||
for (const [path, messages] of messagesByPath) {
|
||||
form.setFieldError(path, messages.join(" "));
|
||||
}
|
||||
const remaining = issues.length - 1;
|
||||
showErrorNotification({
|
||||
title,
|
||||
message: `${issues[0]?.message ?? ""}${remaining > 0 ? ` ${getRemainingLabel(remaining)}` : ""}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
|
||||
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
|
||||
form.clearFieldError(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function areCustomWidgetValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
|
||||
if (Array.isArray(left)) {
|
||||
if (!Array.isArray(right) || left.length !== right.length) return false;
|
||||
return left.every((value, index) => areCustomWidgetValuesEqual(value, right[index]));
|
||||
}
|
||||
|
||||
if (!isRecord(left) || !isRecord(right)) return false;
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
return leftKeys.every((key) => Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { Accordion } from "@mantine/core";
|
||||
|
||||
interface LazyOnceAccordionProps {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LazyOnceAccordion({ label, children }: LazyOnceAccordionProps) {
|
||||
const id = useId();
|
||||
const [value, setValue] = useState<string | null>(null);
|
||||
const [hasOpened, setHasOpened] = useState(false);
|
||||
const controlId = `${id}-control`;
|
||||
const panelId = `${id}-panel`;
|
||||
|
||||
const handleChange = (nextValue: string | null) => {
|
||||
setValue(nextValue);
|
||||
if (nextValue === "content") setHasOpened(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Accordion variant="contained" value={value} onChange={handleChange}>
|
||||
<Accordion.Item value="content">
|
||||
<Accordion.Control id={controlId} aria-controls={panelId}>
|
||||
{label}
|
||||
</Accordion.Control>
|
||||
{/* Native hiding keeps editor effects and CodeMirror history alive while the panel is collapsed. */}
|
||||
<div
|
||||
id={panelId}
|
||||
className={Accordion.classes.panel}
|
||||
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- The explicit role matches Mantine's panel contract.
|
||||
role="region"
|
||||
aria-labelledby={controlId}
|
||||
hidden={value !== "content"}
|
||||
>
|
||||
<div className={Accordion.classes.content}>{hasOpened && children}</div>
|
||||
</div>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
+58
-43
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
@@ -22,13 +22,14 @@ import {
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
|
||||
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
|
||||
import { clearSaveIssues, reportSaveIssues } from "./_custom-widget-save-issue-utils";
|
||||
import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
|
||||
|
||||
interface FormActionsInput {
|
||||
mode: "create" | "edit";
|
||||
definitionId?: string;
|
||||
form: UseFormReturnType<CustomWidgetFormValues>;
|
||||
candidate: ReturnType<typeof buildDefinition>;
|
||||
preview: PreviewState;
|
||||
documentStore: CustomWidgetFormDocumentStore;
|
||||
setPreview: Dispatch<SetStateAction<PreviewState>>;
|
||||
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
@@ -45,6 +46,34 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
|
||||
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
|
||||
const [previewPending, setPreviewPending] = useState(false);
|
||||
const setPreview = input.setPreview;
|
||||
const previewGeneration = useRef(0);
|
||||
const activePreviewGeneration = useRef<number | null>(null);
|
||||
const invalidatePreview = useCallback(() => {
|
||||
previewGeneration.current += 1;
|
||||
if (activePreviewGeneration.current !== null) return;
|
||||
setPreview((preview) => {
|
||||
if (
|
||||
preview.outcome === "idle" &&
|
||||
preview.session === null &&
|
||||
Object.keys(preview.data).length === 0 &&
|
||||
Object.keys(preview.status).length === 0
|
||||
)
|
||||
return preview;
|
||||
return { data: {}, status: {}, session: null, outcome: "idle" };
|
||||
});
|
||||
}, [setPreview]);
|
||||
useLayoutEffect(() => {
|
||||
invalidatePreview();
|
||||
}, [input.optionsSnapshot, invalidatePreview]);
|
||||
useEffect(() => input.documentStore.subscribe(invalidatePreview), [input.documentStore, invalidatePreview]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
previewGeneration.current += 1;
|
||||
activePreviewGeneration.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
let actionLabel = tCommon("action.save");
|
||||
if (input.mode === "create") actionLabel = tCommon("action.create");
|
||||
|
||||
@@ -73,6 +102,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
});
|
||||
input.form.setInitialValues(values);
|
||||
input.form.resetDirty();
|
||||
input.documentStore.markSaved(values);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
window.location.assign(result.managementPath);
|
||||
} else if (input.definitionId) {
|
||||
@@ -88,6 +118,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
]);
|
||||
input.form.setInitialValues(values);
|
||||
input.form.resetDirty();
|
||||
input.documentStore.markSaved(values);
|
||||
showSuccessNotification({
|
||||
title: tCommon("action.save"),
|
||||
message: t("notification.updated", { name: values.name }),
|
||||
@@ -104,15 +135,18 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
});
|
||||
|
||||
const runPreview = async () => {
|
||||
if (previewPending) return;
|
||||
if (!input.candidate.success) {
|
||||
if (activePreviewGeneration.current !== null) return;
|
||||
const valuesAtStart = input.documentStore.getValues();
|
||||
const candidateAtStart = buildDefinition(valuesAtStart);
|
||||
if (!candidateAtStart.success) {
|
||||
showErrorNotification({
|
||||
title: w("section.preview"),
|
||||
message: input.candidate.error.issues[0]?.message ?? w("invalidWidget"),
|
||||
message: candidateAtStart.error.issues[0]?.message ?? w("invalidWidget"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const optionIssues = getCustomWidgetPreviewOptionIssues(input.candidate.data, input.optionsSnapshot);
|
||||
const optionsAtStart = input.optionsSnapshot;
|
||||
const optionIssues = getCustomWidgetPreviewOptionIssues(candidateAtStart.data, optionsAtStart);
|
||||
if (optionIssues.length > 0) {
|
||||
const issue = optionIssues[0];
|
||||
showErrorNotification({
|
||||
@@ -121,17 +155,24 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const secretsAtStart = getChangedSecrets(valuesAtStart);
|
||||
const generation = previewGeneration.current + 1;
|
||||
previewGeneration.current = generation;
|
||||
activePreviewGeneration.current = generation;
|
||||
const isCurrent = () => previewGeneration.current === generation;
|
||||
input.setMobilePane("preview");
|
||||
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
|
||||
setPreviewPending(true);
|
||||
try {
|
||||
const created = await previewMutation.mutateAsync({
|
||||
definition: input.candidate.data,
|
||||
definition: candidateAtStart.data,
|
||||
definitionId: input.definitionId,
|
||||
options: input.optionsSnapshot,
|
||||
secrets: getChangedSecrets(input.form.values),
|
||||
options: optionsAtStart,
|
||||
secrets: secretsAtStart,
|
||||
});
|
||||
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
|
||||
if (!isCurrent()) return;
|
||||
const snapshot = await loadPreviewQueries(candidateAtStart.data, created.previewSession.id);
|
||||
if (!isCurrent()) return;
|
||||
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
|
||||
input.setPreview({
|
||||
...snapshot,
|
||||
@@ -156,10 +197,15 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!isCurrent()) return;
|
||||
input.setPreview((current) => ({ ...current, outcome: "error" }));
|
||||
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
|
||||
} finally {
|
||||
setPreviewPending(false);
|
||||
if (activePreviewGeneration.current === generation) {
|
||||
activePreviewGeneration.current = null;
|
||||
setPreviewPending(false);
|
||||
if (!isCurrent()) setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,7 +216,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
throw new Error(formatCustomWidgetImportIssues(result.issues));
|
||||
}
|
||||
input.setOptionsSnapshot(getDefinitionDefaults(result.widget));
|
||||
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
showSuccessNotification({
|
||||
title: w("ai.response"),
|
||||
message:
|
||||
@@ -196,33 +241,3 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
previewPending,
|
||||
};
|
||||
}
|
||||
|
||||
function reportSaveIssues(
|
||||
form: UseFormReturnType<CustomWidgetFormValues>,
|
||||
issues: CustomWidgetSaveIssue[],
|
||||
setIssues: Dispatch<SetStateAction<CustomWidgetSaveIssue[]>>,
|
||||
title: string,
|
||||
getRemainingLabel: (count: number) => string,
|
||||
) {
|
||||
setIssues(issues);
|
||||
const messagesByPath = new Map<string, string[]>();
|
||||
for (const issue of issues) {
|
||||
if (!issue.path) continue;
|
||||
const field = issue.path.split(".")[0] ?? issue.path;
|
||||
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
|
||||
}
|
||||
for (const [path, messages] of messagesByPath) {
|
||||
form.setFieldError(path, messages.join(" "));
|
||||
}
|
||||
const remaining = issues.length - 1;
|
||||
showErrorNotification({
|
||||
title,
|
||||
message: `${issues[0]?.message ?? ""}${remaining > 0 ? ` ${getRemainingLabel(remaining)}` : ""}`,
|
||||
});
|
||||
}
|
||||
|
||||
function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
|
||||
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
|
||||
form.clearFieldError(path);
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useDeferredValue,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CustomWidgetAiDiagnostic } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { customWidgetOptionsSchema } from "@homarr/custom-widgets/core";
|
||||
import { analyzeJsxTemplate, analyzeRequestManifest } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
|
||||
import { buildDefinition, isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import { useDeferredCustomWidgetFormDocumentValues } from "./_custom-widget-form-state";
|
||||
|
||||
const emptyPreviewValidationIssues: Array<{ path?: string; message: string }> = [];
|
||||
const emptyCandidateIssues: Array<{ path: PropertyKey[] }> = [];
|
||||
|
||||
function normalizeEditorDiagnostics(
|
||||
section: "requests" | "template",
|
||||
diagnostics: readonly EditorDiagnostic[],
|
||||
): CustomWidgetAiDiagnostic[] {
|
||||
return diagnostics.map((diagnostic) => ({
|
||||
section,
|
||||
severity: diagnostic.severity,
|
||||
code: diagnostic.code,
|
||||
line: diagnostic.line,
|
||||
column: diagnostic.column,
|
||||
message: diagnostic.value,
|
||||
}));
|
||||
}
|
||||
|
||||
function createAiDiagnostics(
|
||||
candidate: ReturnType<typeof buildDefinition>,
|
||||
requestDiagnostics: readonly EditorDiagnostic[],
|
||||
templateDiagnostics: readonly EditorDiagnostic[],
|
||||
) {
|
||||
const editorDiagnostics = [
|
||||
...normalizeEditorDiagnostics("requests", requestDiagnostics),
|
||||
...normalizeEditorDiagnostics("template", templateDiagnostics),
|
||||
];
|
||||
if (candidate.success) return editorDiagnostics;
|
||||
return [
|
||||
...candidate.error.issues.map((issue) => ({
|
||||
section: String(issue.path[0] ?? "definition"),
|
||||
severity: "error" as const,
|
||||
path: issue.path.map(String).join(".") || undefined,
|
||||
message: issue.message,
|
||||
})),
|
||||
...editorDiagnostics,
|
||||
];
|
||||
}
|
||||
|
||||
export function analyzeCustomWidgetAiDiagnostics(values: CustomWidgetFormValues): CustomWidgetAiDiagnostic[] {
|
||||
const parsedRequests = parseJson(values.requests);
|
||||
const requestIds = isRecord(parsedRequests) ? Object.keys(parsedRequests) : [];
|
||||
return createAiDiagnostics(
|
||||
buildDefinition(values),
|
||||
analyzeRequestManifest(values.requests),
|
||||
analyzeJsxTemplate(values.template, { requestIds }),
|
||||
);
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormAnalysis(values: CustomWidgetFormValues) {
|
||||
const analysisValues = useDeferredValue(values);
|
||||
const candidate = useMemo(() => buildDefinition(analysisValues), [analysisValues]);
|
||||
const parsedOptions = useMemo(
|
||||
() => customWidgetOptionsSchema.safeParse(parseJson(analysisValues.options)),
|
||||
[analysisValues.options],
|
||||
);
|
||||
const requestIds = useMemo(() => {
|
||||
const parsedRequests = parseJson(analysisValues.requests);
|
||||
return isRecord(parsedRequests) ? Object.keys(parsedRequests) : [];
|
||||
}, [analysisValues.requests]);
|
||||
const requestIdsSignature = requestIds.join("\0");
|
||||
const jsxCompletions = useMemo(
|
||||
() =>
|
||||
createCustomWidgetCompletions(
|
||||
{ options: analysisValues.options, sources: analysisValues.sources },
|
||||
requestIdsSignature ? requestIdsSignature.split("\0") : [],
|
||||
),
|
||||
[analysisValues.options, analysisValues.sources, requestIdsSignature],
|
||||
);
|
||||
const templateDiagnostics = useMemo(
|
||||
() => analyzeJsxTemplate(analysisValues.template, { requestIds }),
|
||||
[analysisValues.template, requestIds],
|
||||
);
|
||||
const requestDiagnostics = useMemo(() => analyzeRequestManifest(analysisValues.requests), [analysisValues.requests]);
|
||||
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
|
||||
const candidateIssues = candidate.success ? emptyCandidateIssues : candidate.error.issues;
|
||||
const invalidSections = useMemo(
|
||||
() => getInvalidCustomWidgetSections(candidateIssues, requestDiagnostics, templateDiagnostics),
|
||||
[candidateIssues, requestDiagnostics, templateDiagnostics],
|
||||
);
|
||||
const previewCandidate = useMemo(
|
||||
() => (candidate.success ? candidate.data : null),
|
||||
// The preview renderer consumes only these definition fields; other fields affect the validity boolean below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[analysisValues.requests, analysisValues.template, candidate.success],
|
||||
);
|
||||
const previewValidationIssues = useMemo(
|
||||
() =>
|
||||
candidate.success
|
||||
? emptyPreviewValidationIssues
|
||||
: candidate.error.issues.map((issue) => ({
|
||||
path: issue.path.map(String).join(".") || undefined,
|
||||
message: issue.message,
|
||||
})),
|
||||
[candidate],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
candidate,
|
||||
candidateValid: candidate.success,
|
||||
parsedOptions,
|
||||
jsxCompletions,
|
||||
templateDiagnostics,
|
||||
requestDiagnostics,
|
||||
hasDiagnostics,
|
||||
invalidSections,
|
||||
previewCandidate,
|
||||
previewValidationIssues,
|
||||
}),
|
||||
[
|
||||
candidate,
|
||||
hasDiagnostics,
|
||||
invalidSections,
|
||||
jsxCompletions,
|
||||
parsedOptions,
|
||||
previewCandidate,
|
||||
previewValidationIssues,
|
||||
requestDiagnostics,
|
||||
templateDiagnostics,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
type CustomWidgetFormAnalysis = ReturnType<typeof useCustomWidgetFormAnalysis>;
|
||||
|
||||
interface CustomWidgetFormAnalysisStore {
|
||||
getAnalysis(): CustomWidgetFormAnalysis;
|
||||
setAnalysis(analysis: CustomWidgetFormAnalysis): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
function createCustomWidgetFormAnalysisStore(initialAnalysis: CustomWidgetFormAnalysis): CustomWidgetFormAnalysisStore {
|
||||
let analysis = initialAnalysis;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
getAnalysis: () => analysis,
|
||||
setAnalysis: (nextAnalysis) => {
|
||||
if (Object.is(analysis, nextAnalysis)) return;
|
||||
analysis = nextAnalysis;
|
||||
listeners.forEach((listener) => listener());
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const CustomWidgetFormAnalysisContext = createContext<CustomWidgetFormAnalysisStore | null>(null);
|
||||
|
||||
export function CustomWidgetFormAnalysisProvider({ children }: { children: ReactNode }) {
|
||||
const values = useDeferredCustomWidgetFormDocumentValues();
|
||||
const analysis = useCustomWidgetFormAnalysis(values);
|
||||
const [store] = useState(() => createCustomWidgetFormAnalysisStore(analysis));
|
||||
useLayoutEffect(() => store.setAnalysis(analysis), [analysis, store]);
|
||||
return createElement(CustomWidgetFormAnalysisContext.Provider, { value: store }, children);
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormAnalysisField<Key extends keyof CustomWidgetFormAnalysis>(field: Key) {
|
||||
const store = useContext(CustomWidgetFormAnalysisContext);
|
||||
if (!store) throw new Error("Custom widget form analysis provider is missing");
|
||||
const getSnapshot = useCallback(() => store.getAnalysis()[field], [field, store]);
|
||||
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export default async function NewCustomWidgetPage() {
|
||||
<Stack>
|
||||
<div>
|
||||
<Title>{t("page.create.title")}</Title>
|
||||
<Text c="dimmed" size="sm" mt={4}>
|
||||
<Text c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))" size="sm" mt={4}>
|
||||
{t("page.create.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useWorkshopQuery } from "@homarr/workshop/backend";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
|
||||
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
|
||||
import { WorkshopSubmissionGrid } from "~/components/workshop/workshop-submission-grid";
|
||||
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
|
||||
@@ -23,9 +22,10 @@ interface WorkshopBrowseProps {
|
||||
search: string | undefined;
|
||||
sort: "top" | "newest";
|
||||
page: number;
|
||||
workshopWebUrl: string;
|
||||
}
|
||||
|
||||
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
|
||||
export function WorkshopBrowse({ search, sort, page, workshopWebUrl }: WorkshopBrowseProps) {
|
||||
const t = useI18n("workshop");
|
||||
const tCommon = useI18n("common.action");
|
||||
const session = useWorkshopSession();
|
||||
@@ -46,7 +46,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
|
||||
<WorkshopAccountButton session={session} />
|
||||
<Button
|
||||
component="a"
|
||||
href={getWorkshopWebUrl()}
|
||||
href={workshopWebUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
@@ -100,7 +100,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
|
||||
<Tooltip label={t("openCommunity")}>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={getWorkshopWebUrl(item.id)}
|
||||
href={`${workshopWebUrl.replace(/\/+$/u, "")}/${encodeURIComponent(item.id)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="subtle"
|
||||
|
||||
@@ -3,6 +3,9 @@ import { z } from "zod/v4";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
import { WorkshopBrowse } from "./_workshop-browse";
|
||||
|
||||
@@ -26,6 +29,13 @@ export default async function WorkshopBrowsePage(props: WorkshopBrowsePageProps)
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
|
||||
const { search, sort, page } = searchParamsSchema.parse(await props.searchParams);
|
||||
const { workshopWebUrl } = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
|
||||
return <WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} />;
|
||||
return (
|
||||
<WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} workshopWebUrl={workshopWebUrl} />
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import { env } from "@homarr/docker/env";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
@@ -74,6 +74,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.home"),
|
||||
icon: IconHomeFilled,
|
||||
href: "/manage",
|
||||
exact: true,
|
||||
"data-onboarding-tour-id": "manage-welcome",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,14 +7,13 @@ import { getI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { HeroBanner } from "./_components/hero-banner";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
ColorSwatch,
|
||||
Fieldset,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
interface BrandingSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
|
||||
const t = useI18n("management.page.settings.section.branding");
|
||||
const logoInput = form.getInputProps("branding.logoImageUrl");
|
||||
const faviconInput = form.getInputProps("branding.faviconImageUrl");
|
||||
const backgroundInput = form.getInputProps("branding.signInBackgroundImageUrl");
|
||||
const branding = form.values.branding;
|
||||
|
||||
return (
|
||||
<SectionCard title={t("title")}>
|
||||
<Stack gap="md">
|
||||
<Fieldset legend={t("groups.identity")} p="sm">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("identity.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="sm" verticalSpacing="sm">
|
||||
<TextInput label={t("appName.label")} {...form.getInputProps("branding.appName")} />
|
||||
<IconPicker
|
||||
label={t("logo.label")}
|
||||
description={null}
|
||||
placeholder={t("logo.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={logoInput.value ?? ""}
|
||||
onChange={(value) => form.setFieldValue("branding.logoImageUrl", value || null)}
|
||||
error={logoInput.error}
|
||||
/>
|
||||
<IconPicker
|
||||
label={t("favicon.label")}
|
||||
description={null}
|
||||
placeholder={t("favicon.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={faviconInput.value ?? ""}
|
||||
onChange={(value) => form.setFieldValue("branding.faviconImageUrl", value || null)}
|
||||
error={faviconInput.error}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend={t("groups.appearance")} p="sm">
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" verticalSpacing="sm">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<BoardColorInput
|
||||
label={t("primaryColor.label")}
|
||||
defaultColor="#fa5252"
|
||||
{...form.getInputProps("branding.primaryColor")}
|
||||
/>
|
||||
<BoardColorInput
|
||||
label={t("secondaryColor.label")}
|
||||
defaultColor="#fd7e14"
|
||||
{...form.getInputProps("branding.secondaryColor")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Switch
|
||||
label={t("lockPrimaryColor.label")}
|
||||
description={t("lockPrimaryColor.description")}
|
||||
{...form.getInputProps("branding.lockPrimaryColor", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
<CornerStylePicker
|
||||
compact
|
||||
label={t("defaultRadius.label")}
|
||||
value={branding.defaultRadius}
|
||||
labels={
|
||||
Object.fromEntries(
|
||||
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`defaultRadius.options.${cornerStyle}`)]),
|
||||
) as Record<(typeof cornerStyleValues)[number], string>
|
||||
}
|
||||
onChange={(cornerStyle) => form.setFieldValue("branding.defaultRadius", cornerStyle)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend={t("groups.authentication")} p="sm">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
|
||||
<TextInput label={t("greeting.label")} {...form.getInputProps("branding.greeting")} />
|
||||
<IconPicker
|
||||
label={t("signInBackground.label")}
|
||||
description={null}
|
||||
placeholder={t("signInBackground.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={backgroundInput.value ?? ""}
|
||||
onChange={(value) => form.setFieldValue("branding.signInBackgroundImageUrl", value || null)}
|
||||
error={backgroundInput.error}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md" verticalSpacing="sm">
|
||||
<Stack gap={6} justify="center">
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("signInOverlay.label")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.round(branding.signInBackgroundOverlay * 100)}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Slider
|
||||
aria-label={t("signInOverlay.label")}
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.05}
|
||||
label={(value) => `${Math.round(value * 100)}%`}
|
||||
disabled={!branding.signInBackgroundImageUrl}
|
||||
value={branding.signInBackgroundOverlay}
|
||||
onChange={(value) => form.setFieldValue("branding.signInBackgroundOverlay", value)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("authVisibility.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t("authVisibility.appName")}
|
||||
{...form.getInputProps("branding.authBranding.showAppName", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t("authVisibility.logo")}
|
||||
{...form.getInputProps("branding.authBranding.showLogo", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t("authVisibility.greeting")}
|
||||
{...form.getInputProps("branding.authBranding.showGreeting", { type: "checkbox" })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<BrandingSummary form={form} />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
const BrandingSummary = ({ form }: BrandingSettingsFormProps) => {
|
||||
const t = useI18n("management.page.settings.section.branding");
|
||||
const branding = form.values.branding;
|
||||
const appName = branding.appName.trim() || "Homarr";
|
||||
const logoImageUrl = branding.logoImageUrl || "/logo/logo.png";
|
||||
const faviconImageUrl = branding.faviconImageUrl || logoImageUrl;
|
||||
const backgroundStatus = branding.signInBackgroundImageUrl
|
||||
? t("summary.backgroundConfigured", { overlay: Math.round(branding.signInBackgroundOverlay * 100) })
|
||||
: t("summary.backgroundDefault");
|
||||
const greetingStatus = branding.greeting.trim() ? t("summary.greetingConfigured") : t("summary.greetingDefault");
|
||||
|
||||
return (
|
||||
<Paper withBorder p="sm" radius={branding.defaultRadius}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="xs" wrap="wrap">
|
||||
<Text fw={650}>{t("summary.title")}</Text>
|
||||
<Badge variant="light">
|
||||
{t("summary.cornerStyle")}: {t(`defaultRadius.options.${branding.defaultRadius}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" verticalSpacing="sm">
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||
{t("groups.identity")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Avatar.Group>
|
||||
<Avatar src={logoImageUrl} alt={t("logo.label")} />
|
||||
<Avatar src={faviconImageUrl} alt={t("favicon.label")} />
|
||||
</Avatar.Group>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{appName}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||
{t("summary.palette")}
|
||||
</Text>
|
||||
<Group gap="md">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ColorSwatch color={branding.primaryColor} size={20} />
|
||||
<Text size="xs">{branding.primaryColor}</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ColorSwatch color={branding.secondaryColor} size={20} />
|
||||
<Text size="xs">{branding.secondaryColor}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Badge color={branding.lockPrimaryColor ? "primaryColor" : "gray"} variant="light" w="fit-content">
|
||||
{branding.lockPrimaryColor ? t("summary.boardColorLocked") : t("summary.boardColorFlexible")}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||
{t("groups.authentication")}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{backgroundStatus} · {greetingStatus}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<VisibilityBadge label={t("summary.appName")} visible={branding.authBranding.showAppName} />
|
||||
<VisibilityBadge label={t("summary.logo")} visible={branding.authBranding.showLogo} />
|
||||
<VisibilityBadge label={t("summary.greeting")} visible={branding.authBranding.showGreeting} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const VisibilityBadge = ({ label, visible }: { label: string; visible: boolean }) => {
|
||||
const t = useI18n("management.page.settings.section.branding.summary");
|
||||
|
||||
return (
|
||||
<Badge size="xs" color={visible ? "green" : "gray"} variant="light">
|
||||
{label}: {visible ? t("on") : t("off")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -12,12 +12,14 @@ import { colorSchemes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { brandingServerSettingsSchema } from "@homarr/server-settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
import { AnalyticsSettings } from "./analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./board-settings-form";
|
||||
import { BrandingSettingsForm } from "./branding-settings-form";
|
||||
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
|
||||
import { CultureSettingsForm } from "./culture-settings-form";
|
||||
import { SearchSettingsForm } from "./search-settings-form";
|
||||
@@ -37,6 +39,7 @@ const settingsFormSchema = z.object({
|
||||
defaultSearchEngineId: z.string().nullable(),
|
||||
defaultColorScheme: z.enum(colorSchemes),
|
||||
defaultLocale: z.string(),
|
||||
branding: brandingServerSettingsSchema,
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof settingsFormSchema>;
|
||||
@@ -55,6 +58,7 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
|
||||
defaultColorScheme: initialData.appearance.defaultColorScheme,
|
||||
defaultLocale: initialData.culture.defaultLocale,
|
||||
branding: initialData.branding,
|
||||
});
|
||||
|
||||
interface SettingsFormProps {
|
||||
@@ -137,6 +141,11 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
value: { defaultColorScheme: values.defaultColorScheme },
|
||||
},
|
||||
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
|
||||
{
|
||||
settingsKey: "branding",
|
||||
when: changed("branding"),
|
||||
value: values.branding,
|
||||
},
|
||||
];
|
||||
|
||||
const promises = groups
|
||||
@@ -176,6 +185,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<BrandingSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
|
||||
@@ -9,10 +9,9 @@ import { SettingsForm } from "./_components/settings-form";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
||||
|
||||
return {
|
||||
title: metaTitle,
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { auth } from "@homarr/auth/next";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
|
||||
import { ApiKeysManagement } from "./components/api-keys";
|
||||
@@ -63,7 +62,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { auth } from "@homarr/auth/next";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { BackupExportCard } from "./_components/backup-export-card";
|
||||
import { BackupImportCard } from "./_components/backup-import-card";
|
||||
@@ -19,7 +18,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.tool.backup");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { logsEnv } from "@homarr/core/infrastructure/logs/env";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { ClientSideTerminalComponent } from "./client";
|
||||
import { LogFontSizeSlider } from "./font-size-slider";
|
||||
import { LogLevelSelection } from "./level-selection";
|
||||
@@ -25,7 +24,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { TasksTable } from "./_components/tasks-table";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -17,7 +16,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+163
-130
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
@@ -22,12 +24,15 @@ import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
headerPreferencesSchema,
|
||||
parseHeaderPreferences,
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userEditProfileSchema,
|
||||
@@ -36,9 +41,9 @@ import {
|
||||
userPingIconsEnabledSchema,
|
||||
} from "@homarr/validation/user";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { HeaderComposer } from "./header-composer";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
@@ -53,6 +58,7 @@ const userGeneralSettingsSchema = z.object({
|
||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
|
||||
headerPreferences: headerPreferencesSchema,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||
@@ -65,9 +71,10 @@ const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
|
||||
|
||||
interface UserGeneralSettingsFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
boardsData: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
|
||||
searchEnginesData: { value: string; label: string }[];
|
||||
showLanguageSelector?: boolean;
|
||||
profileAvatar?: ReactNode;
|
||||
}
|
||||
|
||||
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
|
||||
@@ -81,6 +88,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
|
||||
headerPreferences: parseHeaderPreferences(user.headerPreferences),
|
||||
});
|
||||
|
||||
export const UserGeneralSettingsForm = ({
|
||||
@@ -88,6 +96,7 @@ export const UserGeneralSettingsForm = ({
|
||||
boardsData,
|
||||
searchEnginesData,
|
||||
showLanguageSelector = false,
|
||||
profileAvatar,
|
||||
}: UserGeneralSettingsFormProps) => {
|
||||
const tUser = useI18n("user");
|
||||
const tCommon = useI18n("common");
|
||||
@@ -101,6 +110,7 @@ export const UserGeneralSettingsForm = ({
|
||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
|
||||
const changeHeaderPreferencesMutation = clientApi.user.changeHeaderPreferences.useMutation();
|
||||
|
||||
const initialValues = buildInitialValues(user);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
@@ -121,6 +131,7 @@ export const UserGeneralSettingsForm = ({
|
||||
changeFirstDayOfWeekMutation,
|
||||
changePingIconsEnabledMutation,
|
||||
changeEnableRightClickOnWidgetsMutation,
|
||||
changeHeaderPreferencesMutation,
|
||||
];
|
||||
const isPending = mutations.some((m) => m.isPending);
|
||||
|
||||
@@ -184,6 +195,14 @@ export const UserGeneralSettingsForm = ({
|
||||
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("headerPreferences"),
|
||||
action: () =>
|
||||
changeHeaderPreferencesMutation.mutateAsync({
|
||||
id: user.id,
|
||||
headerPreferences: parsed.data.headerPreferences,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
||||
@@ -218,135 +237,149 @@ export const UserGeneralSettingsForm = ({
|
||||
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tUser("name")}</Title>
|
||||
{!isCredentialsUser && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{tUserManagement("fieldsDisabledExternalProvider")}
|
||||
</Text>
|
||||
<Stack gap="lg">
|
||||
<HeaderComposer
|
||||
value={form.values.headerPreferences}
|
||||
onChange={(headerPreferences) => form.setFieldValue("headerPreferences", headerPreferences)}
|
||||
boards={boardsData}
|
||||
homeBoardId={form.values.homeBoardId}
|
||||
mobileHomeBoardId={form.values.mobileHomeBoardId}
|
||||
/>
|
||||
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tUser("name")}</Title>
|
||||
{!isCredentialsUser && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{tUserManagement("fieldsDisabledExternalProvider")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider />
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.search")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Select
|
||||
label={tUser("field.defaultSearchEngine.label")}
|
||||
w="100%"
|
||||
data={searchEnginesData}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.language")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
||||
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<Radio.Group
|
||||
{...firstDayOfWeekInputProps}
|
||||
value={firstDayOfWeekValue}
|
||||
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
||||
>
|
||||
<Group mt="xs" wrap="wrap">
|
||||
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
||||
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
||||
))}
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
<Divider my="xs" />
|
||||
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
||||
<Switch
|
||||
label={tUser("field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.enableRightClickOnWidgets.label")}
|
||||
description={tUser("field.enableRightClickOnWidgets.description")}
|
||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
{form.isDirty() && (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{tCommon("action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{tCommon("action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider />
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.search")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Select
|
||||
label={tUser("field.defaultSearchEngine.label")}
|
||||
w="100%"
|
||||
data={searchEnginesData}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.language")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
||||
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<Radio.Group
|
||||
{...firstDayOfWeekInputProps}
|
||||
value={firstDayOfWeekValue}
|
||||
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
||||
>
|
||||
<Group mt="xs" wrap="wrap">
|
||||
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
||||
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
||||
))}
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
<Divider my="xs" />
|
||||
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
||||
<Switch
|
||||
label={tUser("field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.enableRightClickOnWidgets.label")}
|
||||
description={tUser("field.enableRightClickOnWidgets.description")}
|
||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
{form.isDirty() && (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{tCommon("action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{tCommon("action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
</form>
|
||||
</Box>
|
||||
{profileAvatar ? <Box w={{ base: "100%", lg: 260 }}>{profileAvatar}</Box> : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
.composer {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.heading,
|
||||
.tray {
|
||||
padding: var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.heading {
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 21rem;
|
||||
padding: var(--mantine-spacing-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
background-image: radial-gradient(var(--mantine-color-default-border) 0.75px, transparent 0.75px);
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
.device {
|
||||
position: relative;
|
||||
width: min(100%, 34rem);
|
||||
min-height: 18rem;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
transition:
|
||||
width 180ms ease,
|
||||
min-height 180ms ease;
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 5rem;
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.zoneTable {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.zoneColumn {
|
||||
min-width: 0;
|
||||
border-inline-end: 1px solid var(--mantine-color-default-border);
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.zoneColumn:last-child {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
.zoneColumn[data-drop-target="true"] {
|
||||
background: var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.zoneColumn[data-drop-target="true"] .zoneItems::after {
|
||||
content: "";
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px dashed var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-light) 65%, transparent);
|
||||
}
|
||||
|
||||
.zoneColumnHeader {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 1.75rem;
|
||||
margin: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zoneItems {
|
||||
display: flex;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sortableItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sortableItem[data-drop-target="true"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-block: 0.2rem;
|
||||
inset-inline-start: -0.2rem;
|
||||
z-index: 1;
|
||||
width: 2px;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
background: var(--mantine-primary-color-filled);
|
||||
box-shadow: 0 0 0 2px var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.sortableItem[data-dragging="true"] {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.itemButton,
|
||||
.dragOverlay {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
min-width: 2.25rem;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-text);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent);
|
||||
}
|
||||
|
||||
.itemButton {
|
||||
cursor: grab;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.itemButton[data-wide="true"],
|
||||
.dragOverlay[data-wide="true"] {
|
||||
width: 4.75rem;
|
||||
}
|
||||
|
||||
.itemButton:hover,
|
||||
.itemButton:focus-visible,
|
||||
.sortableItem[data-drop-target="true"] .itemButton,
|
||||
.sortableItem[data-selected="true"] .itemButton {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
background: var(--mantine-primary-color-light-hover);
|
||||
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.itemButton:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dragOverlay {
|
||||
pointer-events: none;
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
}
|
||||
|
||||
.searchPreview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.logoPreview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logoPreview[data-with-title="true"] {
|
||||
justify-content: flex-start;
|
||||
padding-inline: 0.375rem;
|
||||
}
|
||||
|
||||
.logoPreview span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.searchPreview[data-input="true"] {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.375rem;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.searchPreview span {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hiddenPreview {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.floatingAccount {
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
|
||||
.dashboardPreview {
|
||||
height: 13rem;
|
||||
overflow: hidden;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.dashboardLayout {
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.tray {
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.configuration {
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-light) 42%, var(--mantine-color-body));
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
.heading,
|
||||
.tray,
|
||||
.stage {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.stage {
|
||||
min-height: 19rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.device,
|
||||
.zoneColumn,
|
||||
.itemButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
+653
@@ -0,0 +1,653 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PointerActivationConstraints } from "@dnd-kit/dom";
|
||||
import { SortableKeyboardPlugin } from "@dnd-kit/dom/sortable";
|
||||
import type { DragEndEvent } from "@dnd-kit/react";
|
||||
import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor, useDroppable } from "@dnd-kit/react";
|
||||
import { useSortable } from "@dnd-kit/react/sortable";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconHome,
|
||||
IconLayoutDashboard,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconRobot,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconSunMoon,
|
||||
IconUserCircle,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type {
|
||||
HeaderBuiltinItemId,
|
||||
HeaderItem,
|
||||
HeaderPreferences,
|
||||
HeaderZoneId,
|
||||
HeaderZones,
|
||||
} from "@homarr/validation/user";
|
||||
import {
|
||||
createBoardHeaderItem,
|
||||
createBuiltinHeaderItem,
|
||||
getDefaultHeaderZone,
|
||||
getHeaderItemKey,
|
||||
getHeaderItems,
|
||||
getHeaderItemZone,
|
||||
headerBuiltinItemIds,
|
||||
isRequiredHeaderItem,
|
||||
} from "@homarr/validation/user";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { HomarrLogo } from "~/components/layout/logo/homarr-logo";
|
||||
import classes from "./header-composer.module.css";
|
||||
|
||||
interface HeaderComposerProps {
|
||||
value: HeaderPreferences;
|
||||
onChange: (value: HeaderPreferences) => void;
|
||||
boards: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
|
||||
homeBoardId: string | null;
|
||||
mobileHomeBoardId: string | null;
|
||||
}
|
||||
|
||||
interface HeaderItemDragData {
|
||||
kind: "item";
|
||||
itemKey: string;
|
||||
zone: HeaderZoneId;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface HeaderZoneDropData {
|
||||
kind: "zone";
|
||||
zone: HeaderZoneId;
|
||||
}
|
||||
|
||||
type HeaderDropData = HeaderItemDragData | HeaderZoneDropData;
|
||||
|
||||
const headerSortableSensors = [
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 6 })],
|
||||
}),
|
||||
KeyboardSensor,
|
||||
];
|
||||
const headerSortableGroup = "header-composer";
|
||||
const headerSortablePlugins = [SortableKeyboardPlugin];
|
||||
const headerGuideZones = ["left", "center", "right"] as const;
|
||||
|
||||
export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderComposerProps) => {
|
||||
const [draggedItemKey, setDraggedItemKey] = useState<string | null>(null);
|
||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||
const zoneTableRef = useRef<HTMLDivElement>(null);
|
||||
const { branding } = useSettings();
|
||||
const t = useI18n("management.page.user.setting.general.header");
|
||||
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
|
||||
const activeItems = getHeaderItems(value.zones);
|
||||
|
||||
useEffect(() => {
|
||||
const zoneTable = zoneTableRef.current;
|
||||
if (!zoneTable) return;
|
||||
|
||||
const clearSelectionFromEmptyArea = (event: PointerEvent) => {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
if (event.target.closest("[data-header-composer-item]")) return;
|
||||
setSelectedItemKey(null);
|
||||
};
|
||||
|
||||
zoneTable.addEventListener("pointerdown", clearSelectionFromEmptyArea);
|
||||
return () => zoneTable.removeEventListener("pointerdown", clearSelectionFromEmptyArea);
|
||||
}, []);
|
||||
const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey));
|
||||
const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => {
|
||||
const item = createBuiltinHeaderItem(itemId);
|
||||
return !isRequiredHeaderItem(item) && !activeItemKeySet.has(getHeaderItemKey(item));
|
||||
});
|
||||
const availableBoards = boards.filter(
|
||||
(board) => !activeItemKeySet.has(getHeaderItemKey(createBoardHeaderItem(board.id))),
|
||||
);
|
||||
const draggedItem = findHeaderItem(activeItems, draggedItemKey);
|
||||
const selectedItem = findHeaderItem(activeItems, selectedItemKey);
|
||||
const previewBoard = boardsById.get(homeBoardId ?? "") ?? boards.at(0);
|
||||
|
||||
const toggleSelectedItem = (itemKey: string) => {
|
||||
setSelectedItemKey((currentItemKey) => {
|
||||
if (currentItemKey === itemKey) return null;
|
||||
return itemKey;
|
||||
});
|
||||
};
|
||||
|
||||
const itemLabel = (item: HeaderItem) => {
|
||||
if (item.type === "board") return boardsById.get(item.boardId)?.name ?? t("items.boardUnavailable");
|
||||
return t(`items.${item.id}` as never);
|
||||
};
|
||||
|
||||
const updateZones = (zones: HeaderZones) => onChange({ ...value, zones });
|
||||
|
||||
const removeItem = (item: HeaderItem) => {
|
||||
if (isRequiredHeaderItem(item)) return;
|
||||
const zone = getHeaderItemZone(value.zones, item);
|
||||
if (!zone) return;
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
updateZones({
|
||||
...value.zones,
|
||||
[zone]: value.zones[zone].filter((candidate) => getHeaderItemKey(candidate) !== itemKey),
|
||||
});
|
||||
setSelectedItemKey(null);
|
||||
};
|
||||
|
||||
const addItem = (item: HeaderItem) => {
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
if (activeItemKeySet.has(itemKey)) return;
|
||||
const zone = getDefaultHeaderZone(item);
|
||||
updateZones({ ...value.zones, [zone]: [...value.zones[zone], item] });
|
||||
setSelectedItemKey(itemKey);
|
||||
};
|
||||
|
||||
const handleDragEnd = ({ canceled, operation }: DragEndEvent) => {
|
||||
setDraggedItemKey(null);
|
||||
const source = operation.source;
|
||||
const target = operation.target;
|
||||
if (canceled || !source || !target) return;
|
||||
|
||||
const sourceData = source.data as HeaderItemDragData;
|
||||
const targetData = target.data as HeaderDropData;
|
||||
if (sourceData.kind !== "item") return;
|
||||
|
||||
let targetIndex = value.zones[targetData.zone].length;
|
||||
if (targetData.kind === "item") {
|
||||
if (targetData.itemKey === sourceData.itemKey) return;
|
||||
targetIndex = targetData.index;
|
||||
}
|
||||
|
||||
const nextZones = moveHeaderItem(value.zones, sourceData.itemKey, targetData.zone, targetIndex);
|
||||
if (nextZones === value.zones) return;
|
||||
updateZones(nextZones);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={classes.composer} padding={0} radius="lg" withBorder>
|
||||
<Stack gap={0}>
|
||||
<Group className={classes.heading} justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" radius="md" size="lg">
|
||||
<IconLayoutDashboard size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="lg">
|
||||
{t("title")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Tooltip label={value.visible ? t("action.hide") : t("action.show")}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant={value.visible ? "default" : "filled"}
|
||||
aria-label={value.visible ? t("action.hide") : t("action.show")}
|
||||
onClick={() => onChange({ ...value, visible: !value.visible })}
|
||||
>
|
||||
{value.visible ? <IconEye size={18} /> : <IconEyeOff size={18} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box className={classes.stage} data-visible={value.visible}>
|
||||
<Paper className={classes.device} radius="lg" withBorder>
|
||||
{value.visible ? (
|
||||
<DragDropProvider
|
||||
onDragStart={({ operation }) => setDraggedItemKey(operation.source?.id.toString() ?? null)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className={classes.previewHeader}>
|
||||
<div ref={zoneTableRef} className={classes.zoneTable}>
|
||||
{headerGuideZones.map((zone) => (
|
||||
<HeaderZoneColumn key={zone} zone={zone} label={t(`zones.${zone}` as never)}>
|
||||
{value.zones[zone].map((item, index) => (
|
||||
<SortableHeaderItem
|
||||
key={getHeaderItemKey(item)}
|
||||
item={item}
|
||||
zone={zone}
|
||||
index={index}
|
||||
label={itemLabel(item)}
|
||||
searchDisplay={value.searchDisplay}
|
||||
logoDisplay={value.logoDisplay}
|
||||
logoTitle={branding.appName}
|
||||
boardLogo={
|
||||
item.type === "board" ? (boardsById.get(item.boardId)?.logoImageUrl ?? null) : null
|
||||
}
|
||||
configureLabel={t("action.configure", { item: itemLabel(item) })}
|
||||
selected={selectedItemKey === getHeaderItemKey(item)}
|
||||
onSelect={toggleSelectedItem}
|
||||
/>
|
||||
))}
|
||||
</HeaderZoneColumn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedItem ? (
|
||||
<HeaderItemPreview
|
||||
item={draggedItem}
|
||||
label={itemLabel(draggedItem)}
|
||||
compact={false}
|
||||
searchDisplay={value.searchDisplay}
|
||||
logoDisplay={value.logoDisplay}
|
||||
logoTitle={branding.appName}
|
||||
boardLogo={
|
||||
draggedItem.type === "board" ? boardsById.get(draggedItem.boardId)?.logoImageUrl : null
|
||||
}
|
||||
overlay
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
) : (
|
||||
<div className={classes.hiddenPreview}>
|
||||
<ThemeIcon className={classes.floatingAccount} radius="xl" size="xl" aria-label={t("items.user")}>
|
||||
<IconUserCircle size={22} />
|
||||
</ThemeIcon>
|
||||
</div>
|
||||
)}
|
||||
<div className={classes.dashboardPreview}>
|
||||
<BoardLayoutThumbnail
|
||||
preview={previewBoard?.preview ?? null}
|
||||
label={previewBoard ? t("preview.board", { name: previewBoard.name }) : t("preview.empty")}
|
||||
className={classes.dashboardLayout}
|
||||
/>
|
||||
</div>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Stack className={classes.tray} gap="sm">
|
||||
{selectedItem ? (
|
||||
<Paper className={classes.configuration} p="sm" radius="md" withBorder>
|
||||
<Group justify="space-between" gap="sm" wrap="wrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={650} size="sm">
|
||||
{itemLabel(selectedItem)}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("configuration.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
{selectedItem.type === "builtin" && selectedItem.id === "search" ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={value.searchDisplay}
|
||||
onChange={(searchDisplay) =>
|
||||
onChange({ ...value, searchDisplay: searchDisplay as HeaderPreferences["searchDisplay"] })
|
||||
}
|
||||
data={[
|
||||
{ value: "input", label: t("configuration.searchDisplay.input") },
|
||||
{ value: "icon", label: t("configuration.searchDisplay.icon") },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{selectedItem.type === "builtin" && selectedItem.id === "logo" ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={value.logoDisplay}
|
||||
onChange={(logoDisplay) =>
|
||||
onChange({ ...value, logoDisplay: logoDisplay as HeaderPreferences["logoDisplay"] })
|
||||
}
|
||||
data={[
|
||||
{ value: "logo", label: t("configuration.logoDisplay.logo") },
|
||||
{ value: "logoAndText", label: t("configuration.logoDisplay.logoAndText") },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{!isRequiredHeaderItem(selectedItem) ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => removeItem(selectedItem)}
|
||||
>
|
||||
{t("action.remove", { item: itemLabel(selectedItem) })}
|
||||
</Button>
|
||||
) : (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("available.requiredAlways")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Stack gap={0}>
|
||||
<Text fw={650} size="sm">
|
||||
{t("available.title")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("available.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
disabled={availableBoards.length === 0}
|
||||
>
|
||||
{t("available.boardShortcut")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("available.chooseBoard")}</Menu.Label>
|
||||
{availableBoards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
leftSection={
|
||||
<Avatar src={board.logoImageUrl} size="xs" radius="sm">
|
||||
<IconLayoutDashboard size={14} />
|
||||
</Avatar>
|
||||
}
|
||||
onClick={() => addItem(createBoardHeaderItem(board.id))}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{inactiveBuiltinIds.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("available.allAdded")}
|
||||
</Text>
|
||||
) : (
|
||||
inactiveBuiltinIds.map((itemId) => {
|
||||
const item = createBuiltinHeaderItem(itemId);
|
||||
const Icon = getBuiltinItemIcon(itemId);
|
||||
return (
|
||||
<Tooltip key={itemId} label={t("available.add", { item: itemLabel(item) })}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="default"
|
||||
aria-label={t("available.add", { item: itemLabel(item) })}
|
||||
onClick={() => addItem(item)}
|
||||
>
|
||||
<Icon size={19} stroke={1.7} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface HeaderZoneColumnProps {
|
||||
zone: HeaderZoneId;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const HeaderZoneColumn = ({ zone, label, children }: HeaderZoneColumnProps) => {
|
||||
const { isDropTarget, ref } = useDroppable({
|
||||
id: `header-zone:${zone}`,
|
||||
data: { kind: "zone", zone } satisfies HeaderZoneDropData,
|
||||
});
|
||||
|
||||
return (
|
||||
<section ref={ref} className={classes.zoneColumn} data-drop-target={isDropTarget || undefined}>
|
||||
<Text className={classes.zoneColumnHeader} component="h4">
|
||||
{label}
|
||||
</Text>
|
||||
<div className={classes.zoneItems}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface SortableHeaderItemProps {
|
||||
item: HeaderItem;
|
||||
zone: HeaderZoneId;
|
||||
index: number;
|
||||
label: string;
|
||||
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||
logoTitle: string;
|
||||
boardLogo: string | null;
|
||||
configureLabel: string;
|
||||
selected: boolean;
|
||||
onSelect: (itemKey: string) => void;
|
||||
}
|
||||
|
||||
const SortableHeaderItem = ({
|
||||
item,
|
||||
zone,
|
||||
index,
|
||||
label,
|
||||
searchDisplay,
|
||||
logoDisplay,
|
||||
logoTitle,
|
||||
boardLogo,
|
||||
configureLabel,
|
||||
selected,
|
||||
onSelect,
|
||||
}: SortableHeaderItemProps) => {
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
const { handleRef, isDragging, isDropTarget, ref } = useSortable({
|
||||
id: itemKey,
|
||||
index,
|
||||
group: `${headerSortableGroup}:${zone}`,
|
||||
data: { kind: "item", itemKey, zone, index } satisfies HeaderItemDragData,
|
||||
sensors: headerSortableSensors,
|
||||
plugins: headerSortablePlugins,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes.sortableItem}
|
||||
data-dragging={isDragging || undefined}
|
||||
data-drop-target={isDropTarget || undefined}
|
||||
data-entry-id={itemKey}
|
||||
data-selected={selected || undefined}
|
||||
>
|
||||
<button
|
||||
ref={handleRef}
|
||||
type="button"
|
||||
className={classes.itemButton}
|
||||
data-header-composer-item
|
||||
data-kind={item.type === "builtin" ? item.id : "board"}
|
||||
data-wide={
|
||||
(item.type === "builtin" &&
|
||||
((item.id === "search" && searchDisplay === "input") ||
|
||||
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
|
||||
undefined
|
||||
}
|
||||
aria-label={configureLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect(itemKey);
|
||||
}}
|
||||
>
|
||||
<HeaderItemPreview
|
||||
item={item}
|
||||
label={label}
|
||||
compact={false}
|
||||
searchDisplay={searchDisplay}
|
||||
logoDisplay={logoDisplay}
|
||||
logoTitle={logoTitle}
|
||||
boardLogo={boardLogo}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HeaderItemPreview = ({
|
||||
item,
|
||||
label,
|
||||
compact,
|
||||
searchDisplay,
|
||||
logoDisplay,
|
||||
logoTitle,
|
||||
boardLogo,
|
||||
overlay = false,
|
||||
}: {
|
||||
item: HeaderItem;
|
||||
label: string;
|
||||
compact: boolean;
|
||||
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||
logoTitle: string;
|
||||
boardLogo: string | null | undefined;
|
||||
overlay?: boolean;
|
||||
}) => {
|
||||
const content = (
|
||||
<PreviewItem
|
||||
item={item}
|
||||
label={label}
|
||||
compact={compact}
|
||||
searchDisplay={searchDisplay}
|
||||
logoDisplay={logoDisplay}
|
||||
logoTitle={logoTitle}
|
||||
boardLogo={boardLogo}
|
||||
/>
|
||||
);
|
||||
if (!overlay) return content;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.dragOverlay}
|
||||
data-kind={item.type === "builtin" ? item.id : "board"}
|
||||
data-wide={
|
||||
(item.type === "builtin" &&
|
||||
!compact &&
|
||||
((item.id === "search" && searchDisplay === "input") ||
|
||||
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PreviewItem = ({
|
||||
item,
|
||||
label,
|
||||
compact,
|
||||
searchDisplay,
|
||||
logoDisplay,
|
||||
logoTitle,
|
||||
boardLogo,
|
||||
}: {
|
||||
item: HeaderItem;
|
||||
label: string;
|
||||
compact: boolean;
|
||||
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||
logoTitle: string;
|
||||
boardLogo: string | null | undefined;
|
||||
}) => {
|
||||
if (item.type === "board") {
|
||||
return (
|
||||
<Avatar src={boardLogo} size={22} radius="sm">
|
||||
<IconLayoutDashboard size={17} />
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "logo") {
|
||||
return (
|
||||
<div className={classes.logoPreview} data-with-title={logoDisplay === "logoAndText" || undefined}>
|
||||
<HomarrLogo size={26} />
|
||||
{logoDisplay === "logoAndText" ? <span>{logoTitle}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "search") {
|
||||
const showInput = searchDisplay === "input" && !compact;
|
||||
return (
|
||||
<div className={classes.searchPreview} data-input={showInput || undefined}>
|
||||
<IconSearch size={18} />
|
||||
{showInput ? <span>{label}…</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = getBuiltinItemIcon(item.id);
|
||||
return <Icon size={20} stroke={1.7} />;
|
||||
};
|
||||
|
||||
const moveHeaderItem = (
|
||||
zones: HeaderZones,
|
||||
itemKey: string,
|
||||
targetZone: HeaderZoneId,
|
||||
requestedTargetIndex: number,
|
||||
): HeaderZones => {
|
||||
let sourceItem: HeaderItem | undefined;
|
||||
|
||||
for (const zone of headerGuideZones) {
|
||||
sourceItem = zones[zone].find((item) => getHeaderItemKey(item) === itemKey);
|
||||
if (sourceItem) break;
|
||||
}
|
||||
|
||||
if (!sourceItem) return zones;
|
||||
|
||||
const nextZones: HeaderZones = {
|
||||
left: zones.left.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||
center: zones.center.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||
right: zones.right.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||
};
|
||||
const targetItems = nextZones[targetZone];
|
||||
const targetIndex = Math.max(0, Math.min(requestedTargetIndex, targetItems.length));
|
||||
nextZones[targetZone] = [...targetItems.slice(0, targetIndex), sourceItem, ...targetItems.slice(targetIndex)];
|
||||
return nextZones;
|
||||
};
|
||||
|
||||
const findHeaderItem = (items: HeaderItem[], id: string | null): HeaderItem | undefined => {
|
||||
if (id === null) return undefined;
|
||||
return items.find((item) => getHeaderItemKey(item) === id);
|
||||
};
|
||||
|
||||
const getBuiltinItemIcon = (itemId: HeaderBuiltinItemId) => {
|
||||
if (itemId === "search") return IconSearch;
|
||||
if (itemId === "home") return IconHome;
|
||||
if (itemId === "boardSwitcher") return IconReplace;
|
||||
if (itemId === "assistant") return IconRobot;
|
||||
if (itemId === "docker") return IconBrandDocker;
|
||||
if (itemId === "boardEdit") return IconPencil;
|
||||
if (itemId === "boardSettings") return IconSettings;
|
||||
if (itemId === "settings") return IconSettings;
|
||||
if (itemId === "themeToggle") return IconSunMoon;
|
||||
if (itemId === "user") return IconUserCircle;
|
||||
return IconLayoutDashboard;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Alert, Box, Group, Stack, Title } from "@mantine/core";
|
||||
import { Alert, Stack, Title } from "@mantine/core";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -8,7 +8,6 @@ import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||
@@ -38,7 +37,7 @@ export async function generateMetadata(props: Props) {
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
title: createMetaTitle(t("metaTitle", { username: user.name! })),
|
||||
title: t("metaTitle", { username: user.name! }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +57,7 @@ export default async function EditUserPage(props: Props) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const boards = await api.board.getAllBoards();
|
||||
const boards = await api.board.getManageOverview({ fullPreview: false, userId: user.id });
|
||||
const searchEngines = await api.searchEngine.getSelectable();
|
||||
const isSelf = session?.user.id === user.id;
|
||||
const isCredentialsUser = user.provider === "credentials";
|
||||
@@ -71,23 +70,18 @@ export default async function EditUserPage(props: Props) {
|
||||
</Alert>
|
||||
)}
|
||||
<Title>{tGeneral("title")}</Title>
|
||||
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||
<UserGeneralSettingsForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
searchEnginesData={searchEngines}
|
||||
showLanguageSelector={isSelf}
|
||||
/>
|
||||
</Box>
|
||||
<Box w={{ base: "100%", lg: 260 }}>
|
||||
<UserProfileAvatarForm user={user} />
|
||||
</Box>
|
||||
</Group>
|
||||
<UserGeneralSettingsForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
preview: board.preview,
|
||||
}))}
|
||||
searchEnginesData={searchEngines}
|
||||
showLanguageSelector={isSelf}
|
||||
profileAvatar={<UserProfileAvatarForm user={user} />}
|
||||
/>
|
||||
|
||||
{session?.user.id === user.id && (
|
||||
<Stack mb="lg">
|
||||
|
||||
@@ -8,7 +8,6 @@ import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -22,7 +21,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.user.create");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Link } from "@homarr/ui";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserListComponent } from "./_components/user-list";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -20,7 +19,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.user.list");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
|
||||
|
||||
import { use, useCallback, useState } from "react";
|
||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||
@@ -71,6 +73,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
),
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
previewDimensions: dimensions,
|
||||
},
|
||||
{
|
||||
title(translate) {
|
||||
@@ -86,10 +89,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
} finally {
|
||||
setIsEditorLoading(false);
|
||||
}
|
||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
}, [currentDefinition, dimensions, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((editMode) => !editMode);
|
||||
setEditMode((currentEditMode) => !currentEditMode);
|
||||
showSuccessNotification({
|
||||
message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Suspense } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
|
||||
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||
import { BoardPreviewProvider } from "@homarr/boards/context";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { widgetKinds } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
@@ -16,12 +22,24 @@ export default async function WidgetPreview(props: Props) {
|
||||
if (!widgetKinds.includes(kind as WidgetKind)) {
|
||||
notFound();
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
redirect(`/auth/login?callbackUrl=${encodeURIComponent(`/widgets/${kind}`)}`);
|
||||
}
|
||||
|
||||
const [integrations, board] = await Promise.all([getIntegrationsWithPermissionsAsync(session), getHomeBoardAsync()]);
|
||||
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Suspense fallback={<Loader size="sm" />}>
|
||||
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||
</Suspense>
|
||||
</Center>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<BoardPreviewProvider board={board}>
|
||||
<ModalProvider>
|
||||
<Center h="100vh">
|
||||
<Suspense fallback={<Loader size="sm" />}>
|
||||
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||
</Suspense>
|
||||
</Center>
|
||||
</ModalProvider>
|
||||
</BoardPreviewProvider>
|
||||
</IntegrationProvider>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
|
||||
import type { ToolSet, UIMessage } from "ai";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
assistantProviderRequiresApiKey,
|
||||
assistantReasoningModes,
|
||||
} from "@homarr/definitions";
|
||||
import { localeCookieKey } from "@homarr/definitions/cookie";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { fallbackLocale, isLocaleSupported } from "@homarr/translation";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
||||
@@ -370,7 +375,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return Response.json({ error: "Sign in to use Homarr Assistant." }, { status: 401 });
|
||||
return Response.json({ error: "Sign in to use Assistant." }, { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.text();
|
||||
@@ -414,7 +419,11 @@ export async function POST(request: Request) {
|
||||
|
||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
|
||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
||||
const requestedLocale = (await cookies()).get(localeCookieKey)?.value;
|
||||
let locale: SupportedLanguage = fallbackLocale;
|
||||
if (requestedLocale && isLocaleSupported(requestedLocale)) locale = requestedLocale;
|
||||
const t = await getI18n({ locale, namespace: "assistant.unavailable" });
|
||||
return Response.json({ error: t("notConfigured") }, { status: 503 });
|
||||
}
|
||||
|
||||
let homarrProviderToken: string | null | undefined;
|
||||
|
||||
@@ -1,39 +1,59 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||
const serverSettings = await getRscServerSettingsAsync();
|
||||
const branding = serverSettings.branding;
|
||||
const appIcon = branding.logoImageUrl ?? branding.faviconImageUrl;
|
||||
const icons: NonNullable<MetadataRoute.Manifest["icons"]> = [
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
];
|
||||
|
||||
if (appIcon) {
|
||||
icons.unshift({
|
||||
src: appIcon,
|
||||
sizes: "any",
|
||||
purpose: "any",
|
||||
});
|
||||
} else {
|
||||
icons.unshift(
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Homarr",
|
||||
short_name: "Homarr",
|
||||
name: branding.appName,
|
||||
short_name: branding.appName,
|
||||
description: "Your dashboard for managing your server.",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#fff",
|
||||
theme_color: "#fff",
|
||||
icons: [
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
theme_color: branding.primaryColor,
|
||||
icons,
|
||||
};
|
||||
}
|
||||
@@ -220,7 +220,7 @@ const renderMessage = (message: AssistantConversationExportMessage, index: numbe
|
||||
|
||||
export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
|
||||
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
|
||||
return `# ${title}\n\n> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
|
||||
return `# ${title}\n\n> Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
|
||||
};
|
||||
|
||||
export const buildAssistantConversationMarkdown = ({
|
||||
@@ -233,7 +233,7 @@ export const buildAssistantConversationMarkdown = ({
|
||||
const header = [
|
||||
`# ${title}`,
|
||||
"",
|
||||
"> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
|
||||
"> Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
|
||||
"",
|
||||
`- Conversation ID: \`${thread.id}\``,
|
||||
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
|
||||
|
||||
@@ -25,7 +25,6 @@ const EnabledAssistantRoot = dynamic(() =>
|
||||
|
||||
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
|
||||
useRegisterAssistantSpotlightPlaceholder(description);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
enabled: false,
|
||||
|
||||
@@ -184,10 +184,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.widgetPanel[data-editing] .panelActions {
|
||||
padding-inline-end: 2.75rem;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
min-width: 0;
|
||||
min-height: 3.75rem;
|
||||
|
||||
@@ -32,7 +32,7 @@ import { showErrorNotification, showWarningNotification } from "@homarr/notifica
|
||||
import { closeSpotlight } from "@homarr/spotlight/store";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets/assistant/context";
|
||||
import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
||||
|
||||
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||
@@ -856,6 +856,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
: t("busy.description"),
|
||||
unavailable: !canSend,
|
||||
alwaysVisible: true,
|
||||
placement: "fallback" as const,
|
||||
interaction: (query: string) =>
|
||||
createAssistantPromptInteraction({
|
||||
sendPrompt,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const useRegisterAssistantSpotlightPlaceholder = (description: string) =>
|
||||
description,
|
||||
unavailable: true,
|
||||
alwaysVisible: true,
|
||||
placement: "fallback" as const,
|
||||
interaction: (_query: string) => ({ type: "none" as const }),
|
||||
}),
|
||||
[description, t],
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
|
||||
test("preserves generated notebook content and fills its hidden defaults", () => {
|
||||
const result = getAssistantWidgetConfiguration(
|
||||
widgetImports.notebook.definition,
|
||||
{
|
||||
boardId: "board-1",
|
||||
boardName: "Home",
|
||||
@@ -41,6 +42,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
|
||||
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
||||
const result = getAssistantWidgetConfiguration(
|
||||
widgetImports.audioStats.definition,
|
||||
{
|
||||
boardId: "board-1",
|
||||
boardName: "Home",
|
||||
@@ -88,11 +90,13 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
|
||||
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
||||
const mediaServer = getAssistantWidgetConfiguration(
|
||||
widgetImports.mediaServer.definition,
|
||||
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
||||
settings,
|
||||
[],
|
||||
);
|
||||
const calendar = getAssistantWidgetConfiguration(
|
||||
widgetImports.calendar.definition,
|
||||
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
||||
settings,
|
||||
[],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Suspense, use, useMemo } from "react";
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -13,7 +15,8 @@ import { useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
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 { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
@@ -29,11 +32,11 @@ interface AssistantIntegration extends IntegrationSelectOption {
|
||||
}
|
||||
|
||||
export const getAssistantWidgetConfiguration = (
|
||||
definition: WidgetDefinition,
|
||||
args: ConfigureWidgetArgs,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
integrations: AssistantIntegration[],
|
||||
) => {
|
||||
const definition = widgetImports[args.kind].definition;
|
||||
const integrationSupport = "supportedIntegrations" in definition;
|
||||
const supportedIntegrations = integrationSupport
|
||||
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
||||
@@ -54,35 +57,91 @@ export const getAssistantWidgetConfiguration = (
|
||||
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: reduceWidgetOptionsWithDefaultValues(args.kind, settings, args.options),
|
||||
options: reduceWidgetOptionsWithDefinition(definition, settings, args.options),
|
||||
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,
|
||||
result,
|
||||
addResult,
|
||||
status,
|
||||
toolCallId,
|
||||
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
|
||||
definition,
|
||||
}: AssistantConfigureWidgetToolProps & { definition: WidgetDefinition | undefined }) => {
|
||||
const t = useI18n("assistant.configureWidget");
|
||||
const actionT = useI18n("common.action");
|
||||
const fullT = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const settings = useSettings();
|
||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
||||
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
|
||||
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
|
||||
const integrations = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
|
||||
retry: false,
|
||||
});
|
||||
const configuration = useMemo(() => {
|
||||
if (!args || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
||||
return getAssistantWidgetConfiguration(args, settings, integrations.data ?? []);
|
||||
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||
if (!args || !definition || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
||||
return getAssistantWidgetConfiguration(definition, args, settings, integrations.data ?? []);
|
||||
}, [args, definition, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||
const missingRequiredIntegration =
|
||||
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
||||
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
||||
@@ -134,15 +193,7 @@ export const AssistantConfigureWidgetTool = ({
|
||||
}
|
||||
|
||||
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
|
||||
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>
|
||||
);
|
||||
return <AssistantConfigureWidgetSkeleton />;
|
||||
}
|
||||
|
||||
if (integrations.isError) {
|
||||
@@ -158,7 +209,7 @@ export const AssistantConfigureWidgetTool = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!configuration) return null;
|
||||
if (!configuration || !definition) return null;
|
||||
|
||||
const openConfigurationModal = (
|
||||
value = configuration.value,
|
||||
@@ -167,7 +218,7 @@ export const AssistantConfigureWidgetTool = ({
|
||||
openWidgetModal(
|
||||
{
|
||||
kind: args.kind,
|
||||
definition: widgetImports[args.kind].definition,
|
||||
definition,
|
||||
value,
|
||||
integrationData,
|
||||
integrationSupport: configuration.integrationSupport,
|
||||
@@ -240,7 +291,7 @@ export const AssistantConfigureWidgetTool = ({
|
||||
);
|
||||
}
|
||||
|
||||
const WidgetIcon = widgetImports[args.kind].definition.icon;
|
||||
const WidgetIcon = definition.icon;
|
||||
return (
|
||||
<Box className={classes.appTool}>
|
||||
<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;
|
||||
};
|
||||
@@ -5,10 +5,10 @@
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 15% 10%,
|
||||
linear-gradient(
|
||||
to top,
|
||||
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
|
||||
transparent 35%
|
||||
transparent 55%
|
||||
),
|
||||
var(--mantine-color-body);
|
||||
}
|
||||
|
||||
@@ -12,15 +12,22 @@ interface BoardLayoutThumbnailProps {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
fitFullLayout?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const maxThumbnailRows = 12;
|
||||
|
||||
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
|
||||
export const BoardLayoutThumbnail = ({
|
||||
preview,
|
||||
label,
|
||||
fitFullLayout = false,
|
||||
className,
|
||||
}: BoardLayoutThumbnailProps) => {
|
||||
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||
if (!preview || !layout) {
|
||||
return (
|
||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
||||
<Center className={canvasClassName} role="img" aria-label={label}>
|
||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||
</Center>
|
||||
);
|
||||
@@ -38,7 +45,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
||||
});
|
||||
if (lanes.length === 0) {
|
||||
return (
|
||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
||||
<Center className={canvasClassName} role="img" aria-label={label}>
|
||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||
</Center>
|
||||
);
|
||||
@@ -55,7 +62,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={classes.canvas} role="img" aria-label={label}>
|
||||
<Box className={canvasClassName} role="img" aria-label={label}>
|
||||
<div
|
||||
className={classes.lanes}
|
||||
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
@@ -35,12 +36,19 @@ import classes from "./board-switcher.module.css";
|
||||
|
||||
export const boardSwitcherHotkey = "shift+c";
|
||||
|
||||
export interface BoardSwitcherControls {
|
||||
open: () => void;
|
||||
preload: () => void;
|
||||
hotkey: string;
|
||||
}
|
||||
|
||||
interface BoardSwitcherProps {
|
||||
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
|
||||
children: (controls: BoardSwitcherControls) => ReactNode;
|
||||
}
|
||||
|
||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
const t = useI18n("board.action.switcher");
|
||||
const tBoard = useI18n("board");
|
||||
const manageBoardsT = useI18n("management.page.board");
|
||||
const currentBoard = useOptionalBoard();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -85,6 +93,20 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
}, []);
|
||||
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 }]]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
.badge {
|
||||
@mixin dark {
|
||||
--background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity));
|
||||
--border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity));
|
||||
}
|
||||
@mixin light {
|
||||
--background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity));
|
||||
--border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity));
|
||||
}
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
.refreshIndicator {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settingsButton {
|
||||
position: absolute;
|
||||
top: -34px;
|
||||
right: 14px;
|
||||
z-index: 201;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
@@ -58,3 +63,11 @@
|
||||
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 { createPortal } from "react-dom";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
import { Badge, Box, Button, Card, Center, Loader, Portal } from "@mantine/core";
|
||||
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
||||
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 { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
@@ -20,13 +19,16 @@ import { WidgetError } from "@homarr/widgets/errors";
|
||||
import {
|
||||
createWidgetRuntimeState,
|
||||
getWidgetQueryKeys,
|
||||
getWidgetRuntimeQueries,
|
||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||
} from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
import { WidgetCardShell, WidgetTitleBadge } from "@homarr/widgets/widget-card-shell";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
|
||||
@@ -34,11 +36,11 @@ import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced
|
||||
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
||||
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
||||
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
@@ -48,47 +50,28 @@ interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
}
|
||||
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
|
||||
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
|
||||
if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" } as const;
|
||||
if (hasCustomCssClasses) return {};
|
||||
return { overflowX: "hidden", overflowY: "auto" } as const;
|
||||
};
|
||||
|
||||
type BoardItemCardProps = CardProps & {
|
||||
type BoardItemCardProps = PropsWithChildren<{
|
||||
item: SectionItem;
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
};
|
||||
}>;
|
||||
|
||||
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
||||
const BoardItemCard = ({ item, innerRef, children }: BoardItemCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...cardProps}
|
||||
ref={innerRef}
|
||||
<WidgetCardShell
|
||||
innerRef={innerRef}
|
||||
kind={item.kind}
|
||||
advancedOptions={item.advancedOptions}
|
||||
opacity={board.opacity / 100}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-grid-item-content
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
</WidgetCardShell>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -167,6 +150,7 @@ const LoadedBoardItemContent = ({
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const previewEntranceFrameRef = useRef<number | null>(null);
|
||||
const board = useRequiredBoard();
|
||||
const boardCanvasScale = useBoardCanvasScale();
|
||||
const t = useI18n();
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
@@ -174,12 +158,34 @@ const LoadedBoardItemContent = ({
|
||||
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
||||
[definition, item, settings],
|
||||
);
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||
const isWidgetRefreshing =
|
||||
useIsFetching({
|
||||
predicate: (query) =>
|
||||
query.state.data !== undefined &&
|
||||
matchesWidgetItemQuery(
|
||||
query.queryKey,
|
||||
widgetQueryKeys,
|
||||
{
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
integrationIds: item.integrationIds,
|
||||
options: menuItem.options,
|
||||
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
||||
},
|
||||
definition.queryMatcher,
|
||||
),
|
||||
}) > 0;
|
||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||
const { width: viewportWidth, height: viewportHeight } = viewportSize;
|
||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
||||
const widgetName = getWidgetName(item.kind, t);
|
||||
const previewDimensions = useMemo(
|
||||
() => ({ width, height, scale: boardCanvasScale }),
|
||||
[boardCanvasScale, height, width],
|
||||
);
|
||||
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
||||
const advancedViewId = `advanced-focus-${item.id}`;
|
||||
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
||||
@@ -291,8 +297,11 @@ const LoadedBoardItemContent = ({
|
||||
: undefined;
|
||||
|
||||
const widgetCard = (
|
||||
<Card
|
||||
ref={cardRef}
|
||||
<WidgetCardShell
|
||||
innerRef={cardRef}
|
||||
kind={item.kind}
|
||||
advancedOptions={item.advancedOptions}
|
||||
opacity={isAdvanced ? 0.98 : board.opacity / 100}
|
||||
id={isPreview ? advancedViewId : undefined}
|
||||
role={isPreview ? "region" : undefined}
|
||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||
@@ -303,21 +312,9 @@ const LoadedBoardItemContent = ({
|
||||
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
isPreview && advancedFocusClasses.surface,
|
||||
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
|
||||
containerType: "size",
|
||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
style={previewStyle}
|
||||
>
|
||||
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
||||
@@ -345,15 +342,21 @@ const LoadedBoardItemContent = ({
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
</Card>
|
||||
{isWidgetRefreshing && (
|
||||
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
|
||||
<Loader size="xs" />
|
||||
</Box>
|
||||
)}
|
||||
</WidgetCardShell>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} />}
|
||||
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} previewDimensions={previewDimensions} />}
|
||||
<WidgetContextMenu
|
||||
item={item}
|
||||
definition={definition}
|
||||
previewDimensions={previewDimensions}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
sourceRef={sourceRef}
|
||||
@@ -418,25 +421,12 @@ const LoadedBoardItemContent = ({
|
||||
)}
|
||||
</Box>
|
||||
</WidgetContextMenu>
|
||||
{!isAdvanced && item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
style={{ zIndex: "var(--mantine-z-index-app)" }}
|
||||
top={2}
|
||||
left={16}
|
||||
size="xs"
|
||||
{!isAdvanced && (
|
||||
<WidgetTitleBadge
|
||||
advancedOptions={item.advancedOptions}
|
||||
opacity={board.opacity / 100}
|
||||
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 {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
previewDimensions: { width: number; height: number; scale?: number };
|
||||
resetErrorBoundary?: () => void;
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
|
||||
return <BoardItemMenuInner {...props} />;
|
||||
};
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
@@ -89,6 +90,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
previewDimensions,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
@@ -106,10 +108,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 30 }}
|
||||
className={itemContentClasses.settingsButton}
|
||||
data-menu-open={isMenuOpen || undefined}
|
||||
data-board-widget-settings
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
getIntegrationName,
|
||||
getWidgetName,
|
||||
getRootSectionLane,
|
||||
widgetDefaultSizes,
|
||||
widgetIntegrationSupport,
|
||||
widgetKinds,
|
||||
widgetKindsWithOptionalIntegrations,
|
||||
@@ -47,6 +48,7 @@ import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { getSectionGridColumnCount } from "../sections/grid/section-grid-placements";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||
import {
|
||||
@@ -76,6 +78,8 @@ export interface WidgetItemData {
|
||||
}
|
||||
|
||||
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
||||
const defaultWidgetSize = { width: 1, height: 1 };
|
||||
const maximumWidgetHeight = 64;
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
@@ -124,6 +128,13 @@ const ItemSelectModalContent = ({
|
||||
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
|
||||
? targetSectionId
|
||||
: (placementOptions[0]?.value ?? null);
|
||||
const maximumWidgetSize = useMemo(() => {
|
||||
if (!effectiveSectionId) return defaultWidgetSize;
|
||||
return {
|
||||
width: Math.max(1, getSectionGridColumnCount(board, currentLayoutId, effectiveSectionId) ?? 1),
|
||||
height: maximumWidgetHeight,
|
||||
};
|
||||
}, [board, currentLayoutId, effectiveSectionId]);
|
||||
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
@@ -246,6 +257,7 @@ const ItemSelectModalContent = ({
|
||||
try {
|
||||
const definition = await loadWidgetDefinition("customApi");
|
||||
const itemId = createId();
|
||||
const initialSize = widgetDefaultSizes.customApi ?? defaultWidgetSize;
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
@@ -263,7 +275,7 @@ const ItemSelectModalContent = ({
|
||||
options,
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }, selectedSize) => {
|
||||
const updatedBoard = createItem({
|
||||
id: itemId,
|
||||
kind: "customApi",
|
||||
@@ -271,12 +283,14 @@ const ItemSelectModalContent = ({
|
||||
options: configuredOptions,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
size: selectedSize ?? initialSize,
|
||||
});
|
||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
previewResize: { initialSize, maximumSize: maximumWidgetSize },
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||
@@ -311,6 +325,7 @@ const ItemSelectModalContent = ({
|
||||
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
const initialSize = widgetDefaultSizes[kind] ?? defaultWidgetSize;
|
||||
const openEditor = (
|
||||
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
|
||||
) => {
|
||||
@@ -326,7 +341,7 @@ const ItemSelectModalContent = ({
|
||||
options: defaultOptions,
|
||||
integrationIds: selectedIntegrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }, selectedSize) => {
|
||||
const updatedBoard = createItem({
|
||||
id: itemId,
|
||||
kind,
|
||||
@@ -334,6 +349,7 @@ const ItemSelectModalContent = ({
|
||||
integrationIds: newIntegrationIds,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
size: selectedSize ?? initialSize,
|
||||
});
|
||||
if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return;
|
||||
trackSetup("widget-completed", {
|
||||
@@ -346,6 +362,7 @@ const ItemSelectModalContent = ({
|
||||
integrationData: availableIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
previewResize: { initialSize, maximumSize: maximumWidgetSize },
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
@@ -7,16 +8,49 @@ import type { FallbackProps } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
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 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 { AssistantContext, useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
||||
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
||||
|
||||
const defaultWidgetSize = { width: 1, height: 1 };
|
||||
const ignoreAssistantAction = () => undefined;
|
||||
const ignoreAssistantPrompt = () => false;
|
||||
const ignoreAssistantRefresh = () => Promise.resolve();
|
||||
const getPreviewDimensions = (size: WidgetEditModalSize) => ({
|
||||
width: getLogicalTrackSize(size.width),
|
||||
height: getLogicalTrackSize(size.height),
|
||||
});
|
||||
|
||||
const PreviewRuntimeWrapper = ({ children }: PropsWithChildren) => {
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const previewAssistant = useMemo(() => {
|
||||
if (!assistant) return null;
|
||||
return {
|
||||
...assistant,
|
||||
open: ignoreAssistantAction,
|
||||
close: ignoreAssistantAction,
|
||||
toggle: ignoreAssistantAction,
|
||||
sendPrompt: ignoreAssistantPrompt,
|
||||
refreshCurrentView: ignoreAssistantRefresh,
|
||||
setWidgetVisible: ignoreAssistantAction,
|
||||
activateWidget: ignoreAssistantAction,
|
||||
};
|
||||
}, [assistant]);
|
||||
|
||||
if (!previewAssistant) return children;
|
||||
return <AssistantContext.Provider value={previewAssistant}>{children}</AssistantContext.Provider>;
|
||||
};
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
||||
|
||||
export const loadWidgetEditModal = () => {
|
||||
@@ -83,6 +117,7 @@ interface LazyWidgetEditModalContentProps {
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const { default: PreviewComponent } = use(loadWidgetComponent(props.innerProps.kind));
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const Component = WidgetEditModal.component;
|
||||
const utils = clientApi.useUtils();
|
||||
@@ -114,6 +149,18 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
||||
return [...existing, ...additional];
|
||||
}, [props.innerProps.integrationData, createdIntegrations]);
|
||||
const previewDimensions = useMemo(() => {
|
||||
if (props.innerProps.previewDimensions) return props.innerProps.previewDimensions;
|
||||
const size = widgetDefaultSizes[props.innerProps.kind] ?? defaultWidgetSize;
|
||||
return {
|
||||
width: getLogicalTrackSize(size.width),
|
||||
height: getLogicalTrackSize(size.height),
|
||||
};
|
||||
}, [props.innerProps.kind, props.innerProps.previewDimensions]);
|
||||
let previewResize = props.innerProps.previewResize;
|
||||
if (previewResize) {
|
||||
previewResize = { ...previewResize, getDimensions: getPreviewDimensions };
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
@@ -122,6 +169,10 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
...props.innerProps,
|
||||
integrationData: combinedIntegrationData,
|
||||
integrationEditForm: LazyIntegrationEditForm,
|
||||
previewComponent: PreviewComponent,
|
||||
previewDimensions,
|
||||
previewResize,
|
||||
previewWrapper: PreviewRuntimeWrapper,
|
||||
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||
}}
|
||||
/>
|
||||
@@ -157,6 +208,10 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
size: "100rem",
|
||||
transitionProps: {
|
||||
transition: "pop",
|
||||
duration: 180,
|
||||
},
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
previewDimensions: { width: number; height: number; scale?: number };
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
sourceRef: RefObject<HTMLElement | null>;
|
||||
@@ -44,6 +45,7 @@ interface WidgetContextMenuProps {
|
||||
export const WidgetContextMenu = ({
|
||||
item,
|
||||
definition,
|
||||
previewDimensions,
|
||||
widgetRuntimeRef,
|
||||
sourceRef,
|
||||
children,
|
||||
@@ -160,7 +162,7 @@ export const WidgetContextMenu = ({
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: (editResult) => {
|
||||
@@ -189,6 +191,9 @@ export const WidgetContextMenu = ({
|
||||
),
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
previewDimensions,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -197,12 +202,15 @@ export const WidgetContextMenu = ({
|
||||
},
|
||||
);
|
||||
}, [
|
||||
board.id,
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
handleRefetch,
|
||||
openModal,
|
||||
options,
|
||||
persistBoard,
|
||||
previewDimensions,
|
||||
settings,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
@@ -214,7 +222,9 @@ export const WidgetContextMenu = ({
|
||||
[setItemOptions],
|
||||
);
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
padding: 4px 40px 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--background-color);
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
|
||||
The board uses fixed logical units:
|
||||
|
||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||
- the gap matches Mantine's `sm` spacing at `12` logical pixels, giving a `212` pixel track pitch;
|
||||
- multi-cell items include the gaps between their cells (`2 × 1` is `412 × 200`);
|
||||
- one grid track has a `212 × 212` logical-pixel footprint;
|
||||
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter;
|
||||
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset);
|
||||
- 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
|
||||
the painted size while the inner canvas remains at its logical dimensions, so a
|
||||
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
|
||||
stable. The visual canvas always fits the available width, every root lane
|
||||
keeps at least one viewport of height, and edit-mode previews extend it downward
|
||||
as needed.
|
||||
Grid footprints keep the same logical dimensions at every viewport. Widget cards
|
||||
use a scale-compensated inset so their painted size and content measurements
|
||||
match the legacy responsive grid. The visual canvas always fits the available
|
||||
width, every root lane keeps at least one viewport of height, and edit-mode
|
||||
previews extend it downward as needed.
|
||||
|
||||
The read-only renderer should:
|
||||
|
||||
|
||||
Loaded 100 of 385 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user