Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411e75e799 | ||
|
|
0553d60a58 | ||
|
|
7da2e10064 | ||
|
|
a002b91add | ||
|
|
9c441571bc | ||
|
|
643ecf3df7 | ||
|
|
1b2f1bd720 | ||
|
|
c82965067c | ||
|
|
8abc9cae6c | ||
|
|
becc6a71a9 | ||
|
|
78a4f0e513 | ||
|
|
70e1b29913 | ||
|
|
73de83b48f | ||
|
|
26d12d9fad | ||
|
|
f0eddda573 | ||
|
|
b2c72d54c2 | ||
|
|
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 | ||
|
|
d5cf1b82f2 | ||
|
|
e022c3f735 | ||
|
|
14cc3e8a44 | ||
|
|
93d160aebd | ||
|
|
493e93ea42 | ||
|
|
85c20702cd | ||
|
|
91b248d395 | ||
|
|
b7eadc7867 | ||
|
|
1861d8415c | ||
|
|
7fe5f8f81c | ||
|
|
b3a30e0cf6 | ||
|
|
763e1324e8 | ||
|
|
5188ed89d6 |
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."
|
||||
@@ -320,8 +320,12 @@ jobs:
|
||||
|
||||
publish-preview:
|
||||
name: Publish multi-platform preview image
|
||||
needs: [preview-amd64, preview-arm64]
|
||||
needs: [container, preview-amd64, preview-arm64]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.container.result == 'success' &&
|
||||
needs.preview-amd64.result == 'success' &&
|
||||
needs.preview-arm64.result == 'success' &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
@@ -375,9 +379,6 @@ jobs:
|
||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||
run: |
|
||||
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
||||
@@ -386,9 +387,6 @@ jobs:
|
||||
if: "!env.ACT"
|
||||
run: |
|
||||
tags=("${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=("pr-6545")
|
||||
fi
|
||||
for tag in "${tags[@]}"; do
|
||||
image="${PREVIEW_IMAGE}:${tag}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
@@ -435,6 +433,14 @@ jobs:
|
||||
core.info(`No package version found with tag "${tag}"`);
|
||||
return;
|
||||
}
|
||||
const targetTags = target.metadata?.container?.tags ?? [];
|
||||
const extraTags = targetTags.filter((targetTag) => targetTag !== tag);
|
||||
if (extraTags.length > 0) {
|
||||
core.setFailed(
|
||||
`Package version ${target.id} also carries tag(s) ${extraTags.map((targetTag) => `"${targetTag}"`).join(", ")}; refusing cleanup.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await github.rest.packages.deletePackageVersionForOrg({
|
||||
package_type: "container",
|
||||
package_name: packageName,
|
||||
|
||||
@@ -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 |
@@ -29,6 +29,9 @@ Alternatively, you can [open the full interactive API reference](/api-reference)
|
||||
Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key.
|
||||
You can obtain API keys from the "Authentication" tab at the top of the page.
|
||||
API keys will not expire and can be used indefinitely.
|
||||
When you create a key, its one-time value is shown inline above the key table. Copy it before closing the notice; the
|
||||
creation action remains disabled until the value has been acknowledged. Deletion uses a two-click inline confirmation
|
||||
instead of opening a separate dialog.
|
||||
|
||||
### API Key Format
|
||||
|
||||
@@ -112,6 +115,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
||||
|
||||
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
|
||||
|
||||
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
## Docker action payload migration
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,9 +34,11 @@ arrow keys to move through the results and press `Enter` to open the selected bo
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
Select **New board** on the boards management page. The creation form expands in the page, so you can enter the board
|
||||
details without opening a dialog or leaving the board list. The availability of the board name is checked as you type.
|
||||
|
||||

|
||||
The board menu also keeps duplication in context: select **Duplicate board**, choose the new name in the form below that
|
||||
board, and create the copy without navigating to a separate page.
|
||||
|
||||
### Name
|
||||
|
||||
@@ -56,6 +58,9 @@ This will enable public access for everyone - regardless of whether they are log
|
||||
This can be really useful if you want to have public boards in your company or for family and friends.
|
||||
Modifications for public boards must be allowed explicitly using user groups and their permissions.
|
||||
|
||||
Deleting a board from the management list uses an inline safeguard. Select **Delete**, then select **Confirm** in the
|
||||
same menu item. No separate confirmation dialog is opened.
|
||||
|
||||
## Board settings
|
||||
|
||||
You navigate to the board settings by clicking on these three dots:
|
||||
@@ -317,7 +322,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.
|
||||
@@ -405,8 +410,7 @@ nor the integrations the user does not have access to.
|
||||
Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board.
|
||||
Duplicating a board that contains Custom Widgets also requires an administrator.
|
||||
You can duplicate a board by clicking on the three dots in the list of boards and selecting "Duplicate board".
|
||||
|
||||

|
||||
The name form expands below that board card and can be cancelled without leaving the list.
|
||||
|
||||
## Setting boards as your home board
|
||||
|
||||
|
||||
@@ -30,15 +30,12 @@ Navigate to `Management` > `Tools` > `Certificates`.
|
||||
|
||||
## Add a certificate
|
||||
|
||||
To add a certificate click on the "Add certificate" button.
|
||||
To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page.
|
||||
|
||||

|
||||
|
||||
Then select the certificate file you want to add.
|
||||
|
||||

|
||||
|
||||
And click the "Add" button.
|
||||
Then select the certificate file you want to add and click the "Add" button. You can cancel to collapse the form without
|
||||
leaving the page.
|
||||
|
||||
## Delete a certificate
|
||||
|
||||
@@ -46,7 +43,7 @@ To delete a certificate you can click on the trash icon in the certificate card.
|
||||
|
||||

|
||||
|
||||
Then confirm the deletion.
|
||||
The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened.
|
||||
|
||||
## Managing it through file system
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -114,7 +114,8 @@ The install flow always links to the corresponding public Workshop entry under t
|
||||
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
|
||||
|
||||
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
|
||||
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary.
|
||||
delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a
|
||||
dialog. Community moderation is separate from Homarr administration and is not a security boundary.
|
||||
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
|
||||
performs definitive validation again at install time.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -128,6 +128,26 @@ You can decide if app status is disabled on all boards.
|
||||
|
||||
You can set the default color scheme for users without their own color scheme.
|
||||
|
||||
## Branding
|
||||
|
||||
Branding settings let administrators adapt Homarr to the identity and visual style of their installation. Changes apply across the application and authentication pages after you save the settings.
|
||||
|
||||
### Identity
|
||||
|
||||
- **Application name** replaces the default Homarr name in page titles and branded surfaces.
|
||||
- **Logo** is used in the header and other application branding.
|
||||
- **Favicon** controls the browser tab icon. When it is empty, Homarr falls back to the configured logo.
|
||||
|
||||
### Colors and corners
|
||||
|
||||
Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements.
|
||||
|
||||
### Sign-in page
|
||||
|
||||
You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting.
|
||||
|
||||
Use an image URL that every user can load before signing in. A URL that requires an authenticated Homarr session will not work on the sign-in page.
|
||||
|
||||
## Culture
|
||||
|
||||
### Default language
|
||||
|
||||
@@ -108,7 +108,7 @@ However, you can create as many groups as you'd like.
|
||||
### Creating a group
|
||||
|
||||
Click on the button `New group` to create a new one.
|
||||
Homarr will prompt you for a group name.
|
||||
The group-name form expands above the group list and can be closed without leaving the page.
|
||||
|
||||
### Managing a group
|
||||
|
||||
@@ -259,7 +259,8 @@ An invitation link can be used indefinitely.
|
||||
|
||||
### Creating an invitation
|
||||
|
||||
Click on the button:
|
||||
Select the create-invitation button. The expiration form expands above the invitation table instead of opening a
|
||||
dialog or navigating to another page:
|
||||
|
||||

|
||||
|
||||
@@ -269,12 +270,12 @@ The default is 4 hours in the future.
|
||||
|
||||

|
||||
|
||||
Next, click `Create` and you'll get a confirmation with an ID, Token and a link that can be copied:
|
||||
Next, click `Create` and the same section shows the ID, Token and a link that can be copied:
|
||||
|
||||

|
||||
|
||||
Both ID and Token are also in the URL. Ensure that you copy either ID & Token or the URL - it will never be shown again.
|
||||
Clicking the `Copy & close` button will copy the URL to your clipboard.
|
||||
Use **Copy** to copy the URL, then close the section when you are finished.
|
||||
|
||||
### Self-onboarding
|
||||
|
||||
@@ -293,3 +294,4 @@ If an invitation needs to be deleted before or after the expiry, the delete butt
|
||||
|
||||
The link will become invalid.
|
||||
If the invitation expires, the link will also expire.
|
||||
The first click changes the action to **Confirm**; the second click deletes the invitation without opening a dialog.
|
||||
@@ -11,7 +11,7 @@ import { appWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. Compact tiles retain the familiar title, optional description, and centered app icon proportions.
|
||||
It also supports a status check to indicate whether Homarr can reach the application or website.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -23,6 +23,8 @@ This widget adapts its display based on the linked integration:
|
||||
|
||||
Only options relevant to your linked integration are shown in the configuration panel.
|
||||
|
||||
Compact mode keeps every configured statistic visible in the familiar filled tile layout, including a vertical stack for narrow Navidrome cards. Advanced mode retains the complete expanded statistics and stream details.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
|
||||
@@ -17,7 +17,7 @@ import beszelAlerts from "./img/beszel-alerts.png";
|
||||
|
||||
<WidgetHeader widget={beszelAlertsWidget} categories={["System Monitoring", "Alerts"]} />
|
||||
|
||||
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Alerts are updated in real time via WebSocket subscriptions.
|
||||
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Compact mode keeps configured OK alerts and descriptions visible alongside triggered alerts, and keeps history visible when the history option is enabled. Alerts are updated in real time via WebSocket subscriptions.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { coolifyIntegration } from "@site/docs/integrations/coolify";
|
||||
|
||||
<WidgetHeader widget={coolifyWidget} categories={["System monitoring", "Deployment"]} />
|
||||
|
||||
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version.
|
||||
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version. Compact resource badges and footer details remain visible without height-based hiding.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
|
||||
|
||||
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
|
||||
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget's width and height. Bulk actions keep successful results visible and report the number of failures.
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, with its familiar textual status control retained at compact heights. Bulk actions keep successful results visible and report the number of failures.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { dockerContainersWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
|
||||
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them.
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them. Compact mode keeps every configured table column and shows the complete CPU and memory totals whenever the footer is available.
|
||||
|
||||
:::info
|
||||
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
|
||||
|
||||
@@ -58,7 +58,7 @@ import filterPanelImg from "./img/filter-panel.png";
|
||||
|
||||
### Features
|
||||
|
||||
- **Responsive compact table** — in compact mode, selected columns adapt to both widget width and height; narrow or short tiles hide speed/state columns, and the shortest tiles also hide the ETA column
|
||||
- **Responsive compact table** — in compact mode, selected columns use the established width breakpoints; widget height does not remove configured columns or the footer
|
||||
- **Sortable columns** — click any sortable column header to sort the current view; the configured default controls the initial column and direction
|
||||
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
|
||||
- **Download-row context menu** — copy a download name or ID from any row; pause, resume, and delete actions appear when you have Interact access to that integration
|
||||
@@ -72,11 +72,10 @@ import filterPanelImg from "./img/filter-panel.png";
|
||||
|
||||
:::note
|
||||
|
||||
Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state
|
||||
columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also
|
||||
hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions,
|
||||
keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available
|
||||
from the footer.
|
||||
Compact layouts use their established width breakpoints: below 500px wide, speed columns are hidden; below 300px wide,
|
||||
the ETA and state columns are hidden too. Height does not remove configured columns or the footer. Advanced view bypasses
|
||||
the compact width reductions, keeps the selected column set, and always includes the stats bar and footer controls, with
|
||||
the filter panel available from the footer.
|
||||
|
||||
The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and
|
||||
state counts, while transfer-speed totals summarize the selected clients and the global torrent ratio summarizes the
|
||||
|
||||
@@ -16,6 +16,8 @@ import { opnsenseIntegration } from "../../integrations/opnsense";
|
||||
|
||||
<WidgetHeader widget={firewallWidget} categories={["Firewall"]} />
|
||||
|
||||
Compact mode keeps the configured firewall summary and interface section visible without dropping interfaces based on widget height.
|
||||
|
||||
### Screenshots
|
||||
|
||||

|
||||
|
||||
@@ -67,6 +67,8 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai
|
||||
- **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page
|
||||
- **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni
|
||||
|
||||
Homarr passes the configured URL directly to the browser. The iframe sandbox and configured permissions still apply.
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={iframeWidget.configuration} />
|
||||
|
||||
@@ -15,6 +15,8 @@ import { immichServerStatsWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={immichServerStatsWidget} categories={["Photos server"]} />
|
||||
|
||||
Compact mode presents every enabled statistic as a vertical icon, label, and value card.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import screen from "./img/screen.png";
|
||||
|
||||
@@ -14,6 +14,8 @@ import { indexerManagerWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={indexerManagerWidget} categories={["Indexer"]} />
|
||||
|
||||
Compact mode keeps the titled indexer list, per-indexer status indicators, and full-width **Test all** action together.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import bigItem from "./img/big.png";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
|
||||
<WidgetHeader widget={mediaReleasesWidget} categories={["Releases", "Media"]} />
|
||||
|
||||
Provides a list of newly added media or upcoming releases from your media server.
|
||||
Provides a list of newly added media or upcoming releases from your media server. Compact rows retain their poster, full release date and time, and configured metadata.
|
||||
|
||||
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Compact request cards retain their full artwork, request metadata, title, status, and permitted actions.
|
||||
|
||||
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
|
||||
|
||||
Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated.
|
||||
Provides an overview of media requests, including totals, availability, approvals, and rejections. Compact mode keeps every configured stat and section title visible; the user ranking remains scrollable instead of being truncated.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||
|
||||
The media server widget shows active sessions across your media servers.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. Compact mode keeps the user, currently playing, and status columns together with the stream summary and integration names.
|
||||
|
||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||
|
||||
@@ -17,7 +17,7 @@ import { minecraftServerStatusWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={minecraftServerStatusWidget} categories={["Gaming", "Minecraft"]} />
|
||||
|
||||
Shows the status and amount of players on a Minecraft server. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us).
|
||||
Shows the status and amount of players on a Minecraft server. Compact mode keeps the server icon and player count visible instead of hiding them based on tile size. It supports both Java and Bedrock editions, but only public servers accessible through the [mcsrvstat.us API](https://api.mcsrvstat.us) are supported.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { networkControllerStatusWidget } from ".";
|
||||
|
||||
Shows connected users and connected guest devices on wired or wireless networks.
|
||||
|
||||
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
|
||||
Compact view aggregates the configured controllers into one familiar card for the selected Wi-Fi or wired total. A failed refresh is shown as a small warning instead of replacing the totals card. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
|
||||
|
||||
Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view.
|
||||
Shows whether your WAN, internet, Wi-Fi, and VPN are available. The compact board view keeps the familiar status list, including internet latency and the connected VPN-user count.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -36,7 +36,7 @@ import summaryCard from "./img/summary.png";
|
||||
]}
|
||||
/>
|
||||
|
||||
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
|
||||
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { gotifyIntegration } from "@site/docs/integrations/gotify";
|
||||
|
||||
<WidgetHeader widget={notificationsWidget} categories={["Notifications", "Messaging"]} />
|
||||
|
||||
The notifications widget allows you to display notifications from various integrations in a unified view.
|
||||
The notifications widget allows you to display notifications from various integrations in a unified view. Compact mode presents them as a vertical list of padded notification cards.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx";
|
||||
|
||||
<WidgetHeader widget={paperlessNgxWidget} categories={["Documents"]} />
|
||||
|
||||
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types.
|
||||
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types. Compact mode keeps every configured statistic in the familiar filled tile layout and uses a vertical stack on narrow cards.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { releasesWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={releasesWidget} categories={["Releases", "Software"]} />
|
||||
|
||||
The releases widget enables you to track a list of repositories and see their respective latest release.
|
||||
The releases widget enables you to track a list of repositories and see their respective latest release. Compact mode keeps repositories in the familiar full-width stack.
|
||||
It also supports importing releases from your local docker containers.
|
||||
|
||||
### Screenshots
|
||||
@@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
|
||||
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
|
||||
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
|
||||
|
||||
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
|
||||
Advanced view shows every configured repository in a roomier responsive layout. Repository names and details remain visible, and selecting a repository still expands its description and links.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { rssFeedWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={rssFeedWidget} categories={["Feeds"]} />
|
||||
|
||||
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard.
|
||||
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. Compact mode uses a vertical card list and keeps enclosure images as the familiar poster and blurred backdrop.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
|
||||
|
||||
<WidgetHeader widget={smartHomeEntityStateWidget} categories={["Smart home"]} />
|
||||
|
||||
The entity state enables you to display the current state of an entity.
|
||||
The entity state enables you to display the current state of an entity. Compact mode presents the friendly name first and its state beneath it.
|
||||
This works with most entities and enables you display almost any data.
|
||||
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
|
||||
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
|
||||
including the latest result, historical averages, and a live chart of the last 12 hours.
|
||||
|
||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
|
||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
|
||||
|
||||
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
|
||||
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations. Compact mode uses a one-column card list with the configured primary value, available temperature, and solid health bar.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
||||
|
||||
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
|
||||
|
||||
Displays CPU, RAM and network of your host in real time
|
||||
Displays CPU, RAM and network of your host in real time. Compact view keeps every configured chart in its familiar stacked layout, while advanced view adds the expanded per-source presentation.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { timetableWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
|
||||
|
||||
The Timetable widget provides real-time departure times for a selected station.
|
||||
The Timetable widget provides real-time departure times for a selected station. Compact mode keeps configured titles, line names, and platform fields visible while adapting dense rows to the available space.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
|
||||
|
||||
<WidgetHeader widget={tracearrWidget} categories={["Media Monitoring"]} />
|
||||
|
||||
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
|
||||
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations. Compact mode renders every enabled section in that familiar order without hiding or truncating sections based on widget height.
|
||||
|
||||
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
|
||||
|
||||
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
|
||||
|
||||
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
|
||||
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps every configured protocol card and entry-point label visible and names each healthy source when multiple integrations are selected.
|
||||
|
||||
When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { umamiIntegration } from "@site/docs/integrations/umami";
|
||||
|
||||
<WidgetHeader widget={umamiWidget} categories={["Analytics"]} />
|
||||
|
||||
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists.
|
||||
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists. Compact mode keeps configured axes and secondary statistics visible regardless of widget height.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
|
||||
|
||||
<WidgetHeader widget={uptimeKumaWidget} categories={["Monitoring"]} />
|
||||
|
||||
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
|
||||
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. Compact mode shows every configured ring and statistic without dropping them based on widget height.
|
||||
|
||||
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
|
||||
|
||||
|
||||
@@ -38,8 +38,10 @@ its server; changing the browser clock cannot bypass them.
|
||||
Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it.
|
||||
|
||||
Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this
|
||||
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is
|
||||
configured for zero data retention with data collection disabled. Homarr does not use this content to train models.
|
||||
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. Each active day
|
||||
has a separate quota record so Workshop operators can inspect per-user daily usage without seeing request content.
|
||||
OpenRouter is configured for zero data retention with data collection disabled. Homarr does not use this content to
|
||||
train models.
|
||||
|
||||
We would rather become millionaires in a more ethical manner.
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ per-request telemetry. It accepts only uploaded image data, conservatively bound
|
||||
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
|
||||
limit as the final monetary backstop because request quotas are not dollar accounting.
|
||||
|
||||
PocketBase stores one `assistant_quotas` record per user and active UTC day. Workshop superusers can use this private
|
||||
collection for a daily usage breakdown; regular users cannot list or view its records.
|
||||
|
||||
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
|
||||
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
|
||||
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
|
||||
|
||||
+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",
|
||||
|
||||
@@ -72,10 +72,12 @@
|
||||
"@rexa-developer/tiks": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/query-sync-storage-persister": "catalog:",
|
||||
"@tanstack/react-devtools": "catalog:",
|
||||
"@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,131 @@
|
||||
import { dehydrate, QueryClient } from "@tanstack/react-query";
|
||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createSessionQueryPersistence,
|
||||
getQueryPersistenceStorageKey,
|
||||
queryPersistenceBuster,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
|
||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
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();
|
||||
});
|
||||
|
||||
describe("dashboard query persistence", () => {
|
||||
test("keeps usable dashboard data and skips volatile or unrelated queries", () => {
|
||||
const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 });
|
||||
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
|
||||
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
||||
const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []);
|
||||
const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], {
|
||||
apiKey: "secret",
|
||||
});
|
||||
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
|
||||
const pendingClient = new QueryClient();
|
||||
const pending = pendingClient.getQueryCache().build(pendingClient, {
|
||||
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
});
|
||||
const failed = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], []);
|
||||
failed.query.setState({ ...failed.query.state, error: new Error("refresh failed"), status: "error" });
|
||||
|
||||
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
|
||||
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
|
||||
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
|
||||
});
|
||||
|
||||
test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
|
||||
vi.useFakeTimers();
|
||||
const storage = createMemoryStorage();
|
||||
const userA = createSessionQueryPersistence("user-a", storage);
|
||||
const userB = createSessionQueryPersistence("user-b", storage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||
});
|
||||
const cachedQuery = queryClient.getQueryCache().find({
|
||||
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||
});
|
||||
if (!cachedQuery) throw new Error("Expected cached query to be present");
|
||||
cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" });
|
||||
queryClient.getMutationCache().build(
|
||||
queryClient,
|
||||
{ mutationKey: ["integration", "create"], mutationFn: async () => undefined },
|
||||
{
|
||||
context: undefined,
|
||||
data: undefined,
|
||||
error: null,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
isPaused: true,
|
||||
status: "pending",
|
||||
submittedAt: Date.now(),
|
||||
variables: { apiKey: "secret" },
|
||||
},
|
||||
);
|
||||
const persistedClient: PersistedClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient, userA.dehydrateOptions),
|
||||
};
|
||||
|
||||
expect(persistedClient.clientState.mutations).toHaveLength(0);
|
||||
|
||||
await userA.persister.persistClient(persistedClient);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
const restored = await userA.persister.restoreClient();
|
||||
expect(restored?.clientState.queries[0]?.state.data).toEqual({
|
||||
startsAt: 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(await userB.persister.restoreClient()).toBeUndefined();
|
||||
|
||||
await userA.persister.removeClient();
|
||||
expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
|
||||
});
|
||||
|
||||
test("treats unavailable session storage as an empty cache", async () => {
|
||||
const blockedStorage = createMemoryStorage();
|
||||
blockedStorage.getItem = () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
};
|
||||
blockedStorage.removeItem = () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
};
|
||||
|
||||
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
|
||||
expect(await persistence.persister.restoreClient()).toBeUndefined();
|
||||
expect(() => persistence.persister.removeClient()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
||||
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
|
||||
import { parse, stringify } from "superjson";
|
||||
|
||||
import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||
|
||||
export const queryPersistenceBuster = "v6-dashboard-data";
|
||||
|
||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||
|
||||
export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
||||
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
|
||||
|
||||
interface PersistableQuery {
|
||||
queryKey: QueryKey;
|
||||
state: { data: unknown; status: string };
|
||||
}
|
||||
|
||||
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
|
||||
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
|
||||
|
||||
const serializePersistedClient = (client: PersistedClient) =>
|
||||
stringify({
|
||||
...client,
|
||||
clientState: {
|
||||
...client.clientState,
|
||||
queries: client.clientState.queries.map((query) => ({
|
||||
...query,
|
||||
state: {
|
||||
...query.state,
|
||||
error: null,
|
||||
errorUpdateCount: 0,
|
||||
errorUpdatedAt: 0,
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
status: "success",
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const getSessionStorage = () => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const protectStorage = (storage: Storage | undefined) => {
|
||||
if (!storage) return undefined;
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
try {
|
||||
return storage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
storage.setItem(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"];
|
||||
|
||||
export const createSessionQueryPersistence = (
|
||||
scope: string | null,
|
||||
storage: Storage | undefined = getSessionStorage(),
|
||||
): SessionQueryPersistence => ({
|
||||
persister: createSyncStoragePersister({
|
||||
storage: protectStorage(storage),
|
||||
key: getQueryPersistenceStorageKey(scope),
|
||||
serialize: serializePersistedClient,
|
||||
deserialize: parse,
|
||||
retry: removeOldestQuery,
|
||||
}),
|
||||
maxAge: queryCacheDefaultGcTimeMs,
|
||||
buster: queryPersistenceBuster,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateMutation: () => false,
|
||||
shouldDehydrateQuery: shouldPersistDashboardQuery,
|
||||
},
|
||||
});
|
||||
@@ -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 { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
@@ -24,6 +25,8 @@ import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
dashboardSupportingQueryPolicies,
|
||||
isTrpcForbiddenError,
|
||||
isWidgetDataQueryKey,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
@@ -35,6 +38,8 @@ import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import { createSessionQueryPersistence } from "./query-persistence";
|
||||
import type { SessionQueryPersistence } from "./query-persistence";
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const DevelopmentTools =
|
||||
@@ -67,21 +72,29 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
|
||||
const handleScopeChange = useCallback(() => {
|
||||
void queryPersistence.persister.removeClient();
|
||||
reloadPage();
|
||||
}, [queryPersistence]);
|
||||
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
initialScope={initialSessionQueryScope}
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={reloadPage}
|
||||
onScopeChange={handleScopeChange}
|
||||
>
|
||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
||||
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
const ScopedTRPCReactProvider = ({
|
||||
children,
|
||||
queryPersistence,
|
||||
}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
@@ -98,6 +111,13 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
const [queryClient] = useState(() => {
|
||||
const client = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError(error, query) {
|
||||
if (!isTrpcForbiddenError(error) || !isWidgetDataQueryKey(query.queryKey)) return;
|
||||
void queryPersistence.persister.removeClient();
|
||||
query.setState({ data: undefined, dataUpdatedAt: 0, isInvalidated: true });
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
@@ -186,10 +206,10 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
|
||||
<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">
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Box, Button, Group, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { InlineConfirmButton } from "@homarr/ui";
|
||||
import { boardRenameSchema } from "@homarr/validation/board";
|
||||
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
|
||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||
@@ -17,70 +18,70 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
const t = useI18n("board.setting");
|
||||
const tCommonAction = useI18n("common.action");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal } = useModalAction(BoardRenameModal);
|
||||
const utils = clientApi.useUtils();
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), {
|
||||
initialValues: {
|
||||
name: board.name,
|
||||
},
|
||||
});
|
||||
const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
|
||||
clientApi.board.changeBoardVisibility.useMutation();
|
||||
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const visibility = board.isPublic ? "public" : "private";
|
||||
|
||||
const onRenameClick = useCallback(
|
||||
() =>
|
||||
openModal({
|
||||
useEffect(() => {
|
||||
if (isRenaming) renameInputRef.current?.focus();
|
||||
}, [isRenaming]);
|
||||
|
||||
const handleRename = renameForm.onSubmit((values) => {
|
||||
renameBoard(
|
||||
{ id: board.id, name: values.name },
|
||||
{
|
||||
onSuccess() {
|
||||
router.push(`/boards/${values.name}/settings`);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const cancelRename = () => {
|
||||
renameForm.reset();
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
changeVisibility(
|
||||
{
|
||||
id: board.id,
|
||||
previousName: board.name,
|
||||
onSuccess: (name) => router.push(`/boards/${name}/settings`),
|
||||
}),
|
||||
[board.id, board.name, router, openModal],
|
||||
);
|
||||
|
||||
const onVisibilityClick = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
|
||||
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
|
||||
onConfirm: () => {
|
||||
changeVisibility(
|
||||
{
|
||||
id: board.id,
|
||||
visibility: visibility === "public" ? "private" : "public",
|
||||
},
|
||||
{
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
visibility: visibility === "public" ? "private" : "public",
|
||||
},
|
||||
});
|
||||
}, [
|
||||
board.id,
|
||||
board.name,
|
||||
changeVisibility,
|
||||
t,
|
||||
utils.board.getBoardByName,
|
||||
utils.board.getHomeBoard,
|
||||
visibility,
|
||||
openConfirmModal,
|
||||
]);
|
||||
|
||||
const onDeleteClick = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("section.dangerZone.action.delete.confirm.title"),
|
||||
children: t("section.dangerZone.action.delete.confirm.description"),
|
||||
onConfirm: () => {
|
||||
deleteBoard(
|
||||
{ id: board.id },
|
||||
{
|
||||
onSettled: () => {
|
||||
router.push("/");
|
||||
},
|
||||
},
|
||||
);
|
||||
{
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteBoard(
|
||||
{ id: board.id },
|
||||
{
|
||||
onSettled: () => {
|
||||
router.push("/");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DangerZoneRoot>
|
||||
@@ -88,9 +89,28 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
label={t("section.dangerZone.action.rename.label")}
|
||||
description={t("section.dangerZone.action.rename.description")}
|
||||
action={
|
||||
<Button variant="subtle" color="red" onClick={onRenameClick}>
|
||||
{t("section.dangerZone.action.rename.button")}
|
||||
</Button>
|
||||
isRenaming ? (
|
||||
<Box component="form" onSubmit={handleRename}>
|
||||
<Group wrap="wrap" justify="end">
|
||||
<TextInput
|
||||
ref={renameInputRef}
|
||||
aria-label={t("section.dangerZone.action.rename.label")}
|
||||
size="sm"
|
||||
{...renameForm.getInputProps("name")}
|
||||
/>
|
||||
<Button variant="default" onClick={cancelRename} disabled={isRenamePending}>
|
||||
{tCommonAction("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="red" loading={isRenamePending}>
|
||||
{tCommonAction("confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
) : (
|
||||
<Button variant="subtle" color="red" onClick={() => setIsRenaming(true)}>
|
||||
{t("section.dangerZone.action.rename.button")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!hideVisibility && (
|
||||
@@ -98,9 +118,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
|
||||
<InlineConfirmButton
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={isChangeVisibilityPending}
|
||||
onConfirm={handleVisibilityChange}
|
||||
confirmLabel={tCommonAction("confirm")}
|
||||
>
|
||||
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
</Button>
|
||||
</InlineConfirmButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -108,9 +134,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
label={tCommonAction("delete")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||
<InlineConfirmButton
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={isDeletePending}
|
||||
onConfirm={handleDelete}
|
||||
confirmLabel={tCommonAction("confirm")}
|
||||
>
|
||||
{tCommonAction("delete")}
|
||||
</Button>
|
||||
</InlineConfirmButton>
|
||||
}
|
||||
/>
|
||||
</DangerZoneRoot>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,9 +23,9 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { InlineConfirmButton } from "@homarr/ui";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { Board } from "../../_types";
|
||||
@@ -46,7 +46,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
const tCommon = useI18n("common");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
|
||||
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(null);
|
||||
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
|
||||
@@ -84,29 +83,20 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReset = (layout: FormValues["layouts"][number]) => {
|
||||
openConfirmModal({
|
||||
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
|
||||
children: tBoard("setting.section.layout.reset.confirm.message"),
|
||||
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
|
||||
onConfirm() {
|
||||
setResettingLayoutId(layout.id);
|
||||
void (async () => {
|
||||
try {
|
||||
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
if (!canonicalLayout) return;
|
||||
const handleReset = async (layout: FormValues["layouts"][number]) => {
|
||||
setResettingLayoutId(layout.id);
|
||||
try {
|
||||
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
if (!canonicalLayout) return;
|
||||
|
||||
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
|
||||
await utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
router.refresh();
|
||||
} catch {
|
||||
// The mutation callback displays the error notification.
|
||||
} finally {
|
||||
setResettingLayoutId(null);
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
|
||||
await utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
router.refresh();
|
||||
} catch {
|
||||
// The mutation callback displays the error notification.
|
||||
} finally {
|
||||
setResettingLayoutId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||
@@ -236,16 +226,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
{layout.role !== "base" && persistedLayout && (
|
||||
<Button
|
||||
<InlineConfirmButton
|
||||
type="button"
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
|
||||
loading={resettingLayoutId === layout.id}
|
||||
disabled={isSaving}
|
||||
onClick={() => confirmReset(layout)}
|
||||
onConfirm={() => handleReset(layout)}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
>
|
||||
{tBoard("setting.section.layout.reset.action")}
|
||||
</Button>
|
||||
</InlineConfirmButton>
|
||||
)}
|
||||
{layout.role === "custom" && (
|
||||
<Button
|
||||
|
||||
@@ -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" />}
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -18,48 +17,41 @@ interface AppDeleteButtonProps {
|
||||
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
const t = useI18n("app.page.delete");
|
||||
const tCommon = useI18n("common");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("title"),
|
||||
children: t("message", {
|
||||
name: app.name,
|
||||
}),
|
||||
onConfirm: () => {
|
||||
mutate(
|
||||
{ id: app.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.delete.success"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.delete.error"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
const onConfirm = useCallback(() => {
|
||||
mutate(
|
||||
{ id: app.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.delete.success"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.delete.error"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [app, mutate, t, tCommon, openConfirmModal]);
|
||||
);
|
||||
}, [app.id, mutate, t, tCommon]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
<InlineConfirmActionIcon
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
confirmationAriaLabel={tCommon("action.confirm")}
|
||||
onConfirm={onConfirm}
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tCommon("action.deleteNamed", { name: app.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</InlineConfirmActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+12
-34
@@ -8,10 +8,8 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
|
||||
@@ -25,9 +23,10 @@ interface BoardCardMenuDropdownProps {
|
||||
RouterOutputs["board"]["getManageOverview"][number],
|
||||
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
|
||||
>;
|
||||
onDuplicate: () => void;
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||
export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropdownProps) => {
|
||||
const t = useI18n("management.page.board.action");
|
||||
const tRoot = useI18n();
|
||||
const tCommon = useI18n("common");
|
||||
@@ -35,9 +34,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
|
||||
|
||||
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
// Revalidate all as it's part of the user settings, /boards page and board manage page
|
||||
@@ -56,20 +52,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeletion = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: tCommon("action.deleteNamed", { name: board.name }),
|
||||
children: t("delete.confirm.description", {
|
||||
name: board.name,
|
||||
}),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
onConfirm: async () => {
|
||||
await deleteBoardMutation.mutateAsync({
|
||||
id: board.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
|
||||
const handleDeletion = useCallback(
|
||||
() => deleteBoardMutation.mutateAsync({ id: board.id }),
|
||||
[board.id, deleteBoardMutation],
|
||||
);
|
||||
|
||||
const handleSetHomeBoard = useCallback(async () => {
|
||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
@@ -79,15 +65,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
}, [board.id, setMobileHomeBoardMutation]);
|
||||
|
||||
const handleDuplicateBoard = useCallback(() => {
|
||||
openDuplicateModal({
|
||||
board: {
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, openDuplicateModal]);
|
||||
|
||||
return (
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||
@@ -97,7 +74,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
{t("setMobileHomeBoard.label")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
<Menu.Item onClick={onDuplicate} leftSection={<IconCopy {...iconProps} />}>
|
||||
{tRoot("board.action.duplicate.title")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -117,14 +94,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
|
||||
<Menu.Item
|
||||
<InlineConfirmMenuItem
|
||||
c="red.7"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDeletion}
|
||||
onConfirm={handleDeletion}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
disabled={deleteBoardMutation.isPending}
|
||||
>
|
||||
{tCommon("action.delete")}
|
||||
</Menu.Item>
|
||||
</InlineConfirmMenuItem>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardSection,
|
||||
Collapse,
|
||||
Group,
|
||||
Menu,
|
||||
MenuTarget,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { BoardCardMenuDropdown } from "./board-card-menu-dropdown";
|
||||
import { DuplicateBoardForm } from "./duplicate-board-form";
|
||||
|
||||
interface BoardCardProps {
|
||||
board: RouterOutputs["board"]["getManageOverview"][number];
|
||||
isMenuVisible: boolean;
|
||||
labels: {
|
||||
visibility: string;
|
||||
preview: string;
|
||||
unknownCreator: string;
|
||||
home: string;
|
||||
homeTooltip: string;
|
||||
mobileHome: string;
|
||||
mobileHomeTooltip: string;
|
||||
open: string;
|
||||
settings: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
|
||||
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
|
||||
const [duplicateFormKey, setDuplicateFormKey] = useState(0);
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card padding={0} withBorder>
|
||||
<CardSection withBorder>
|
||||
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
|
||||
</CardSection>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
|
||||
<Tooltip label={labels.visibility}>
|
||||
<VisibilityIcon size={20} stroke={1.5} />
|
||||
</Tooltip>
|
||||
<Text fw={700} truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
|
||||
{board.isHome && (
|
||||
<Tooltip label={labels.homeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
|
||||
{labels.home}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.isMobileHome && (
|
||||
<Tooltip label={labels.mobileHomeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||
{labels.mobileHome}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.creator && (
|
||||
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</CardSection>
|
||||
|
||||
<CardSection>
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Button
|
||||
style={{ border: "none", borderRadius: 0 }}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
variant="default"
|
||||
h={44}
|
||||
fullWidth
|
||||
>
|
||||
{labels.open}
|
||||
</Button>
|
||||
{isMenuVisible && (
|
||||
<Menu position="bottom-end">
|
||||
<MenuTarget>
|
||||
<ActionIcon
|
||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||
variant="default"
|
||||
size={44}
|
||||
aria-label={labels.settings}
|
||||
>
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuTarget>
|
||||
<BoardCardMenuDropdown
|
||||
board={board}
|
||||
onDuplicate={() => {
|
||||
setDuplicateFormKey((value) => value + 1);
|
||||
setIsDuplicateOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</CardSection>
|
||||
</Card>
|
||||
<Collapse expanded={isDuplicateOpen}>
|
||||
<Card mt="sm" withBorder>
|
||||
<DuplicateBoardForm key={duplicateFormKey} board={board} onCancel={() => setIsDuplicateOpen(false)} />
|
||||
</Card>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button, Card, Collapse, Group, Stack } from "@mantine/core";
|
||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal } from "@homarr/modals-collection";
|
||||
import { BoardCreateForm } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n("management.page.board.action");
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isOpen, setIsOpen] = useState(searchParams.get("create") === "true");
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("create") !== "true") return;
|
||||
|
||||
setFormKey((value) => value + 1);
|
||||
setIsOpen(true);
|
||||
}, [searchParams]);
|
||||
|
||||
const close = () => {
|
||||
setIsOpen(false);
|
||||
setFormKey((value) => value + 1);
|
||||
if (searchParams.has("create")) router.replace("/manage/boards", { scroll: false });
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (isOpen) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
setFormKey((value) => value + 1);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ManageMobilePrimaryAction>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
|
||||
{t("new.label")}
|
||||
</Button>
|
||||
</ManageMobilePrimaryAction>
|
||||
<Stack>
|
||||
<Group justify="end">
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={toggle}>
|
||||
{t("new.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Collapse expanded={isOpen}>
|
||||
<Card withBorder>
|
||||
<BoardCreateForm key={formKey} onCancel={close} />
|
||||
</Card>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
+22
-31
@@ -1,29 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { BoardNameAvailability, useBoardNameStatus } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardDuplicateSchema } from "@homarr/validation/board";
|
||||
|
||||
import { createModal } from "../../../modals/src/creator";
|
||||
import { useBoardNameStatus } from "./add-board-modal";
|
||||
|
||||
interface InnerProps {
|
||||
interface DuplicateBoardFormProps {
|
||||
board: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const router = useRouter();
|
||||
const form = useZodForm(boardDuplicateSchema.omit({ id: true }), {
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
name: innerProps.board.name,
|
||||
name: board.name,
|
||||
},
|
||||
});
|
||||
const boardNameStatus = useBoardNameStatus(form.values.name);
|
||||
@@ -36,20 +39,21 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
// Prevent submit before name availability check
|
||||
if (!boardNameStatus.canSubmit) return;
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
...values,
|
||||
id: innerProps.board.id,
|
||||
id: board.id,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
title: tBoard("action.duplicate.notification.success.title"),
|
||||
message: tBoard("action.duplicate.notification.success.message"),
|
||||
});
|
||||
onCancel();
|
||||
router.refresh();
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
@@ -61,39 +65,26 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
|
||||
);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Stack maw={720}>
|
||||
<Text size="sm" c="gray.6">
|
||||
{tBoard("action.duplicate.message", { name: innerProps.board.name })}
|
||||
{tBoard("action.duplicate.message", { name: board.name })}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label={tCommon("field.name")}
|
||||
data-autofocus
|
||||
autoFocus
|
||||
{...form.getInputProps("name")}
|
||||
description={
|
||||
boardNameStatus.description ? (
|
||||
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
||||
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
||||
<span>{boardNameStatus.description.label}</span>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
description={<BoardNameAvailability status={boardNameStatus} />}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
<Group justify="end" wrap="wrap">
|
||||
<Button variant="default" onClick={onCancel}>
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{tCommon("action.create")}
|
||||
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
|
||||
{tBoard("action.duplicate.title")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("board.action.duplicate.title");
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,38 +1,15 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardSection,
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
Menu,
|
||||
MenuTarget,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconDeviceMobile,
|
||||
IconDotsVertical,
|
||||
IconHomeFilled,
|
||||
IconLayoutDashboard,
|
||||
IconLock,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react";
|
||||
import { Grid, GridCol } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
import { BoardCard } from "./_components/board-card";
|
||||
import { CreateBoardButton } from "./_components/create-board-button";
|
||||
|
||||
export default async function ManageBoardsPage() {
|
||||
@@ -42,24 +19,14 @@ export default async function ManageBoardsPage() {
|
||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={
|
||||
canCreateBoards ? (
|
||||
<TourTarget id="manage-boards-create">
|
||||
<CreateBoardButton />
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
<ManagePageLayout title={t("title")}>
|
||||
{canCreateBoards && (
|
||||
<TourTarget id="manage-boards-create">
|
||||
<CreateBoardButton />
|
||||
</TourTarget>
|
||||
)}
|
||||
{boards.length === 0 && (
|
||||
<NoResults
|
||||
icon={IconLayoutDashboard}
|
||||
title={t("noResults.title")}
|
||||
description={t("noResults.description")}
|
||||
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
|
||||
/>
|
||||
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
|
||||
)}
|
||||
{boards.length > 0 && (
|
||||
<TourTarget id="manage-boards-list">
|
||||
@@ -92,97 +59,3 @@ export default async function ManageBoardsPage() {
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface BoardCardProps {
|
||||
board: RouterOutputs["board"]["getManageOverview"][number];
|
||||
isMenuVisible: boolean;
|
||||
labels: {
|
||||
visibility: string;
|
||||
preview: string;
|
||||
unknownCreator: string;
|
||||
home: string;
|
||||
homeTooltip: string;
|
||||
mobileHome: string;
|
||||
mobileHomeTooltip: string;
|
||||
open: string;
|
||||
settings: string;
|
||||
};
|
||||
}
|
||||
|
||||
const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<Card padding={0} withBorder>
|
||||
<CardSection withBorder>
|
||||
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
|
||||
</CardSection>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
|
||||
<Tooltip label={labels.visibility}>
|
||||
<VisibilityIcon size={20} stroke={1.5} />
|
||||
</Tooltip>
|
||||
<Text fw={700} truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
|
||||
{board.isHome && (
|
||||
<Tooltip label={labels.homeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
|
||||
{labels.home}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.isMobileHome && (
|
||||
<Tooltip label={labels.mobileHomeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||
{labels.mobileHome}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.creator && (
|
||||
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</CardSection>
|
||||
|
||||
<CardSection>
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Button
|
||||
style={{ border: "none", borderRadius: 0 }}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
variant="default"
|
||||
h={44}
|
||||
fullWidth
|
||||
>
|
||||
{labels.open}
|
||||
</Button>
|
||||
{isMenuVisible && (
|
||||
<Menu position="bottom-end">
|
||||
<MenuTarget>
|
||||
<ActionIcon
|
||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||
variant="default"
|
||||
size={44}
|
||||
aria-label={labels.settings}
|
||||
>
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuTarget>
|
||||
<BoardCardMenuDropdown board={board} />
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</CardSection>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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,7 +3,8 @@
|
||||
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";
|
||||
|
||||
@@ -11,29 +12,45 @@ import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-con
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
request?: string | null;
|
||||
documentationUrl?: string | null;
|
||||
getDraft: () => CustomWidgetAiDraft;
|
||||
getDiagnostics: () => readonly CustomWidgetAiDiagnostic[];
|
||||
}
|
||||
|
||||
export const CopyAiPromptButton = ({
|
||||
rawResponse,
|
||||
currentConfig,
|
||||
getDraft,
|
||||
getDiagnostics,
|
||||
request,
|
||||
documentationUrl,
|
||||
}: CopyAiPromptButtonProps) => {
|
||||
const t = useI18n("customWidget");
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
|
||||
|
||||
const handleAssistant = () => {
|
||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return;
|
||||
const prompt = buildCustomWidgetAssistantPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
getDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
getDiagnostics(),
|
||||
);
|
||||
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,
|
||||
getDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
getDiagnostics(),
|
||||
);
|
||||
await navigator.clipboard.writeText(value);
|
||||
showSuccessNotification({
|
||||
title: t("action.copyAiPrompt"),
|
||||
|
||||
@@ -20,10 +20,9 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { downloadJson } from "~/components/custom-widgets/download";
|
||||
@@ -40,7 +39,7 @@ interface WidgetRef {
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const t = useI18n("customWidget");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const tCommon = useI18n("common");
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
@@ -173,30 +172,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
openConfirmModal({
|
||||
title: t("action.delete"),
|
||||
children: t("action.deleteConfirm", { name: widget.name }),
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(
|
||||
{ id: widget.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
},
|
||||
);
|
||||
deleteMutation.mutate(
|
||||
{ id: widget.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -263,14 +256,15 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
</>
|
||||
)}
|
||||
{!widget.migrationRequired && (
|
||||
<Menu.Item
|
||||
<InlineConfirmMenuItem
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
onConfirm={handleDelete}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</Menu.Item>
|
||||
</InlineConfirmMenuItem>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
+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
|
||||
|
||||
Loaded 100 of 567 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user